feat: add channel navigation and user status indicators
Tab cycles focus to sidebar where Up/Down navigates channels and Enter joins the selected one. Muted/deafened users show status symbols in the channel tree.
This commit is contained in:
@@ -15,11 +15,12 @@ TUI Mumble client with voice support and push-to-talk.
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Cycle focus (chat input / channel tree) |
|
||||
| `F1` | Toggle self-deafen |
|
||||
| `F4` | Push-to-talk (configurable) |
|
||||
| `Enter` | Send message |
|
||||
| `Up` | Previous sent message (in chat input) |
|
||||
| `Down` | Next sent message (in chat input) |
|
||||
| `Enter` | Send message / join channel (sidebar) |
|
||||
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||
| `Down` | Next message (input) / next channel (sidebar) |
|
||||
| `q` | Quit |
|
||||
| `Ctrl+C` | Quit |
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ tuimble --host mumble.example.com --user myname
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `Tab` | Cycle focus (chat input / channel tree) |
|
||||
| `F1` | Toggle self-deafen |
|
||||
| `F4` | Push-to-talk (configurable) |
|
||||
| `Enter` | Send message |
|
||||
| `Up` | Previous sent message (in chat input) |
|
||||
| `Down` | Next sent message (in chat input) |
|
||||
| `Enter` | Send message / join channel (sidebar) |
|
||||
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||
| `Down` | Next message (input) / next channel (sidebar) |
|
||||
| `q` | Quit |
|
||||
| `Ctrl+C` | Quit |
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header, Input, RichLog, Static
|
||||
|
||||
from tuimble.audio import AudioPipeline
|
||||
from tuimble.client import Channel, MumbleClient
|
||||
from tuimble.client import Channel, MumbleClient, User
|
||||
from tuimble.config import Config, load_config
|
||||
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
|
||||
|
||||
@@ -45,6 +45,14 @@ class ServerStateChanged(Message):
|
||||
pass
|
||||
|
||||
|
||||
class ChannelSelected(Message):
|
||||
"""User selected a channel to join."""
|
||||
|
||||
def __init__(self, channel_id: int) -> None:
|
||||
super().__init__()
|
||||
self.channel_id = channel_id
|
||||
|
||||
|
||||
# -- widgets -----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -85,29 +93,67 @@ class StatusBar(Static):
|
||||
|
||||
|
||||
class ChannelTree(Static):
|
||||
"""Channel and user list."""
|
||||
"""Channel and user list with keyboard navigation."""
|
||||
|
||||
DEFAULT_WIDTH = 24
|
||||
can_focus = True
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._channels: dict[int, Channel] = {}
|
||||
self._users_by_channel: dict[int, list[str]] = {}
|
||||
self._users_by_channel: dict[int, list[User]] = {}
|
||||
self._channel_ids: list[int] = []
|
||||
self._focused_idx: int = 0
|
||||
self._my_channel_id: int | None = None
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
channels: dict[int, Channel],
|
||||
users_by_channel: dict[int, list[str]],
|
||||
users_by_channel: dict[int, list[User]],
|
||||
my_channel_id: int | None = None,
|
||||
) -> None:
|
||||
self._channels = channels
|
||||
self._users_by_channel = users_by_channel
|
||||
self._my_channel_id = my_channel_id
|
||||
self._channel_ids = self._build_channel_order()
|
||||
if self._channel_ids:
|
||||
self._focused_idx = max(
|
||||
0, min(self._focused_idx, len(self._channel_ids) - 1)
|
||||
)
|
||||
else:
|
||||
self._focused_idx = 0
|
||||
self.refresh()
|
||||
|
||||
def clear_state(self) -> None:
|
||||
self._channels = {}
|
||||
self._users_by_channel = {}
|
||||
self._channel_ids = []
|
||||
self._focused_idx = 0
|
||||
self._my_channel_id = None
|
||||
self.refresh()
|
||||
|
||||
def _build_channel_order(self) -> list[int]:
|
||||
"""Build flat list of channel IDs in tree order."""
|
||||
if not self._channels:
|
||||
return []
|
||||
order: list[int] = []
|
||||
root_id = self._find_root()
|
||||
self._collect_order(root_id, order)
|
||||
return order
|
||||
|
||||
def _collect_order(self, channel_id: int, order: list[int]) -> None:
|
||||
ch = self._channels.get(channel_id)
|
||||
if ch is None:
|
||||
return
|
||||
order.append(channel_id)
|
||||
children = sorted(
|
||||
(c for c in self._channels.values()
|
||||
if c.parent_id == channel_id and c.channel_id != channel_id),
|
||||
key=lambda c: c.name,
|
||||
)
|
||||
for child in children:
|
||||
self._collect_order(child.channel_id, order)
|
||||
|
||||
@property
|
||||
def _available_width(self) -> int:
|
||||
"""Usable character width (content area, excludes padding/border)."""
|
||||
@@ -127,6 +173,15 @@ class ChannelTree(Static):
|
||||
return text[:max_width]
|
||||
return text[: max_width - 3] + "..."
|
||||
|
||||
@staticmethod
|
||||
def _user_status(user: User) -> str:
|
||||
"""Return status indicator for a user."""
|
||||
if user.self_deaf or user.deaf:
|
||||
return " [#f7768e]\u2298[/]"
|
||||
if user.self_mute or user.mute:
|
||||
return " [#e0af68]\u2715[/]"
|
||||
return ""
|
||||
|
||||
def render(self) -> str:
|
||||
if not self._channels:
|
||||
return " Channels\n [dim]\u2514\u2500 (not connected)[/]"
|
||||
@@ -156,10 +211,30 @@ class ChannelTree(Static):
|
||||
w = self._available_width
|
||||
prefix = " " * indent
|
||||
branch = "\u2514\u2500" if is_last else "\u251c\u2500"
|
||||
# indent + 2 branch chars + 1 space
|
||||
name_max = w - indent - 3
|
||||
|
||||
is_current = channel_id == self._my_channel_id
|
||||
is_focused = (
|
||||
self.has_focus
|
||||
and self._channel_ids
|
||||
and self._focused_idx < len(self._channel_ids)
|
||||
and self._channel_ids[self._focused_idx] == channel_id
|
||||
)
|
||||
|
||||
marker = "\u25cf " if is_current else " "
|
||||
# indent + 2 branch + 1 space + 2 marker
|
||||
name_max = w - indent - 5
|
||||
name = self._truncate(ch.name, name_max)
|
||||
lines.append(f"{prefix}{branch} [bold]{name}[/]")
|
||||
|
||||
if is_focused:
|
||||
lines.append(
|
||||
f"{prefix}{branch} {marker}[reverse bold]{name}[/]"
|
||||
)
|
||||
elif is_current:
|
||||
lines.append(
|
||||
f"{prefix}{branch} {marker}[bold #9ece6a]{name}[/]"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{prefix}{branch} {marker}[bold]{name}[/]")
|
||||
|
||||
users = self._users_by_channel.get(channel_id, [])
|
||||
children = [
|
||||
@@ -171,12 +246,15 @@ class ChannelTree(Static):
|
||||
sub_indent = indent + 2
|
||||
sub_prefix = " " * sub_indent
|
||||
# sub_indent + 2 bullet chars + 1 space
|
||||
user_max = w - sub_indent - 3
|
||||
for i, user_name in enumerate(users):
|
||||
user_max = w - sub_indent - 5
|
||||
for i, user in enumerate(users):
|
||||
is_last_item = i == len(users) - 1 and not children
|
||||
bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500"
|
||||
uname = self._truncate(user_name, user_max)
|
||||
lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]")
|
||||
uname = self._truncate(user.name, user_max)
|
||||
status = self._user_status(user)
|
||||
lines.append(
|
||||
f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]{status}"
|
||||
)
|
||||
|
||||
for i, child in enumerate(children):
|
||||
self._render_tree(
|
||||
@@ -186,6 +264,27 @@ class ChannelTree(Static):
|
||||
is_last=i == len(children) - 1,
|
||||
)
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
if not self._channel_ids:
|
||||
return
|
||||
if event.key == "up":
|
||||
self._focused_idx = max(0, self._focused_idx - 1)
|
||||
self.refresh()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "down":
|
||||
self._focused_idx = min(
|
||||
len(self._channel_ids) - 1, self._focused_idx + 1
|
||||
)
|
||||
self.refresh()
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
elif event.key == "enter":
|
||||
cid = self._channel_ids[self._focused_idx]
|
||||
self.post_message(ChannelSelected(cid))
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
|
||||
class ChatLog(RichLog):
|
||||
"""Message log."""
|
||||
@@ -215,6 +314,9 @@ class TuimbleApp(App):
|
||||
padding: 0 1;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#sidebar:focus {
|
||||
border-right: solid #7aa2f7;
|
||||
}
|
||||
#chat-area {
|
||||
width: 1fr;
|
||||
}
|
||||
@@ -359,6 +461,19 @@ class TuimbleApp(App):
|
||||
def on_server_state_changed(self, _msg: ServerStateChanged) -> None:
|
||||
self._refresh_channel_tree()
|
||||
|
||||
def on_channel_selected(self, msg: ChannelSelected) -> None:
|
||||
if not self._client.connected:
|
||||
return
|
||||
ch = self._client.channels.get(msg.channel_id)
|
||||
name = ch.name if ch else str(msg.channel_id)
|
||||
try:
|
||||
self._client.join_channel(msg.channel_id)
|
||||
except Exception as exc:
|
||||
self._show_error(f"join failed: {exc}")
|
||||
return
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(f"[dim]\u2192 joined {name}[/dim]")
|
||||
|
||||
@on(Input.Submitted, "#input")
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
text = event.value.strip()
|
||||
@@ -408,13 +523,13 @@ class TuimbleApp(App):
|
||||
return
|
||||
channels = self._client.channels
|
||||
users = self._client.users
|
||||
users_by_channel: dict[int, list[str]] = {}
|
||||
users_by_channel: dict[int, list[User]] = {}
|
||||
for u in users.values():
|
||||
users_by_channel.setdefault(u.channel_id, []).append(u.name)
|
||||
users_by_channel.setdefault(u.channel_id, []).append(u)
|
||||
for lst in users_by_channel.values():
|
||||
lst.sort()
|
||||
lst.sort(key=lambda u: u.name)
|
||||
tree = self.query_one("#sidebar", ChannelTree)
|
||||
tree.set_state(channels, users_by_channel)
|
||||
tree.set_state(channels, users_by_channel, self._client.my_channel_id)
|
||||
|
||||
# -- deafen --------------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user