From b852459effc1f1af4af1eb6a41dad07c3148cdf0 Mon Sep 17 00:00:00 2001 From: Username Date: Tue, 24 Feb 2026 13:57:57 +0100 Subject: [PATCH] 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. --- README.md | 7 ++- docs/USAGE.md | 7 ++- src/tuimble/app.py | 145 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 138 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5ee99ca..4ff6fa9 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/USAGE.md b/docs/USAGE.md index 470ee3b..e116599 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 | diff --git a/src/tuimble/app.py b/src/tuimble/app.py index af984c7..b61642e 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -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 --------------------------------------------------------------