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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user