diff --git a/src/tuimble/app.py b/src/tuimble/app.py index b1e5ff8..a1e81ca 100644 --- a/src/tuimble/app.py +++ b/src/tuimble/app.py @@ -371,6 +371,7 @@ class TuimbleApp(App): ("f1", "toggle_deaf", "Deafen"), ("f2", "cycle_output_volume", "Vol Out"), ("f3", "cycle_input_volume", "Vol In"), + ("f5", "reload_config", "Reload"), ("q", "quit", "Quit"), ("ctrl+c", "quit", "Quit"), ] @@ -402,6 +403,7 @@ class TuimbleApp(App): ) self._audio.input_gain = acfg.input_gain self._audio.output_gain = acfg.output_gain + self._pending_reload: Config | None = None def compose(self) -> ComposeResult: yield Header() @@ -602,10 +604,169 @@ class TuimbleApp(App): chatlog = self.query_one("#chatlog", ChatLog) chatlog.write(f"[dim]input volume {pct}%[/dim]") + # -- config reload -------------------------------------------------------- + + def _detect_config_changes( + self, old: Config, new: Config, + ) -> tuple[list[str], list[str]]: + """Compare configs, return (safe_changes, restart_changes).""" + safe: list[str] = [] + restart: list[str] = [] + + if old.ptt.key != new.ptt.key: + safe.append(f"ptt key: {old.ptt.key} -> {new.ptt.key}") + if old.ptt.mode != new.ptt.mode: + safe.append(f"ptt mode: {old.ptt.mode} -> {new.ptt.mode}") + if old.ptt.backend != new.ptt.backend: + safe.append( + f"ptt backend: {old.ptt.backend} -> {new.ptt.backend}" + ) + if old.audio.input_gain != new.audio.input_gain: + safe.append( + f"input gain: {old.audio.input_gain} -> " + f"{new.audio.input_gain}" + ) + if old.audio.output_gain != new.audio.output_gain: + safe.append( + f"output gain: {old.audio.output_gain} -> " + f"{new.audio.output_gain}" + ) + + o_srv, n_srv = old.server, new.server + for attr in ("host", "port", "username", "password", + "certfile", "keyfile"): + ov, nv = getattr(o_srv, attr), getattr(n_srv, attr) + if ov != nv: + label = "password" if attr == "password" else attr + restart.append(f"server.{label} changed") + + o_aud, n_aud = old.audio, new.audio + for attr in ("input_device", "output_device", "sample_rate"): + ov, nv = getattr(o_aud, attr), getattr(n_aud, attr) + if ov != nv: + restart.append(f"audio.{attr} changed") + + return safe, restart + + def _apply_safe_changes(self, new: Config) -> None: + """Apply hot-reload-safe config changes immediately.""" + self._config.ptt = new.ptt + self._ptt = detect_backend( + self._on_ptt_change, new.ptt.backend + ) + + self._audio.input_gain = new.audio.input_gain + self._audio.output_gain = new.audio.output_gain + status = self.query_one("#status", StatusBar) + status.input_vol = int(new.audio.input_gain * 100) + status.output_vol = int(new.audio.output_gain * 100) + + def _apply_restart_changes(self, new: Config) -> None: + """Apply changes that require reconnect/audio restart.""" + chatlog = self.query_one("#chatlog", ChatLog) + old = self._config + + server_changed = ( + old.server.host != new.server.host + or old.server.port != new.server.port + or old.server.username != new.server.username + or old.server.password != new.server.password + or old.server.certfile != new.server.certfile + or old.server.keyfile != new.server.keyfile + ) + audio_hw_changed = ( + old.audio.input_device != new.audio.input_device + or old.audio.output_device != new.audio.output_device + or old.audio.sample_rate != new.audio.sample_rate + ) + + if server_changed: + self._audio.stop() + self._client.set_dispatcher(None) + self._client.disconnect() + 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._config = new + chatlog.write( + f"[dim]reconnecting to {srv.host}:{srv.port}...[/]" + ) + self._connect_to_server() + elif audio_hw_changed: + self._audio.stop() + acfg = new.audio + self._audio = AudioPipeline( + sample_rate=acfg.sample_rate, + frame_size=acfg.frame_size, + input_device=acfg.input_device, + output_device=acfg.output_device, + ) + self._audio.input_gain = acfg.input_gain + self._audio.output_gain = acfg.output_gain + self._config = new + if self._client.connected: + self._start_audio() + chatlog.write("[dim]audio pipeline restarted[/dim]") + + def action_reload_config(self) -> None: + """Reload config from disk (F5).""" + chatlog = self.query_one("#chatlog", ChatLog) + + if self._pending_reload is not None: + new = self._pending_reload + self._pending_reload = None + self._apply_restart_changes(new) + return + + try: + new = load_config() + except Exception as exc: + self._show_error(f"config reload: {exc}") + return + + safe, restart = self._detect_config_changes(self._config, new) + + if not safe and not restart: + chatlog.write("[dim]config unchanged[/dim]") + return + + self._apply_safe_changes(new) + self._config.audio.input_gain = new.audio.input_gain + self._config.audio.output_gain = new.audio.output_gain + + if safe: + for change in safe: + chatlog.write(f"[dim]\u2713 {change}[/dim]") + + if restart: + for change in restart: + chatlog.write(f"[#e0af68]\u26a0 {change}[/]") + chatlog.write( + "[dim]press F5 again to apply, " + "or any key to cancel[/dim]" + ) + self._pending_reload = new + else: + chatlog.write("[dim]\u2713 config reloaded[/dim]") + # -- PTT ----------------------------------------------------------------- def on_key(self, event: events.Key) -> None: """Handle input history navigation and PTT key events.""" + if self._pending_reload is not None and event.key != "f5": + self._pending_reload = None + chatlog = self.query_one("#chatlog", ChatLog) + chatlog.write("[dim]reload cancelled[/dim]") + focused = self.focused if isinstance(focused, Input) and event.key in ("up", "down"): inp = focused