From 2f7b1926401a2ad803927ff04d896294d4a5c345 Mon Sep 17 00:00:00 2001 From: Username Date: Tue, 24 Feb 2026 12:01:06 +0100 Subject: [PATCH] app: wire mumble connection, text chat, channel tree Connect to server on mount via @work(thread=True). Bridge pymumble callbacks to Textual messages (ServerConnected, ServerDisconnected, TextMessageReceived, ServerStateChanged). Render live channel/user tree recursively. Send text on input submit. Clean disconnect on quit. --- src/tuimble/app.py | 249 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 239 insertions(+), 10 deletions(-) diff --git a/src/tuimble/app.py b/src/tuimble/app.py index 62d5dcf..eef5e78 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -2,21 +2,56 @@ from __future__ import annotations -from textual import events +import html +import logging + +from textual import events, on, work from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical +from textual.message import Message from textual.reactive import reactive from textual.widgets import Footer, Header, Input, RichLog, Static +from tuimble.client import Channel, MumbleClient from tuimble.config import Config, load_config from tuimble.ptt import KittyPtt, TogglePtt, detect_backend +log = logging.getLogger(__name__) + + +# -- custom messages (pymumble thread -> Textual) ---------------------------- + + +class ServerConnected(Message): + pass + + +class ServerDisconnected(Message): + pass + + +class TextMessageReceived(Message): + def __init__(self, sender: str, text: str) -> None: + super().__init__() + self.sender = sender + self.text = text + + +class ServerStateChanged(Message): + """Channel or user list changed on the server.""" + + pass + + +# -- widgets ----------------------------------------------------------------- + class StatusBar(Static): """Connection and PTT status indicator.""" ptt_active = reactive(False) connected = reactive(False) + server_info = reactive("") def render(self) -> str: if self.connected: @@ -27,14 +62,91 @@ class StatusBar(Static): ptt = "[#e0af68]\u25cf[/] TX" else: ptt = "[#565f89]\u25cb[/] idle" - return f" {conn} {ptt}" + info = f" [dim]{self.server_info}[/]" if self.server_info else "" + return f" {conn} {ptt}{info}" class ChannelTree(Static): """Channel and user list.""" + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._channels: dict[int, Channel] = {} + self._users_by_channel: dict[int, list[str]] = {} + + def set_state( + self, + channels: dict[int, Channel], + users_by_channel: dict[int, list[str]], + ) -> None: + self._channels = channels + self._users_by_channel = users_by_channel + self.refresh() + + def clear_state(self) -> None: + self._channels = {} + self._users_by_channel = {} + self.refresh() + def render(self) -> str: - return " Channels\n [dim]\u2514\u2500 (not connected)[/]" + if not self._channels: + return " Channels\n [dim]\u2514\u2500 (not connected)[/]" + + lines = [" [bold]Channels[/]"] + root_id = self._find_root() + self._render_tree(root_id, lines, indent=1, is_last=True) + return "\n".join(lines) + + def _find_root(self) -> int: + for cid, ch in self._channels.items(): + if ch.parent_id == -1 or cid == 0: + return cid + return min(self._channels) if self._channels else 0 + + def _render_tree( + self, + channel_id: int, + lines: list[str], + indent: int, + is_last: bool, + ) -> None: + ch = self._channels.get(channel_id) + if ch is None: + return + + prefix = " " * indent + branch = "\u2514\u2500" if is_last else "\u251c\u2500" + lines.append(f"{prefix}{branch} [bold]{ch.name}[/]") + + users = self._users_by_channel.get(channel_id, []) + children = [ + c + for c in sorted(self._channels.values(), key=lambda c: c.name) + if c.parent_id == channel_id and c.channel_id != channel_id + ] + + sub_prefix = " " * (indent + 2) + for i, user_name in enumerate(users): + is_last_item = i == len(users) - 1 and not children + bullet = "\u2514\u2500" if is_last_item else "\u251c\u2500" + lines.append(f"{sub_prefix}{bullet} [#7aa2f7]{user_name}[/]") + + for i, child in enumerate(children): + self._render_tree( + child.channel_id, + lines, + indent + 2, + is_last=i == len(children) - 1, + ) + + +class ChatLog(RichLog): + """Message log.""" + + pass + + +# -- main app ---------------------------------------------------------------- class TuimbleApp(App): @@ -91,6 +203,13 @@ class TuimbleApp(App): self._ptt = detect_backend( self._on_ptt_change, self._config.ptt.backend ) + srv = self._config.server + self._client = MumbleClient( + host=srv.host, + port=srv.port, + username=srv.username, + password=srv.password, + ) def compose(self) -> ComposeResult: yield Header() @@ -102,10 +221,112 @@ class TuimbleApp(App): yield StatusBar(id="status") yield Footer() - def on_mount(self): + def on_mount(self) -> None: chatlog = self.query_one("#chatlog", ChatLog) chatlog.write("[dim]tuimble v0.1.0[/dim]") - chatlog.write("[dim]press q to quit[/dim]") + srv = self._config.server + chatlog.write(f"[dim]connecting to {srv.host}:{srv.port}...[/dim]") + self._connect_to_server() + + # -- server connection (worker thread) ----------------------------------- + + @work(thread=True) + def _connect_to_server(self) -> None: + self._client.set_dispatcher(self.call_from_thread) + self._client.on_connected = self._cb_connected + self._client.on_disconnected = self._cb_disconnected + self._client.on_text_message = self._cb_text_message + self._client.on_user_update = self._cb_state_changed + self._client.on_channel_update = self._cb_state_changed + + try: + self._client.connect() + except Exception as exc: + self.call_from_thread(self._show_error, str(exc)) + + def _cb_connected(self) -> None: + self.post_message(ServerConnected()) + + def _cb_disconnected(self) -> None: + self.post_message(ServerDisconnected()) + + def _cb_text_message(self, sender: str, text: str) -> None: + self.post_message(TextMessageReceived(sender, text)) + + def _cb_state_changed(self) -> None: + self.post_message(ServerStateChanged()) + + def _show_error(self, text: str) -> None: + chatlog = self.query_one("#chatlog", ChatLog) + chatlog.write(f"[#f7768e]\u2717 {text}[/]") + + # -- message handlers ---------------------------------------------------- + + def on_server_connected(self, _msg: ServerConnected) -> None: + status = self.query_one("#status", StatusBar) + status.connected = True + srv = self._config.server + status.server_info = f"{srv.host}:{srv.port}" + + chatlog = self.query_one("#chatlog", ChatLog) + chatlog.write( + f"[#9ece6a]\u2713 connected as {self._config.server.username}[/]" + ) + self._refresh_channel_tree() + + def on_server_disconnected(self, _msg: ServerDisconnected) -> None: + status = self.query_one("#status", StatusBar) + status.connected = False + status.server_info = "" + + chatlog = self.query_one("#chatlog", ChatLog) + chatlog.write("[#f7768e]\u2717 disconnected from server[/]") + + tree = self.query_one("#sidebar", ChannelTree) + tree.clear_state() + + def on_text_message_received(self, msg: TextMessageReceived) -> None: + chatlog = self.query_one("#chatlog", ChatLog) + # Strip HTML tags from Mumble messages + clean = _strip_html(msg.text) + chatlog.write(f"[#7aa2f7]{msg.sender}[/] {clean}") + + def on_server_state_changed(self, _msg: ServerStateChanged) -> None: + self._refresh_channel_tree() + + @on(Input.Submitted, "#input") + def on_input_submitted(self, event: Input.Submitted) -> None: + text = event.value.strip() + if not text: + return + event.input.clear() + + if not self._client.connected: + self._show_error("not connected") + return + + self._client.send_text(text) + chatlog = self.query_one("#chatlog", ChatLog) + chatlog.write( + f"[#e0af68]{self._config.server.username}[/] {text}" + ) + + # -- channel tree -------------------------------------------------------- + + def _refresh_channel_tree(self) -> None: + if not self._client.connected: + return + channels = self._client.channels + users = self._client.users + users_by_channel: dict[int, list[str]] = {} + for u in users.values(): + users_by_channel.setdefault(u.channel_id, []).append(u.name) + for lst in users_by_channel.values(): + lst.sort() + tree = self.query_one("#sidebar", ChannelTree) + tree.set_state(channels, users_by_channel) + + # -- PTT ----------------------------------------------------------------- def on_key(self, event: events.Key) -> None: """Handle PTT key events.""" @@ -122,13 +343,21 @@ class TuimbleApp(App): if event.key == ptt_key: self._ptt.toggle() - def _on_ptt_change(self, transmitting: bool): - """Called when PTT state changes.""" + def _on_ptt_change(self, transmitting: bool) -> None: status = self.query_one("#status", StatusBar) status.ptt_active = transmitting + # -- lifecycle ----------------------------------------------------------- -class ChatLog(RichLog): - """Message log.""" + def action_quit(self) -> None: + self._client.set_dispatcher(None) + self._client.disconnect() + self.exit() - pass + +def _strip_html(text: str) -> str: + """Remove HTML tags and unescape entities from Mumble messages.""" + import re + + clean = re.sub(r"<[^>]+>", "", text) + return html.unescape(clean)