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 __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.app import App, ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.message import Message
|
||||||
from textual.reactive import reactive
|
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.client import Channel, MumbleClient
|
||||||
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
|
||||||
|
|
||||||
|
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):
|
class StatusBar(Static):
|
||||||
"""Connection and PTT status indicator."""
|
"""Connection and PTT status indicator."""
|
||||||
|
|
||||||
ptt_active = reactive(False)
|
ptt_active = reactive(False)
|
||||||
connected = reactive(False)
|
connected = reactive(False)
|
||||||
|
server_info = reactive("")
|
||||||
|
|
||||||
def render(self) -> str:
|
def render(self) -> str:
|
||||||
if self.connected:
|
if self.connected:
|
||||||
@@ -27,14 +62,91 @@ class StatusBar(Static):
|
|||||||
ptt = "[#e0af68]\u25cf[/] TX"
|
ptt = "[#e0af68]\u25cf[/] TX"
|
||||||
else:
|
else:
|
||||||
ptt = "[#565f89]\u25cb[/] idle"
|
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):
|
class ChannelTree(Static):
|
||||||
"""Channel and user list."""
|
"""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:
|
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):
|
class TuimbleApp(App):
|
||||||
@@ -91,6 +203,13 @@ class TuimbleApp(App):
|
|||||||
self._ptt = detect_backend(
|
self._ptt = detect_backend(
|
||||||
self._on_ptt_change, self._config.ptt.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:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header()
|
yield Header()
|
||||||
@@ -102,10 +221,112 @@ class TuimbleApp(App):
|
|||||||
yield StatusBar(id="status")
|
yield StatusBar(id="status")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self) -> None:
|
||||||
chatlog = self.query_one("#chatlog", ChatLog)
|
chatlog = self.query_one("#chatlog", ChatLog)
|
||||||
chatlog.write("[dim]tuimble v0.1.0[/dim]")
|
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:
|
def on_key(self, event: events.Key) -> None:
|
||||||
"""Handle PTT key events."""
|
"""Handle PTT key events."""
|
||||||
@@ -122,13 +343,21 @@ class TuimbleApp(App):
|
|||||||
if event.key == ptt_key:
|
if event.key == ptt_key:
|
||||||
self._ptt.toggle()
|
self._ptt.toggle()
|
||||||
|
|
||||||
def _on_ptt_change(self, transmitting: bool):
|
def _on_ptt_change(self, transmitting: bool) -> None:
|
||||||
"""Called when PTT state changes."""
|
|
||||||
status = self.query_one("#status", StatusBar)
|
status = self.query_one("#status", StatusBar)
|
||||||
status.ptt_active = transmitting
|
status.ptt_active = transmitting
|
||||||
|
|
||||||
|
# -- lifecycle -----------------------------------------------------------
|
||||||
|
|
||||||
class ChatLog(RichLog):
|
def action_quit(self) -> None:
|
||||||
"""Message log."""
|
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