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:
Username
2026-02-24 13:57:57 +01:00
parent ec81fac507
commit b852459eff
3 changed files with 138 additions and 21 deletions

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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 --------------------------------------------------------------