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 |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
|
| `Tab` | Cycle focus (chat input / channel tree) |
|
||||||
| `F1` | Toggle self-deafen |
|
| `F1` | Toggle self-deafen |
|
||||||
| `F4` | Push-to-talk (configurable) |
|
| `F4` | Push-to-talk (configurable) |
|
||||||
| `Enter` | Send message |
|
| `Enter` | Send message / join channel (sidebar) |
|
||||||
| `Up` | Previous sent message (in chat input) |
|
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||||
| `Down` | Next sent message (in chat input) |
|
| `Down` | Next message (input) / next channel (sidebar) |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
| `Ctrl+C` | Quit |
|
| `Ctrl+C` | Quit |
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ tuimble --host mumble.example.com --user myname
|
|||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
|
| `Tab` | Cycle focus (chat input / channel tree) |
|
||||||
| `F1` | Toggle self-deafen |
|
| `F1` | Toggle self-deafen |
|
||||||
| `F4` | Push-to-talk (configurable) |
|
| `F4` | Push-to-talk (configurable) |
|
||||||
| `Enter` | Send message |
|
| `Enter` | Send message / join channel (sidebar) |
|
||||||
| `Up` | Previous sent message (in chat input) |
|
| `Up` | Previous message (input) / previous channel (sidebar) |
|
||||||
| `Down` | Next sent message (in chat input) |
|
| `Down` | Next message (input) / next channel (sidebar) |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
| `Ctrl+C` | Quit |
|
| `Ctrl+C` | Quit |
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from textual.reactive import reactive
|
|||||||
from textual.widgets import Footer, Header, Input, RichLog, Static
|
from textual.widgets import Footer, Header, Input, RichLog, Static
|
||||||
|
|
||||||
from tuimble.audio import AudioPipeline
|
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.config import Config, load_config
|
||||||
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
|
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
|
||||||
|
|
||||||
@@ -45,6 +45,14 @@ class ServerStateChanged(Message):
|
|||||||
pass
|
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 -----------------------------------------------------------------
|
# -- widgets -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -85,29 +93,67 @@ class StatusBar(Static):
|
|||||||
|
|
||||||
|
|
||||||
class ChannelTree(Static):
|
class ChannelTree(Static):
|
||||||
"""Channel and user list."""
|
"""Channel and user list with keyboard navigation."""
|
||||||
|
|
||||||
DEFAULT_WIDTH = 24
|
DEFAULT_WIDTH = 24
|
||||||
|
can_focus = True
|
||||||
|
|
||||||
def __init__(self, **kwargs) -> None:
|
def __init__(self, **kwargs) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._channels: dict[int, Channel] = {}
|
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(
|
def set_state(
|
||||||
self,
|
self,
|
||||||
channels: dict[int, Channel],
|
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:
|
) -> None:
|
||||||
self._channels = channels
|
self._channels = channels
|
||||||
self._users_by_channel = users_by_channel
|
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()
|
self.refresh()
|
||||||
|
|
||||||
def clear_state(self) -> None:
|
def clear_state(self) -> None:
|
||||||
self._channels = {}
|
self._channels = {}
|
||||||
self._users_by_channel = {}
|
self._users_by_channel = {}
|
||||||
|
self._channel_ids = []
|
||||||
|
self._focused_idx = 0
|
||||||
|
self._my_channel_id = None
|
||||||
self.refresh()
|
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
|
@property
|
||||||
def _available_width(self) -> int:
|
def _available_width(self) -> int:
|
||||||
"""Usable character width (content area, excludes padding/border)."""
|
"""Usable character width (content area, excludes padding/border)."""
|
||||||
@@ -127,6 +173,15 @@ class ChannelTree(Static):
|
|||||||
return text[:max_width]
|
return text[:max_width]
|
||||||
return text[: max_width - 3] + "..."
|
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:
|
def render(self) -> str:
|
||||||
if not self._channels:
|
if not self._channels:
|
||||||
return " Channels\n [dim]\u2514\u2500 (not connected)[/]"
|
return " Channels\n [dim]\u2514\u2500 (not connected)[/]"
|
||||||
@@ -156,10 +211,30 @@ class ChannelTree(Static):
|
|||||||
w = self._available_width
|
w = self._available_width
|
||||||
prefix = " " * indent
|
prefix = " " * indent
|
||||||
branch = "\u2514\u2500" if is_last else "\u251c\u2500"
|
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)
|
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, [])
|
users = self._users_by_channel.get(channel_id, [])
|
||||||
children = [
|
children = [
|
||||||
@@ -171,12 +246,15 @@ class ChannelTree(Static):
|
|||||||
sub_indent = indent + 2
|
sub_indent = indent + 2
|
||||||
sub_prefix = " " * sub_indent
|
sub_prefix = " " * sub_indent
|
||||||
# sub_indent + 2 bullet chars + 1 space
|
# sub_indent + 2 bullet chars + 1 space
|
||||||
user_max = w - sub_indent - 3
|
user_max = w - sub_indent - 5
|
||||||
for i, user_name in enumerate(users):
|
for i, user in enumerate(users):
|
||||||
is_last_item = i == len(users) - 1 and not children
|
is_last_item = i == len(users) - 1 and not children
|
||||||
bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500"
|
bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500"
|
||||||
uname = self._truncate(user_name, user_max)
|
uname = self._truncate(user.name, user_max)
|
||||||
lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]")
|
status = self._user_status(user)
|
||||||
|
lines.append(
|
||||||
|
f"{sub_prefix}{bullet} [#7aa2f7]{uname}[/]{status}"
|
||||||
|
)
|
||||||
|
|
||||||
for i, child in enumerate(children):
|
for i, child in enumerate(children):
|
||||||
self._render_tree(
|
self._render_tree(
|
||||||
@@ -186,6 +264,27 @@ class ChannelTree(Static):
|
|||||||
is_last=i == len(children) - 1,
|
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):
|
class ChatLog(RichLog):
|
||||||
"""Message log."""
|
"""Message log."""
|
||||||
@@ -215,6 +314,9 @@ class TuimbleApp(App):
|
|||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
#sidebar:focus {
|
||||||
|
border-right: solid #7aa2f7;
|
||||||
|
}
|
||||||
#chat-area {
|
#chat-area {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
}
|
}
|
||||||
@@ -359,6 +461,19 @@ class TuimbleApp(App):
|
|||||||
def on_server_state_changed(self, _msg: ServerStateChanged) -> None:
|
def on_server_state_changed(self, _msg: ServerStateChanged) -> None:
|
||||||
self._refresh_channel_tree()
|
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")
|
@on(Input.Submitted, "#input")
|
||||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
text = event.value.strip()
|
text = event.value.strip()
|
||||||
@@ -408,13 +523,13 @@ class TuimbleApp(App):
|
|||||||
return
|
return
|
||||||
channels = self._client.channels
|
channels = self._client.channels
|
||||||
users = self._client.users
|
users = self._client.users
|
||||||
users_by_channel: dict[int, list[str]] = {}
|
users_by_channel: dict[int, list[User]] = {}
|
||||||
for u in users.values():
|
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():
|
for lst in users_by_channel.values():
|
||||||
lst.sort()
|
lst.sort(key=lambda u: u.name)
|
||||||
tree = self.query_one("#sidebar", ChannelTree)
|
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 --------------------------------------------------------------
|
# -- deafen --------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user