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.
This commit is contained in:
Username
2026-02-24 12:01:06 +01:00
parent 5fecebaa12
commit 2f7b192640

View File

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