app: extract InputHistory, _make_client factory, cache _find_root
InputHistory encapsulates history navigation (up/down/push). _make_client() deduplicates 4 MumbleClient instantiation sites. _find_root() result cached in set_state() to avoid double lookup.
This commit is contained in:
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import time
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from textual import events, on, work
|
||||
@@ -15,20 +14,50 @@ from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Header, Input, RichLog, Static
|
||||
|
||||
from tuimble.audio import AudioPipeline
|
||||
from tuimble.client import Channel, ConnectionFailed, MumbleClient, User
|
||||
from tuimble.client import Channel, MumbleClient, User
|
||||
from tuimble.config import Config, load_config
|
||||
from tuimble.ptt import KittyPtt, TogglePtt, detect_backend
|
||||
from tuimble.reconnect import ReconnectManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
VOLUME_STEPS = (0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0)
|
||||
|
||||
RECONNECT_INITIAL = 2 # seconds before first retry
|
||||
RECONNECT_MAX = 30 # maximum backoff delay
|
||||
RECONNECT_RETRIES = 10 # attempts before giving up
|
||||
TREE_DEBOUNCE = 0.1 # seconds to coalesce state changes
|
||||
|
||||
|
||||
class InputHistory:
|
||||
"""Navigable command history for the chat input."""
|
||||
|
||||
def __init__(self):
|
||||
self._entries: list[str] = []
|
||||
self._idx: int = -1
|
||||
self._draft: str = ""
|
||||
|
||||
def push(self, text: str) -> None:
|
||||
self._entries.append(text)
|
||||
self._idx = -1
|
||||
|
||||
def up(self, current: str) -> str | None:
|
||||
if not self._entries:
|
||||
return None
|
||||
if self._idx == -1:
|
||||
self._draft = current
|
||||
self._idx = len(self._entries) - 1
|
||||
elif self._idx > 0:
|
||||
self._idx -= 1
|
||||
return self._entries[self._idx]
|
||||
|
||||
def down(self) -> str | None:
|
||||
if self._idx == -1:
|
||||
return None
|
||||
if self._idx < len(self._entries) - 1:
|
||||
self._idx += 1
|
||||
return self._entries[self._idx]
|
||||
self._idx = -1
|
||||
return self._draft
|
||||
|
||||
|
||||
def _next_volume(current: float) -> float:
|
||||
"""Cycle through VOLUME_STEPS, wrapping to 0.0 after max."""
|
||||
for step in VOLUME_STEPS:
|
||||
@@ -139,6 +168,7 @@ class ChannelTree(Static):
|
||||
self._channel_ids: list[int] = []
|
||||
self._focused_idx: int = 0
|
||||
self._my_channel_id: int | None = None
|
||||
self._root_id: int = 0
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
@@ -149,6 +179,7 @@ class ChannelTree(Static):
|
||||
self._channels = channels
|
||||
self._users_by_channel = users_by_channel
|
||||
self._my_channel_id = my_channel_id
|
||||
self._root_id = self._find_root() if channels else 0
|
||||
self._channel_ids = self._build_channel_order()
|
||||
if self._channel_ids:
|
||||
self._focused_idx = max(
|
||||
@@ -171,8 +202,7 @@ class ChannelTree(Static):
|
||||
if not self._channels:
|
||||
return []
|
||||
order: list[int] = []
|
||||
root_id = self._find_root()
|
||||
self._collect_order(root_id, order)
|
||||
self._collect_order(self._root_id, order)
|
||||
return order
|
||||
|
||||
def _collect_order(self, channel_id: int, order: list[int]) -> None:
|
||||
@@ -221,8 +251,7 @@ class ChannelTree(Static):
|
||||
|
||||
w = self._get_width()
|
||||
lines = [" [bold]Channels[/]"]
|
||||
root_id = self._find_root()
|
||||
self._render_tree(root_id, lines, indent=1, is_last=True, w=w)
|
||||
self._render_tree(self._root_id, lines, indent=1, is_last=True, w=w)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _find_root(self) -> int:
|
||||
@@ -392,18 +421,8 @@ 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,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
self._history: list[str] = []
|
||||
self._history_idx: int = -1
|
||||
self._history_draft: str = ""
|
||||
self._client = self._make_client()
|
||||
self._history = InputHistory()
|
||||
acfg = self._config.audio
|
||||
self._audio = AudioPipeline(
|
||||
sample_rate=acfg.sample_rate,
|
||||
@@ -415,10 +434,27 @@ class TuimbleApp(App):
|
||||
self._audio.output_gain = acfg.output_gain
|
||||
self._pending_reload: Config | None = None
|
||||
self._tree_refresh_timer = None
|
||||
self._reconnecting: bool = False
|
||||
self._reconnect_attempt: int = 0
|
||||
self._reconnect = ReconnectManager(
|
||||
connect_fn=self._reconnect_connect,
|
||||
on_attempt=self._reconnect_on_attempt,
|
||||
on_success=self._reconnect_on_success,
|
||||
on_failure=self._reconnect_on_failure,
|
||||
on_exhausted=self._reconnect_on_exhausted,
|
||||
)
|
||||
self._intentional_disconnect: bool = False
|
||||
|
||||
def _make_client(self, srv=None) -> MumbleClient:
|
||||
"""Create a MumbleClient from the current (or given) server config."""
|
||||
srv = srv or self._config.server
|
||||
return MumbleClient(
|
||||
host=srv.host,
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Horizontal(id="main"):
|
||||
@@ -481,97 +517,58 @@ class TuimbleApp(App):
|
||||
|
||||
# -- auto-reconnect ------------------------------------------------------
|
||||
|
||||
@work(thread=True)
|
||||
def _reconnect_loop(self) -> None:
|
||||
"""Retry connection with exponential backoff."""
|
||||
while self._reconnecting:
|
||||
self._reconnect_attempt += 1
|
||||
delay = min(
|
||||
RECONNECT_INITIAL * (2 ** (self._reconnect_attempt - 1)),
|
||||
RECONNECT_MAX,
|
||||
)
|
||||
self.call_from_thread(
|
||||
self._log_reconnect, self._reconnect_attempt, delay,
|
||||
)
|
||||
def _reconnect_connect(self) -> None:
|
||||
"""Called by ReconnectManager to attempt a new connection."""
|
||||
self._client.disconnect()
|
||||
self._client = self._make_client()
|
||||
self._wire_client_callbacks()
|
||||
self._client.connect()
|
||||
|
||||
elapsed = 0.0
|
||||
while elapsed < delay and self._reconnecting:
|
||||
time.sleep(0.5)
|
||||
elapsed += 0.5
|
||||
def _reconnect_on_attempt(self, attempt: int, delay: float) -> None:
|
||||
self.call_from_thread(self._log_reconnect, attempt, delay)
|
||||
|
||||
if not self._reconnecting:
|
||||
break
|
||||
def _reconnect_on_success(self) -> None:
|
||||
self.call_from_thread(self.post_message, ServerConnected())
|
||||
|
||||
try:
|
||||
self._client.disconnect()
|
||||
srv = self._config.server
|
||||
self._client = MumbleClient(
|
||||
host=srv.host,
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
self._wire_client_callbacks()
|
||||
self._client.connect()
|
||||
def _reconnect_on_failure(self, attempt: int, error: str) -> None:
|
||||
self.call_from_thread(self._show_error, f"attempt {attempt}: {error}")
|
||||
|
||||
self._reconnecting = False
|
||||
self._reconnect_attempt = 0
|
||||
self.call_from_thread(self._on_reconnect_success)
|
||||
return
|
||||
except ConnectionFailed as exc:
|
||||
if not exc.retryable:
|
||||
self.call_from_thread(
|
||||
self._show_error, f"rejected: {exc}",
|
||||
)
|
||||
self._reconnecting = False
|
||||
self.call_from_thread(self._on_reconnect_exhausted)
|
||||
return
|
||||
self.call_from_thread(
|
||||
self._show_error,
|
||||
f"attempt {self._reconnect_attempt}: {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.call_from_thread(
|
||||
self._show_error,
|
||||
f"attempt {self._reconnect_attempt}: {exc}",
|
||||
)
|
||||
def _reconnect_on_exhausted(self) -> None:
|
||||
self.call_from_thread(self._show_reconnect_exhausted)
|
||||
|
||||
if self._reconnect_attempt >= RECONNECT_RETRIES:
|
||||
self._reconnecting = False
|
||||
self.call_from_thread(self._on_reconnect_exhausted)
|
||||
return
|
||||
|
||||
def _log_reconnect(self, attempt: int, delay: int) -> None:
|
||||
def _log_reconnect(self, attempt: int, delay: float) -> None:
|
||||
"""Log reconnection attempt to chatlog."""
|
||||
from tuimble.reconnect import MAX_RETRIES
|
||||
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.reconnecting = True
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(
|
||||
f"[dim]reconnecting in {delay}s "
|
||||
f"(attempt {attempt}/{RECONNECT_RETRIES})...[/dim]"
|
||||
f"(attempt {attempt}/{MAX_RETRIES})...[/dim]"
|
||||
)
|
||||
|
||||
def _on_reconnect_success(self) -> None:
|
||||
"""Handle successful reconnection."""
|
||||
self.post_message(ServerConnected())
|
||||
|
||||
def _on_reconnect_exhausted(self) -> None:
|
||||
def _show_reconnect_exhausted(self) -> None:
|
||||
"""Handle all reconnection attempts exhausted."""
|
||||
from tuimble.reconnect import MAX_RETRIES
|
||||
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.reconnecting = False
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write(
|
||||
f"[#f7768e]reconnection failed after "
|
||||
f"{RECONNECT_RETRIES} attempts[/]"
|
||||
f"{MAX_RETRIES} attempts[/]"
|
||||
)
|
||||
chatlog.write("[dim]press F5 to retry manually[/dim]")
|
||||
|
||||
@work(thread=True)
|
||||
def _start_reconnect(self) -> None:
|
||||
"""Run the reconnect manager in a worker thread."""
|
||||
self._reconnect.run()
|
||||
|
||||
def _cancel_reconnect(self) -> None:
|
||||
"""Cancel an in-progress reconnect loop."""
|
||||
self._reconnecting = False
|
||||
self._reconnect_attempt = 0
|
||||
self._reconnect.cancel()
|
||||
try:
|
||||
status = self.query_one("#status", StatusBar)
|
||||
status.reconnecting = False
|
||||
@@ -606,15 +603,13 @@ class TuimbleApp(App):
|
||||
tree = self.query_one("#sidebar", ChannelTree)
|
||||
tree.clear_state()
|
||||
|
||||
if self._intentional_disconnect or self._reconnecting:
|
||||
if self._intentional_disconnect or self._reconnect.active:
|
||||
return
|
||||
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
chatlog.write("[#f7768e]\u2717 disconnected from server[/]")
|
||||
|
||||
self._reconnecting = True
|
||||
self._reconnect_attempt = 0
|
||||
self._reconnect_loop()
|
||||
self._start_reconnect()
|
||||
|
||||
def on_text_message_received(self, msg: TextMessageReceived) -> None:
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
@@ -648,8 +643,7 @@ class TuimbleApp(App):
|
||||
if not text:
|
||||
return
|
||||
event.input.clear()
|
||||
self._history.append(text)
|
||||
self._history_idx = -1
|
||||
self._history.push(text)
|
||||
|
||||
if not self._client.connected:
|
||||
self._show_error("not connected")
|
||||
@@ -819,18 +813,10 @@ class TuimbleApp(App):
|
||||
tree = self.query_one("#sidebar", ChannelTree)
|
||||
tree.clear_state()
|
||||
|
||||
srv = new.server
|
||||
self._client = MumbleClient(
|
||||
host=srv.host,
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
self._client = self._make_client(new.server)
|
||||
self._config = new
|
||||
chatlog.write(
|
||||
f"[dim]reconnecting to {srv.host}:{srv.port}...[/]"
|
||||
f"[dim]reconnecting to {new.server.host}:{new.server.port}...[/]"
|
||||
)
|
||||
self._connect_to_server()
|
||||
elif audio_hw_changed:
|
||||
@@ -854,20 +840,13 @@ class TuimbleApp(App):
|
||||
|
||||
Also serves as manual reconnect when disconnected.
|
||||
"""
|
||||
if self._reconnecting:
|
||||
if self._reconnect.active:
|
||||
self._cancel_reconnect()
|
||||
|
||||
if not self._client.connected:
|
||||
chatlog = self.query_one("#chatlog", ChatLog)
|
||||
self._client = self._make_client()
|
||||
srv = self._config.server
|
||||
self._client = MumbleClient(
|
||||
host=srv.host,
|
||||
port=srv.port,
|
||||
username=srv.username,
|
||||
password=srv.password,
|
||||
certfile=srv.certfile,
|
||||
keyfile=srv.keyfile,
|
||||
)
|
||||
chatlog.write(
|
||||
f"[dim]connecting to {srv.host}:{srv.port}...[/dim]"
|
||||
)
|
||||
@@ -927,27 +906,12 @@ class TuimbleApp(App):
|
||||
if isinstance(focused, Input) and event.key in ("up", "down"):
|
||||
inp = focused
|
||||
if event.key == "up":
|
||||
if not self._history:
|
||||
event.prevent_default()
|
||||
return
|
||||
if self._history_idx == -1:
|
||||
self._history_draft = inp.value
|
||||
self._history_idx = len(self._history) - 1
|
||||
elif self._history_idx > 0:
|
||||
self._history_idx -= 1
|
||||
inp.value = self._history[self._history_idx]
|
||||
inp.cursor_position = len(inp.value)
|
||||
else: # down
|
||||
if self._history_idx == -1:
|
||||
event.prevent_default()
|
||||
return
|
||||
if self._history_idx < len(self._history) - 1:
|
||||
self._history_idx += 1
|
||||
inp.value = self._history[self._history_idx]
|
||||
else:
|
||||
self._history_idx = -1
|
||||
inp.value = self._history_draft
|
||||
inp.cursor_position = len(inp.value)
|
||||
val = self._history.up(inp.value)
|
||||
else:
|
||||
val = self._history.down()
|
||||
if val is not None:
|
||||
inp.value = val
|
||||
inp.cursor_position = len(val)
|
||||
event.prevent_default()
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user