feat: smart Save — inline status, no restart for safe changes (v2.03.25)

Save now diffs old vs new config. Safe changes (language, sounds, LLM,
keywords, overlay) show "✓ Applied" in the header for 4s. Restart-required
changes (STT engine, hotkeys, mic, wakeword) show "⚠ restart needed for: …"
and highlight Save & Restart. No modal popup on Save.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-10 03:29:27 +02:00
parent 9c812a960e
commit 599eb08c5a
3 changed files with 75 additions and 11 deletions

View File

@ -9,6 +9,17 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased]
## [2.03.25] - 2026-06-10
### Changed
- **Smart Save — no restart popup anymore.** "Save" now diffs the changed
settings against what requires a daemon restart. If only safe settings
changed (language, sounds, LLM prompt, keywords, overlay, …) it shows
"✓ Settings applied" inline in the header bar for 4 s and closes the
dialog. If restart-required fields changed (STT engine, hotkeys,
microphone, wakeword server) it shows "⚠ Saved — restart needed for: …"
and highlights "Save & Restart" so you can act on it. No modal popups.
## [2.03.24] - 2026-06-10
### Fixed

View File

@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host
(not in a container) so it can type into any application via xdotool.
"""
__version__ = "2.03.24"
__version__ = "2.03.25"

View File

@ -754,14 +754,23 @@ class SettingsDialog:
_header = Gtk.HeaderBar()
_header.set_show_close_button(True)
_header.set_title("Blitztext — Settings")
_save_restart = Gtk.Button(label="Save & Restart")
_save_restart.get_style_context().add_class("suggested-action")
_save_restart.connect("clicked", lambda _b: self.dlg.response(RESP_SAVE_RESTART))
_header.pack_end(_save_restart)
self._save_restart_btn = Gtk.Button(label="Save & Restart")
self._save_restart_btn.get_style_context().add_class("suggested-action")
self._save_restart_btn.connect("clicked", lambda _b: self.dlg.response(RESP_SAVE_RESTART))
self._save_restart_btn.set_tooltip_text(
"Save and restart the daemon — required when changing STT engine, hotkeys, "
"microphone, or wakeword server.")
_header.pack_end(self._save_restart_btn)
_save = Gtk.Button(label="Save")
_save.connect("clicked", lambda _b: self.dlg.response(RESP_SAVE))
_save.set_tooltip_text("Save settings and apply what can be applied without restarting.")
_header.pack_end(_save)
# Status label shown after Save — inline, no modal popup
self._status_lbl = Gtk.Label()
self._status_lbl.set_margin_start(8); self._status_lbl.set_margin_end(4)
_header.set_custom_title(self._status_lbl)
_header.show_all()
self._status_lbl.hide() # hidden until first save
self.dlg.set_titlebar(_header)
_prov = Gtk.CssProvider()
@ -3058,18 +3067,62 @@ notebook.bt-nb tab:checked label {
buttons=Gtk.ButtonsType.OK, text=msg)
d.run(); d.destroy()
def _info(self, msg: str) -> None:
d = Gtk.MessageDialog(transient_for=self.dlg, modal=True, message_type=Gtk.MessageType.INFO,
buttons=Gtk.ButtonsType.OK, text=msg)
d.run(); d.destroy()
# ---- restart-diff fields ---------------------------------------------------
# Human-readable label → tuple of Config field names that must match.
_RESTART_FIELDS: list[tuple[str, tuple]] = [
("active STT engine", ("stt_active",)),
("STT engine settings", ("stt_engines",)),
("device / compute", ("device", "compute_type")),
("wakeword", ("wakeword_enabled", "wakeword_active",
"wakeword_engines")),
("hotkeys", ("input_mode", "push_to_talk",
"key_start", "key_stop", "key_send", "key_cancel")),
("microphone", ("mic",)),
]
@staticmethod
def _cfg_snapshot(cfg) -> dict:
import dataclasses
return dataclasses.asdict(cfg)
def _diff_requires_restart(self, snap_before: dict, snap_after: dict) -> list[str]:
reasons = []
for label, fields in self._RESTART_FIELDS:
if any(snap_before.get(f) != snap_after.get(f) for f in fields):
reasons.append(label)
return reasons
def _set_status(self, msg: str, *, warning: bool = False) -> None:
colour = "#e67e00" if warning else "#2a7d2a"
self._status_lbl.set_markup(
f'<span foreground="{colour}" size="small">{GLib.markup_escape_text(msg)}</span>')
self._status_lbl.show()
if warning:
self._save_restart_btn.get_style_context().add_class("suggested-action")
else:
self._save_restart_btn.get_style_context().remove_class("suggested-action")
def _clear_status(self) -> bool:
self._status_lbl.hide()
return False # don't repeat the timeout
def _on_response(self, dlg, resp):
if resp in (RESP_SAVE, RESP_SAVE_RESTART):
self._force_build_tabs() # ensure every tab's fields exist
self._force_build_tabs()
if resp == RESP_SAVE:
import copy
snap_before = self._cfg_snapshot(self.cfg)
if self._collect():
save(self.cfg)
self._info("Saved. Restart Blitztext to apply engine/hotkey changes.")
snap_after = self._cfg_snapshot(self.cfg)
needs = self._diff_requires_restart(snap_before, snap_after)
if needs:
self._set_status(
f"⚠ Saved — restart needed for: {', '.join(needs)}",
warning=True)
else:
self._set_status("✓ Settings applied")
GLib.timeout_add(4000, self._clear_status)
return
if resp == RESP_SAVE_RESTART:
if self._collect():