diff --git a/src/tuimble/app.py b/src/tuimble/app.py index bdf3a2c..6d37a79 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -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