diff --git a/CLAUDE.md b/CLAUDE.md
index f77c5a2..daa9561 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -31,14 +31,14 @@ python -c "import src; print('OK')"
The regression suite uses `python -m unittest discover -s tests -v`. Each backend module also has a `__main__` smoke test:
```powershell
-python -m src.icons # writes 3 test PNGs (32x32)
+python -m src.icons # writes 3 test PNGs (64x64)
python -m src.config # prints defaults, DPAPI roundtrip, creates APP_DIR
python -m src.audio # records 3s -> test.wav, prints duration + RMS
python -m src.hotkey # prints pressed/released (Windows only)
python -m src.injector "hello" # types into active window (Windows only)
python -m src.stt test.wav # transcribes (needs API config)
python -m src.rewrite "test sentense" # corrects text (needs API config)
-python -m src.settings_dialog # launches the 4-tab dialog standalone
+python -m src.settings_dialog # launches the settings dialog standalone (themed)
```
CLI scripts resolve credentials in this order: `load_config()` (QSettings + DPAPI) → backfill empty fields from a `.env` at cwd via `import_from_env()` → if still empty, print a setup message to stderr and `exit(1)`. No hardcoded provider defaults.
diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md
index d00e399..3f463eb 100644
--- a/docs/IMPLEMENTATION.md
+++ b/docs/IMPLEMENTATION.md
@@ -30,6 +30,10 @@ src/
├── recovery_dialog.py
├── icons.py
├── snackbar.py
+├── theme.py (Hush design system: tokens, fonts, QSS, window polish)
+├── assets/
+│ ├── fonts/ (Geist + Geist Mono, SIL OFL — see OFL.txt)
+│ └── icons/ (SVG glyphs referenced by the stylesheet)
├── utils.py
└── startup.py
requirements.txt (repo root)
@@ -454,7 +458,7 @@ class TrayState(Enum):
IDLE = "idle"; RECORDING = "recording"; PROCESSING = "processing"
def get_icon_pixmap(state: TrayState) -> QPixmap: ...
- """32x32 QPixmap from embedded base64 PNG. Grey=idle, red=recording, yellow=processing."""
+ """64x64 QPixmap from embedded base64 PNG. Grey=idle, ember=recording, tide blue=processing."""
def get_icon_bytes(state: TrayState) -> bytes: ...
"""Raw PNG bytes for testing without Qt."""
@@ -481,6 +485,27 @@ class RecordingSnackbar(QWidget):
Processing/idle reset the meter. No gain, speech-quality, or device-health claim."""
```
+### theme.py
+
+```python
+CANVAS, SURFACE, RAISED, HOVER, LINE, LINE_STRONG, TEXT, TEXT_2, TEXT_3: str # surface/text tokens
+EMBER, TIDE, MOSS, ROSE, AMBER: str # signal colors: recording, processing, success, error, warning
+
+def apply_theme(app: QApplication) -> None: ...
+ """Fusion style + bundled fonts, dark palette, app stylesheet; on Windows also a
+ dark native title bar for every window/dialog on first show. Idempotent."""
+def set_role(widget: QWidget, role: str) -> QWidget: ...
+ """Tag a widget for `[hush="role"]` selectors (title, section, hint, error, warning,
+ primary, ghost, danger, ...)."""
+def polish_menu(menu: QMenu) -> QMenu: ...
+ """Rounded, shadow-free popup; call before the menu is first shown."""
+def draw_mark(painter: QPainter, rect: QRectF, color: QColor) -> None: ...
+ """Paint the three-bar Screamer mark (also baked into the tray PNGs)."""
+```
+
+Appearance only: theme functions never alter widget state, signals or text.
+`main.py` and the standalone `settings_dialog` entry point call `apply_theme()`.
+
### settings_dialog.py
```python
@@ -496,7 +521,8 @@ class SettingsDialog(QDialog):
calibrate_fn: Callable[[int | None], float] | None = None,
refresh_devices_fn: Callable[[], list[tuple[int, str]]] | None = None,
): ...
- """6-tab dialog (General, STT, LLM, Audio, Vocabulary, Rewrite Profiles) prefilled from config.
+ """6-page dialog (General, STT, LLM, Audio, Vocabulary, Rewrite Profiles) prefilled from config.
+ Pages sit behind a sidebar whose `_tabs` object keeps QTabWidget index semantics.
Edits a copy; Apply and OK persist validated settings.
*devices*: list of (device_id, display_name) for the Audio tab.
*calibrate_fn*: fn(device_id) -> float for RMS calibration, after fresh identity validation.
@@ -544,7 +570,8 @@ No public exports. Entry point only:
| Rule | Detail |
|------|--------|
| Composition root | `main.py` imports all other modules. Nothing imports `main.py`. |
-| Settings dialog | `settings_dialog.py` imports `config.py`, `startup.py`, `utils.py`, and the shared `audio.py` device-resolution policy. |
+| Settings dialog | `settings_dialog.py` imports `config.py`, `startup.py`, `utils.py`, `theme.py`, and the shared `audio.py` device-resolution policy. |
+| Theme | UI modules (`settings_dialog.py`, `recovery_dialog.py`, `snackbar.py`, `main.py`) may import `theme.py`; `theme.py` imports only Qt. |
| Shared utilities | `audio.py`, `hotkey.py`, `stt.py`, `rewrite.py`, `injector.py`, `startup.py` may import `utils.py`. |
| Zero peer imports | The six backend modules must NOT import each other. |
| Config consumer | `stt.py` and `rewrite.py` import config value types and receive `AppConfig` as a parameter; `audio.py` and `hotkey.py` use config constants and value objects. |
diff --git a/screamer.spec b/screamer.spec
index fd28b4a..ae02885 100644
--- a/screamer.spec
+++ b/screamer.spec
@@ -8,7 +8,7 @@ a = Analysis(
["src/main.py"],
pathex=[],
binaries=[],
- datas=[],
+ datas=[("src/assets", "src/assets")],
hiddenimports=collect_submodules("PySide6") + ["sounddevice", "numpy", "httpx", "dotenv"],
hookspath=[],
hooksconfig={},
diff --git a/src/assets/fonts/Geist-Medium.ttf b/src/assets/fonts/Geist-Medium.ttf
new file mode 100644
index 0000000..3cab0ba
Binary files /dev/null and b/src/assets/fonts/Geist-Medium.ttf differ
diff --git a/src/assets/fonts/Geist-Regular.ttf b/src/assets/fonts/Geist-Regular.ttf
new file mode 100644
index 0000000..af75bea
Binary files /dev/null and b/src/assets/fonts/Geist-Regular.ttf differ
diff --git a/src/assets/fonts/Geist-SemiBold.ttf b/src/assets/fonts/Geist-SemiBold.ttf
new file mode 100644
index 0000000..863451c
Binary files /dev/null and b/src/assets/fonts/Geist-SemiBold.ttf differ
diff --git a/src/assets/fonts/GeistMono-Medium.ttf b/src/assets/fonts/GeistMono-Medium.ttf
new file mode 100644
index 0000000..6285046
Binary files /dev/null and b/src/assets/fonts/GeistMono-Medium.ttf differ
diff --git a/src/assets/fonts/GeistMono-Regular.ttf b/src/assets/fonts/GeistMono-Regular.ttf
new file mode 100644
index 0000000..64d9b51
Binary files /dev/null and b/src/assets/fonts/GeistMono-Regular.ttf differ
diff --git a/src/assets/fonts/OFL.txt b/src/assets/fonts/OFL.txt
new file mode 100644
index 0000000..04e95fc
--- /dev/null
+++ b/src/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg
new file mode 100644
index 0000000..4b1a9b5
--- /dev/null
+++ b/src/assets/icons/check.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/icons/chevron-down.svg b/src/assets/icons/chevron-down.svg
new file mode 100644
index 0000000..112b595
--- /dev/null
+++ b/src/assets/icons/chevron-down.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/icons/chevron-right.svg b/src/assets/icons/chevron-right.svg
new file mode 100644
index 0000000..b8ee3da
--- /dev/null
+++ b/src/assets/icons/chevron-right.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/icons/chevron-up.svg b/src/assets/icons/chevron-up.svg
new file mode 100644
index 0000000..641351a
--- /dev/null
+++ b/src/assets/icons/chevron-up.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/icons/radio-off.svg b/src/assets/icons/radio-off.svg
new file mode 100644
index 0000000..05279e5
--- /dev/null
+++ b/src/assets/icons/radio-off.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/icons/radio-on.svg b/src/assets/icons/radio-on.svg
new file mode 100644
index 0000000..d1449bf
--- /dev/null
+++ b/src/assets/icons/radio-on.svg
@@ -0,0 +1 @@
+
diff --git a/src/icons.py b/src/icons.py
index 8663a1a..9de69a9 100644
--- a/src/icons.py
+++ b/src/icons.py
@@ -1,4 +1,4 @@
-"""Embedded 32x32 PNG icons for tray states: idle, recording, processing."""
+"""Embedded 64x64 PNG icons for tray states: idle, recording, processing."""
from __future__ import annotations
@@ -14,22 +14,44 @@ class TrayState(Enum):
PROCESSING = "processing"
-# Embedded base64 PNG data (32x32 RGBA) for each tray state.
+# Embedded base64 PNG data (64x64 RGBA, the Hush waveform mark) for each tray state.
_EMBEDDED_PNG: dict[TrayState, bytes] = {
TrayState.IDLE: base64.b64decode(
- "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAR0lEQVR42u3WwQkAIAzF0O7P31kX"
- "8NBLDWICnvtAKK0ye7Ek6/QECBAgQIAAbPAVSHf4GAIH4F/QRSCXEX6aCcA34B+LyKbaY7Gl8Ci"
- "VH8wAAAAASUVORK5CYII="
+ "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAACM0lE"
+ "QVR4nO2asWobQRCG/zmldHCR8lTkOhkXATcqLfA7mBQCByNi/BTLPYWwMMIiDgSV6Q1S6QcwdqfGeoKo"
+ "lSaFL+msXaHR7B6ar/7Z/fVrd2ZgDzAMwzAMY18h7Q2dc1mzaPWY6RKEYwAA44mIh6+zl9uyLFeaflQD"
+ "6I/GeYNX9wA670gmS8q61xfncy1PDa2NnHPZ4eGn3yDqrJF9zsAnJ1+Of0ynU9bwlWlsAgDNotUD0WmA"
+ "tNMsWr2dG6pQC4CZLneh3Ra1AP4XPGntlnzQ2gjAgaRWqpvonQBB+qNxnhdHDwy6AaGNt8AOQGgz6CYv"
+ "jh76o3EeslbtAnDOZY3V8ifeb6UA0Gnw6t455/19tQtAupsE14BUJrhq/3AtMFinCToBkndua4S7iTcA"
+ "6TsngGg38RpOdYKTwhtAqhOcFP4jm+gEJ0XInRW9c6lRuzlAGgsgtoHYWACxDcTGAohtIDYWQGwDsbEA"
+ "YhuIjQUQ20BsLIDYBmITEsBig/U20SaBPwDGU/Bqm2gTwRsAEQ9DF9tEmwreAF5nL7cAJgFrTSptrfAG"
+ "UJblaklZF+tDmCwp6yq9DonWpKAucH1xPp/Pns8IfAXGY7XwAoxHAl/NZ89nat/1CNek4LfB6t8dwPPW"
+ "tmuIeMigdqjWp6ndHCBdk2oXgHRNql0AgGxNUvtQcnD36w/CX44W3799/bhLP//QOwGJTpRqAaQ6UaoF"
+ "kOpEufcfS6t2gaQmSsMwDMMw9p6/7sQDg8/K92sAAAAASUVORK5CYII="
),
TrayState.RECORDING: base64.b64decode(
- "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASklEQVR42mNgGAWjYCiCOxoa/7Hh"
- "UQeMOmDUAaMOGHXAqAMGzGK6OIRYy2nmiAF3wIBHAbGOGJCW0YA3zUYdMOAl4MgoiEYBrQAAaota"
- "8JSNLOoAAAAASUVORK5CYII="
+ "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAB/0lE"
+ "QVR4nO2aMW7CQBBF/1qBipyAI6RzQxkkWnIDKisSdwiF5YLcAYSoyAmgRaJNQ8cROEGoiORJgekIHoth"
+ "di3mVRYa7X6+d2dH3gEMwzAMw3hUnPaElKYRfrfvACWAeyl+3QFujkY8c1mWa+pRNYBG/TZyt4BD93IA"
+ "Noho4MarvZamSGsiStMI5L7+/fMA4NBF7haUpmq61CY6LXu8lsY5dItYFfQMACX3ib0NRQPOCU869jae"
+ "tCYC0JKMlTpNFFeAHDTqt3HcrgFMANfBybBW8TzBcbumUb/NGat2BkifJrUzQPo0YeeAcCo4Svj1GyUA"
+ "ptciWCtAcs/djuxpUmpAgBWc6GlSLjjQCk4KxhsLs4KTgmFAmBWcFJw9K7rnQqN+dYAwZoBvAb4xA3wL"
+ "8I0Z4FuAb8wA3wJ8Ywb4FuAbM8C3AN+YAb4F+IZjwKHCeFVig4DzSWzHH65KbBhwPonN+cNViQ2DcgMa"
+ "8QyETWkcYYNGPBPQpEqpAS7LckQ0uGrCubVF53ZINCexTgE3Xu3RjHsAhgB9FwMfiuchmnFPr69HNiex"
+ "7waLtzvFxbu2JV/Tzbg5gE6F2KvUrw4Qzkm1M0A6J9XOAEA2J6k1StLH2w/4N0cH97l8vqeeM5ptckFW"
+ "lJptckFWlHoGBFpRWrO01kRAaBWlYRiGYRiPzh8F89p1tUggvgAAAABJRU5ErkJggg=="
),
TrayState.PROCESSING: base64.b64decode(
- "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAASklEQVR42mNgGAWjYCiCO1tE/mPD"
- "ow4YdcCoA0YdMOqAUQcMmMV0cQixltPMEQPugAGPAmIdMSAtowFvmo06YMBLwJFREI0CWgEA4LGl"
- "8JJpBiYAAAAASUVORK5CYII="
+ "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAB/UlE"
+ "QVR4nO2aMWpCQRCG/3laWgoWHiEHEDQQwWtYBcFDWIiFh1DEyhxDsFDBA3gEiweWlvomRR6k0t0XJ7P7"
+ "cL4yDLuTz30zA7uAYRiGYRivCmlvOGZOsm06YMIngLf8z0diLJP3xmJClGnmoypgtD83KbuuAHTvhGw4"
+ "qfan7fpJK6dEa6Mxc0J8/cL9fx4AupRdV2NmtbzUNsq26QCMD4/QbrZNB/+eUI6agPybF499FjUB+C14"
+ "0rFPUdXaCEBNMlaqm2ieADFG+3PztkvXTJgBaOFHWA1Aiwmz2y5dj/bnps9apRMg3U1KJ0C6m3jXgFgm"
+ "uD90k/mjGK8TIPnNCSDaTZwCIpzgRLuJM+FYJzgpnAJineCk8DmyUU5wUvgIEP3mYqN0c4A0JiB0AqEx"
+ "AaETCI0JCJ1AaExA6ARCYwJCJxAaExA6gdCYgNAJhMZHwKXAekVio8BHwLHAekVio8ApgBhL38WKxMaC"
+ "++rovbEAsPFYa5PHlgqngAlRxkm1j8cSNpxU+0q3Q6I1yasLTNv1U6XT6BFjCOCQL3wBcCDGsNJp9BTf"
+ "9YjWJO+7wfzXncNx1/bfEGPJhJZvrCumdHOAdE0qnQDpmlQ6AYBsTdJ8I3SB/82Rs3pL1STNExDlRKkm"
+ "INaJUu9JaqQTpZqACCdKAPZc3jAMwzCMF+YbTHPprRi3kvgAAAAASUVORK5CYII="
),
}
@@ -41,7 +63,7 @@ class TrayState(Enum):
def get_icon_pixmap(state: TrayState) -> QPixmap:
- """32x32 QPixmap from embedded base64 PNG data (decoded once per state, then cached)."""
+ """64x64 QPixmap from embedded base64 PNG data (decoded once per state, then cached)."""
pixmap = _pixmap_cache.get(state)
if pixmap is None:
pixmap = QPixmap()
diff --git a/src/main.py b/src/main.py
index 11ed8ac..84d109f 100644
--- a/src/main.py
+++ b/src/main.py
@@ -54,7 +54,7 @@
save_config,
validate_config,
)
-from src import http_client
+from src import http_client, theme
from src.hotkey import HotkeyListener, HotkeyMode
from src.icons import TrayState, get_icon_pixmap
from src.injector import WindowIdentity, get_foreground_target, type_text
@@ -288,7 +288,7 @@ def _build_tray(self) -> None:
self._tray = QSystemTrayIcon(self)
self._tray.setIcon(QIcon(get_icon_pixmap(TrayState.IDLE)))
self._tray.setToolTip("Screamer — Idle")
- self._menu = QMenu()
+ self._menu = theme.polish_menu(QMenu())
self._tray.setContextMenu(self._menu)
self._rebuild_menu()
self._tray.activated.connect(self._on_tray_activated)
@@ -301,7 +301,7 @@ def _add_choice_submenu(
current: str,
on_select: Any,
) -> QMenu:
- submenu = QMenu(title, self._menu)
+ submenu = theme.polish_menu(QMenu(title, self._menu))
submenu.setEnabled(self._settings_dlg is None)
self._menu.addMenu(submenu)
group = QButtonGroup(submenu)
@@ -1386,6 +1386,7 @@ def main(argv: list[str] | None = None) -> None:
app = QApplication([])
app.setQuitOnLastWindowClosed(False)
+ theme.apply_theme(app)
directory = Path(APP_DIR).resolve()
tray_app: _TrayApp | None = None
diff --git a/src/recovery_dialog.py b/src/recovery_dialog.py
index 4ae048b..41676b3 100644
--- a/src/recovery_dialog.py
+++ b/src/recovery_dialog.py
@@ -1,8 +1,10 @@
"""On-demand plain-text recovery UI; main owns actions and records."""
from PySide6.QtCore import Signal, Qt
+from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QDialog,
+ QFrame,
QHBoxLayout,
QLabel,
QListWidget,
@@ -11,8 +13,27 @@
QVBoxLayout,
)
+from src import theme
from src.results import DictationRecord
+# Visual emphasis only: the primary copy action and the destructive actions.
+_BUTTON_ROLES = {
+ "Copy final": "primary",
+ "Delete": "danger",
+ "Clear all": "danger",
+ "Discard audio": "danger",
+}
+
+
+def _styled(label: QLabel, role: str, font: QFont) -> QLabel:
+ theme.set_role(label, role)
+ label.setFont(font)
+ return label
+
+
+def _section(text: str) -> QLabel:
+ return _styled(QLabel(text.upper()), "section", theme.ui_font(11, QFont.Weight.DemiBold, 8.0))
+
class RecoveryDialog(QDialog):
copy_requested = Signal(object, str)
@@ -26,34 +47,66 @@ class RecoveryDialog(QDialog):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Screamer — Recovery")
- self.resize(760, 620)
+ self.resize(940, 660)
self._records = []
self._candidate_id = None
self._busy = False
self._enabled = False
self._pending = False
layout = QVBoxLayout(self)
- self._notice = QLabel()
+ layout.setContentsMargins(32, 28, 32, 22)
+ layout.setSpacing(0)
+ heading = theme.set_role(QLabel("Recovery"), "title")
+ heading.setFont(theme.ui_font(22, QFont.Weight.DemiBold, -1.0))
+ layout.addWidget(heading)
+ layout.addSpacing(6)
+ self._notice = _styled(QLabel(), "hint", theme.ui_font(12))
self._notice.setTextFormat(Qt.TextFormat.PlainText)
self._notice.setWordWrap(True)
layout.addWidget(self._notice)
+ layout.addSpacing(22)
+
+ body = QHBoxLayout()
+ body.setSpacing(24)
+ layout.addLayout(body, 1)
+ side = QVBoxLayout()
+ side.setSpacing(8)
+ side.addWidget(_section("Dictations"))
self._list = QListWidget()
+ self._list.setFixedWidth(272)
+ self._list.setFont(theme.mono_font(12))
self._list.currentRowChanged.connect(self._select)
- layout.addWidget(self._list)
- self._metadata = QLabel()
+ side.addWidget(self._list, 1)
+ body.addLayout(side)
+
+ detail = QVBoxLayout()
+ detail.setSpacing(8)
+ body.addLayout(detail, 1)
+ self._metadata = _styled(QLabel(), "meta", theme.mono_font(12))
self._metadata.setTextFormat(Qt.TextFormat.PlainText)
self._metadata.setWordWrap(True)
- layout.addWidget(self._metadata)
+ detail.addWidget(_section("Details"))
+ detail.addWidget(self._metadata)
self._raw = QPlainTextEdit()
self._final = QPlainTextEdit()
for title, editor in (("Raw transcript", self._raw), ("Final / candidate", self._final)):
- layout.addWidget(QLabel(title))
+ detail.addSpacing(10)
+ detail.addWidget(_section(title))
editor.setReadOnly(True)
- layout.addWidget(editor)
- self._attempt = QLabel()
+ editor.setFont(theme.ui_font(14))
+ detail.addWidget(editor, 1)
+ detail.addSpacing(4)
+ self._attempt = _styled(QLabel(), "hint", theme.mono_font(11))
self._attempt.setTextFormat(Qt.TextFormat.PlainText)
self._attempt.setWordWrap(True)
- layout.addWidget(self._attempt)
+ detail.addWidget(self._attempt)
+
+ layout.addSpacing(18)
+ divider = QFrame()
+ divider.setObjectName("hushDivider")
+ divider.setFixedHeight(1)
+ layout.addWidget(divider)
+ layout.addSpacing(14)
self._buttons = {}
for actions in (
(
@@ -71,12 +124,17 @@ def __init__(self) -> None:
),
):
row = QHBoxLayout()
+ row.setSpacing(8)
layout.addLayout(row)
+ layout.addSpacing(8)
for title, callback in actions:
button = QPushButton(title)
button.clicked.connect(callback)
+ if title in _BUTTON_ROLES:
+ theme.set_role(button, _BUTTON_ROLES[title])
row.addWidget(button)
self._buttons[title] = button
+ row.addStretch()
self._select(-1)
def selected_record(self) -> DictationRecord | None:
diff --git a/src/settings_dialog.py b/src/settings_dialog.py
index 21bff57..12f72c7 100644
--- a/src/settings_dialog.py
+++ b/src/settings_dialog.py
@@ -11,8 +11,33 @@
import uuid
from typing import Callable
-from PySide6.QtCore import QCoreApplication, QEvent, Qt, QThread, Signal
-from PySide6.QtGui import QAction, QIcon, QKeyEvent, QMouseEvent, QPainter, QPalette, QPixmap
+from PySide6.QtCore import (
+ QAbstractAnimation,
+ QCoreApplication,
+ QEvent,
+ QObject,
+ QPointF,
+ QRectF,
+ QSize,
+ Qt,
+ QThread,
+ QVariantAnimation,
+ Signal,
+)
+from PySide6.QtGui import (
+ QAction,
+ QColor,
+ QFont,
+ QFontMetrics,
+ QGuiApplication,
+ QIcon,
+ QKeyEvent,
+ QMouseEvent,
+ QPainter,
+ QPainterPath,
+ QPen,
+ QPixmap,
+)
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
@@ -33,8 +58,13 @@
QPushButton,
QRadioButton,
QDoubleSpinBox,
+ QFrame,
+ QScrollArea,
+ QSizePolicy,
QSpinBox,
- QTabWidget,
+ QStackedWidget,
+ QStyle,
+ QStyleOptionFrame,
QVBoxLayout,
QWidget,
)
@@ -66,6 +96,7 @@
insecure_base_urls,
validate_config,
)
+from src import theme
from src.audio import resolve_device
from src.utils import APP_NAME, AppError, ScreamerError, log_duration
from src.startup import is_supported
@@ -105,17 +136,306 @@ def run(self) -> None:
def _eye_icon() -> QIcon:
- """Small glyph for the show/hide toggle (the project ships no icon assets)."""
- pixmap = QPixmap(16, 16)
+ """Painted eye glyph for the show/hide toggle (crisp at 2x device pixel ratio)."""
+ pixmap = QPixmap(32, 32)
+ pixmap.setDevicePixelRatio(2.0)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
- # Default pen is black — invisible on dark themes; follow the palette.
- painter.setPen(QApplication.palette().color(QPalette.ColorRole.Text))
- painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "\N{EYE}")
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ pen = QPen(QColor(theme.TEXT_2), 1.4)
+ painter.setPen(pen)
+ outline = QPainterPath(QPointF(1.5, 8.0))
+ outline.quadTo(8.0, 1.0, 14.5, 8.0)
+ outline.quadTo(8.0, 15.0, 1.5, 8.0)
+ painter.drawPath(outline)
+ painter.setBrush(QColor(theme.TEXT_2))
+ painter.drawEllipse(QPointF(8.0, 8.0), 2.0, 2.0)
painter.end()
return QIcon(pixmap)
+class ToggleSwitch(QCheckBox):
+ """A QCheckBox drawn as a settings row: label on the left, switch on the right.
+
+ Only painting differs from QCheckBox; checked state, signals and the whole
+ row being clickable keep standard checkbox behavior.
+ """
+
+ _TRACK_W = 34
+ _TRACK_H = 20
+
+ def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
+ super().__init__(text, parent)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ self._knob = 0.0
+ self._anim = QVariantAnimation(self)
+ self._anim.setDuration(140)
+ self._anim.valueChanged.connect(self._on_anim_value)
+ self.toggled.connect(self._on_toggled)
+
+ def _on_anim_value(self, value) -> None:
+ self._knob = float(value)
+ self.update()
+
+ def _on_toggled(self, checked: bool) -> None:
+ self._anim.stop()
+ if not self.isVisible():
+ self._knob = 1.0 if checked else 0.0
+ return
+ self._anim.setStartValue(self._knob)
+ self._anim.setEndValue(1.0 if checked else 0.0)
+ self._anim.start()
+
+ def sizeHint(self) -> QSize:
+ fm = self.fontMetrics()
+ return QSize(
+ fm.horizontalAdvance(self.text()) + 24 + self._TRACK_W,
+ max(fm.height(), self._TRACK_H) + 10,
+ )
+
+ def minimumSizeHint(self) -> QSize:
+ return self.sizeHint()
+
+ def hitButton(self, pos) -> bool:
+ return self.rect().contains(pos)
+
+ def paintEvent(self, event) -> None:
+ animating = self._anim.state() == QAbstractAnimation.State.Running
+ knob = self._knob if animating else (1.0 if self.isChecked() else 0.0)
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ if not self.isEnabled():
+ painter.setOpacity(0.4)
+ rect = self.rect()
+ track = QRectF(
+ rect.right() - self._TRACK_W - 1,
+ (rect.height() - self._TRACK_H) / 2,
+ self._TRACK_W,
+ self._TRACK_H,
+ )
+ painter.setPen(QColor(theme.TEXT))
+ painter.drawText(
+ rect.adjusted(0, 0, -(self._TRACK_W + 16), 0),
+ Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
+ self.text(),
+ )
+ off, on = QColor(theme.LINE_STRONG), QColor(theme.EMBER)
+ track_color = QColor(
+ round(off.red() + (on.red() - off.red()) * knob),
+ round(off.green() + (on.green() - off.green()) * knob),
+ round(off.blue() + (on.blue() - off.blue()) * knob),
+ )
+ radius = self._TRACK_H / 2
+ if self.hasFocus():
+ painter.setPen(QPen(theme.qcolor(theme.EMBER, 0.55), 1.5))
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ painter.drawRoundedRect(track.adjusted(-3, -3, 3, 3), radius + 3, radius + 3)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(track_color)
+ painter.drawRoundedRect(track, radius, radius)
+ knob_r = radius - 3
+ cx = track.left() + radius + (track.width() - 2 * radius) * knob
+ painter.setBrush(QColor("#FFF6F2") if knob > 0.5 else QColor(theme.TEXT_2))
+ painter.drawEllipse(QPointF(cx, track.center().y()), knob_r, knob_r)
+ painter.end()
+
+
+# Sidebar labels and page headers, keyed by the tab text passed to addTab().
+_PAGE_META = {
+ "General": ("General", "General", "How you trigger dictation and where the text goes."),
+ "STT": ("Speech", "Speech to text", "Transcription provider, language and fallback."),
+ "LLM": ("Rewrite", "AI rewrite", "Optional cleanup of the transcript by a language model."),
+ "Audio": ("Audio", "Audio", "Microphone input and silence detection."),
+ "Vocabulary": ("Vocabulary", "Vocabulary", "Preferred spellings for names and terms."),
+ "Profiles": ("Profiles", "Rewrite profiles", "Which prompt cleans up your text, and where."),
+}
+
+
+class _PageScroll(QScrollArea):
+ """Scrolls a settings page; its size hint follows the content up to the screen."""
+
+ def __init__(self, content: QWidget) -> None:
+ super().__init__()
+ self.setObjectName("hushScroll")
+ self.setFrameShape(QFrame.Shape.NoFrame)
+ self.setWidgetResizable(True)
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.viewport().setAutoFillBackground(False)
+ content.setObjectName("hushPageBody")
+ self.setWidget(content)
+ content.installEventFilter(self)
+
+ def eventFilter(self, watched, event) -> bool:
+ if watched is self.widget() and event.type() == QEvent.Type.LayoutRequest:
+ self.updateGeometry()
+ return super().eventFilter(watched, event)
+
+ def sizeHint(self) -> QSize:
+ content = self.widget()
+ hint = content.sizeHint()
+ width = hint.width()
+ height = content.heightForWidth(width) if content.hasHeightForWidth() else hint.height()
+ screen = self.screen() or QGuiApplication.primaryScreen()
+ cap = screen.availableGeometry().height() - 240 if screen is not None else 720
+ bar = self.verticalScrollBar().sizeHint().width()
+ return QSize(width + bar + 4, max(320, min(max(height, hint.height()), cap)))
+
+ def minimumSizeHint(self) -> QSize:
+ return self.sizeHint()
+
+
+class _SidebarTabs(QObject):
+ """Sidebar navigation + page stack exposing the QTabWidget calls the dialog uses.
+
+ Tab indexes, ``setCurrentIndex`` and ``setTabText`` keep their QTabWidget meaning.
+ """
+
+ def __init__(self, parent: QWidget) -> None:
+ super().__init__(parent)
+ self.nav = QListWidget()
+ self.nav.setObjectName("hushNav")
+ self.nav.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+ self.nav.setFrameShape(QFrame.Shape.NoFrame)
+ self.nav.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.nav.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ self.nav.setFont(theme.ui_font(13, QFont.Weight.Medium))
+ self.stack = QStackedWidget()
+ self._texts: list[str] = []
+ self.nav.currentRowChanged.connect(self._on_row_changed)
+
+ def _on_row_changed(self, row: int) -> None:
+ if row >= 0:
+ self.stack.setCurrentIndex(row)
+
+ def addTab(self, content: QWidget, text: str) -> int:
+ _nav, title, subtitle = _PAGE_META.get(text, (text, text, ""))
+ page = QWidget()
+ page.setObjectName("hushPage")
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(40, 32, 28, 8)
+ layout.setSpacing(0)
+ heading = theme.set_role(QLabel(title), "title")
+ heading.setFont(theme.ui_font(22, QFont.Weight.DemiBold, -1.0))
+ layout.addWidget(heading)
+ if subtitle:
+ sub = theme.set_role(QLabel(subtitle), "subtitle")
+ sub.setFont(theme.ui_font(13))
+ layout.addSpacing(6)
+ layout.addWidget(sub)
+ layout.addSpacing(22)
+ content.setMinimumWidth(560)
+ content.setContentsMargins(0, 0, 12, 16)
+ layout.addWidget(_PageScroll(content), 1)
+ self.stack.addWidget(page)
+ self._texts.append(text)
+ self.nav.addItem(QListWidgetItem())
+ self.setTabText(len(self._texts) - 1, text)
+ if self.nav.currentRow() < 0:
+ self.nav.setCurrentRow(0)
+ return len(self._texts) - 1
+
+ def setTabText(self, index: int, text: str) -> None:
+ self._texts[index] = text
+ base = next((key for key in _PAGE_META if text.startswith(key)), None)
+ label = _PAGE_META[base][0] + text[len(base) :] if base else text
+ item = self.nav.item(index)
+ item.setText(label)
+ item.setSizeHint(QSize(0, 36))
+ if "⚠" in text:
+ item.setForeground(QColor(theme.AMBER))
+ else:
+ item.setData(Qt.ItemDataRole.ForegroundRole, None)
+
+ def tabText(self, index: int) -> str:
+ return self._texts[index]
+
+ def setCurrentIndex(self, index: int) -> None:
+ self.nav.setCurrentRow(index)
+
+ def currentIndex(self) -> int:
+ return self.stack.currentIndex()
+
+ def count(self) -> int:
+ return len(self._texts)
+
+
+class _MarkWidget(QWidget):
+ """The Screamer mark, painted in ember."""
+
+ def __init__(self, size: int = 18) -> None:
+ super().__init__()
+ self.setFixedSize(size, size)
+
+ def paintEvent(self, event) -> None:
+ painter = QPainter(self)
+ theme.draw_mark(painter, QRectF(self.rect()), QColor(theme.EMBER))
+ painter.end()
+
+
+def _style_form(form: QFormLayout) -> QFormLayout:
+ """Airy, left-aligned form rows; drops the old trailing colons from row labels."""
+ in_card = isinstance(form.parentWidget(), QGroupBox)
+ form.setContentsMargins(*((20, 22, 20, 18) if in_card else (0, 0, 0, 0)))
+ form.setHorizontalSpacing(28)
+ form.setVerticalSpacing(12)
+ form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
+ form.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
+ form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
+ form.setRowWrapPolicy(QFormLayout.RowWrapPolicy.DontWrapRows)
+ for row in range(form.rowCount()):
+ item = form.itemAt(row, QFormLayout.ItemRole.LabelRole)
+ label = item.widget() if item is not None else None
+ if isinstance(label, QLabel):
+ label.setText(label.text().removesuffix(":"))
+ # Centre the caption on a single-line field; taller fields keep it on top.
+ label.setMinimumHeight(34)
+ label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
+ return form
+
+
+def _section(form: QFormLayout, text: str, *, first: bool = False) -> QLabel:
+ """Small uppercase section heading spanning the form."""
+ label = theme.set_role(QLabel(text.upper()), "section")
+ label.setFont(theme.ui_font(11, QFont.Weight.DemiBold, 8.0))
+ label.setContentsMargins(0, 0 if first else 18, 0, 0)
+ form.addRow(label)
+ return label
+
+
+def _hint(label: QLabel, role: str = "hint") -> QLabel:
+ theme.set_role(label, role)
+ label.setFont(theme.ui_font(12))
+ return label
+
+
+def _button_row(*buttons: QPushButton) -> QHBoxLayout:
+ row = QHBoxLayout()
+ row.setSpacing(8)
+ for button in buttons:
+ row.addWidget(button)
+ row.addStretch()
+ return row
+
+
+def _card_form(group: QGroupBox) -> QFormLayout:
+ """Form inside a Hush card (QGroupBox); margins leave room for the card padding."""
+ return QFormLayout(group)
+
+
+def _leading(widget: QWidget) -> QHBoxLayout:
+ """Keep a compact field at its natural width instead of stretching it."""
+ row = QHBoxLayout()
+ row.addWidget(widget)
+ row.addStretch()
+ return row
+
+
+def _mono(*widgets: QWidget) -> None:
+ for widget in widgets:
+ widget.setFont(theme.mono_font(13))
+
+
class PasswordField(QLineEdit):
"""Masked line edit with an explicit trailing show/hide toggle.
@@ -191,10 +511,36 @@ def get_config(self) -> AppConfig:
# ------------------------------------------------------------------
def _build_ui(self) -> None:
- root = QVBoxLayout(self)
-
- self._tabs = QTabWidget()
- root.addWidget(self._tabs)
+ root = QHBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+
+ self._tabs = _SidebarTabs(self)
+
+ sidebar = QFrame()
+ sidebar.setObjectName("hushSidebar")
+ sidebar.setFixedWidth(204)
+ side = QVBoxLayout(sidebar)
+ side.setContentsMargins(14, 22, 14, 18)
+ side.setSpacing(0)
+ brand = QHBoxLayout()
+ brand.setContentsMargins(10, 0, 0, 0)
+ brand.setSpacing(10)
+ brand.addWidget(_MarkWidget(18))
+ brand_name = theme.set_role(QLabel(APP_NAME), "brand")
+ brand_name.setFont(theme.ui_font(15, QFont.Weight.DemiBold, -0.5))
+ brand.addWidget(brand_name)
+ brand.addStretch()
+ side.addLayout(brand)
+ side.addSpacing(26)
+ side.addWidget(self._tabs.nav, 1)
+ root.addWidget(sidebar)
+
+ main = QVBoxLayout()
+ main.setContentsMargins(0, 0, 0, 0)
+ main.setSpacing(0)
+ main.addWidget(self._tabs.stack, 1)
+ root.addLayout(main, 1)
self._build_general_tab()
self._build_stt_tab()
@@ -204,9 +550,9 @@ def _build_ui(self) -> None:
self._build_profiles_tab()
# Bottom bar.
- btn_import = QPushButton("Import from .env")
+ btn_import = theme.set_role(QPushButton("Import from .env"), "ghost")
btn_import.clicked.connect(self._on_import_env)
- btn_reset = QPushButton("Reset to Defaults")
+ btn_reset = theme.set_role(QPushButton("Reset to Defaults"), "ghost")
btn_reset.clicked.connect(self._on_reset)
self._button_box = QDialogButtonBox(
@@ -220,12 +566,16 @@ def _build_ui(self) -> None:
self._on_apply
)
- bottom = QHBoxLayout()
+ footer = QFrame()
+ footer.setObjectName("hushFooter")
+ bottom = QHBoxLayout(footer)
+ bottom.setContentsMargins(32, 14, 24, 14)
+ bottom.setSpacing(4)
bottom.addWidget(btn_import)
bottom.addWidget(btn_reset)
bottom.addStretch()
bottom.addWidget(self._button_box)
- root.addLayout(bottom)
+ main.addWidget(footer)
# --- General tab ---------------------------------------------------
@@ -235,6 +585,7 @@ def _build_general_tab(self) -> None:
self._captured_hotkey: Hotkey | None = None
+ _section(form, "Shortcut", first=True)
self._hotkey_combo = QComboBox()
for key, label in HOTKEY_OPTIONS:
self._hotkey_combo.addItem(label, key)
@@ -249,22 +600,27 @@ def _build_general_tab(self) -> None:
self._hotkey_record_btn.setCheckable(True)
self._hotkey_record_btn.clicked.connect(self._on_hotkey_record_clicked)
capture_row = QHBoxLayout()
+ capture_row.setSpacing(8)
capture_row.addWidget(self._hotkey_capture, 1)
capture_row.addWidget(self._hotkey_record_btn)
form.addRow("", capture_row)
- self._hotkey_error = QLabel("")
- self._hotkey_error.setStyleSheet("color: #c0392b;")
+ self._hotkey_error = _hint(QLabel(""), "error")
self._hotkey_error.setVisible(False)
form.addRow("", self._hotkey_error)
self._mode_hold = QRadioButton("Hold to talk")
self._mode_toggle = QRadioButton("Toggle")
+ for radio in (self._mode_hold, self._mode_toggle):
+ radio.setMinimumHeight(34)
mode_row = QHBoxLayout()
+ mode_row.setSpacing(24)
mode_row.addWidget(self._mode_hold)
mode_row.addWidget(self._mode_toggle)
+ mode_row.addStretch()
form.addRow("Recording mode:", mode_row)
+ _section(form, "Output")
self._post_key_combo = QComboBox()
for key, label in POST_KEY_OPTIONS:
self._post_key_combo.addItem(label, key)
@@ -286,9 +642,11 @@ def _build_general_tab(self) -> None:
"Mouse bindings are not supported. Must differ from the dictation shortcut. "
"The registered-shortcut conflict check cannot detect other low-level-hook apps."
)
+ _mono(self._recovery_hotkey_input)
form.addRow("Recovery shortcut:", self._recovery_hotkey_input)
- self._history_check = QCheckBox("Save local text history (opt-in)")
+ _section(form, "History")
+ self._history_check = ToggleSwitch("Save local text history (opt-in)")
form.addRow(self._history_check)
disclosure = QLabel(
"History may contain dictated text, AI prompts you dictate, and spoken secrets. "
@@ -298,20 +656,23 @@ def _build_general_tab(self) -> None:
"saved history until you use Clear History."
)
disclosure.setWordWrap(True)
- form.addRow(disclosure)
+ form.addRow(_hint(disclosure))
self._history_limit_spin = QSpinBox()
self._history_limit_spin.setRange(1, 100)
- form.addRow("Retain latest results:", self._history_limit_spin)
- self._clear_history_btn = QPushButton("Clear History…")
+ self._history_limit_spin.setMinimumWidth(96)
+ form.addRow("Retain latest results:", _leading(self._history_limit_spin))
+ self._clear_history_btn = theme.set_role(QPushButton("Clear History…"), "danger")
self._clear_history_btn.clicked.connect(self._on_clear_history)
- form.addRow(self._clear_history_btn)
+ form.addRow(_button_row(self._clear_history_btn))
- self._startup_check = QCheckBox("Start Screamer with Windows")
+ _section(form, "System")
+ self._startup_check = ToggleSwitch("Start Screamer with Windows")
if not is_supported():
self._startup_check.setEnabled(False)
self._startup_check.setToolTip("Windows only")
form.addRow(self._startup_check)
+ _style_form(form)
self._tabs.addTab(tab, "General")
def _on_clear_history(self) -> None:
@@ -390,17 +751,19 @@ def _build_stt_tab(self) -> None:
tab = QWidget()
form = QFormLayout(tab)
+ _section(form, "Primary provider", first=True)
self._stt_key, self._stt_url, self._stt_model, self._stt_headers = _add_provider_fields(
form,
base_url_placeholder="https://api.openai.com/v1",
)
- self._stt_prompt_primary_check = QCheckBox("Send vocabulary prompt to primary STT")
+ self._stt_prompt_primary_check = ToggleSwitch("Send vocabulary prompt to primary STT")
self._stt_prompt_primary_check.setToolTip(
"Enable only if this endpoint accepts the multipart prompt field. "
"A rejected prompt follows the normal fallback path, without retrying it removed."
)
form.addRow(self._stt_prompt_primary_check)
+ _section(form, "Language")
self._stt_lang = QComboBox()
form.addRow("Active language:", self._stt_lang)
@@ -411,25 +774,24 @@ def _build_stt_tab(self) -> None:
self._stt_favorite_input = QLineEdit()
self._stt_favorite_input.setPlaceholderText("ISO 639-1 language code, e.g. de or pt")
+ _mono(self._stt_favorite_input)
form.addRow("Favorite code:", self._stt_favorite_input)
btn_add = QPushButton("Add")
btn_add.clicked.connect(self._on_favorite_add)
btn_edit = QPushButton("Edit selected")
btn_edit.clicked.connect(self._on_favorite_edit)
- btn_remove = QPushButton("Remove selected")
+ btn_remove = theme.set_role(QPushButton("Remove selected"), "danger")
btn_remove.clicked.connect(self._on_favorite_remove)
- favorites_buttons = QHBoxLayout()
- for button in (btn_add, btn_edit, btn_remove):
- favorites_buttons.addWidget(button)
- form.addRow("", favorites_buttons)
+ form.addRow("", _button_row(btn_add, btn_edit, btn_remove))
# --- Fallback ---
- self._stt_fb_check = QCheckBox("Enable fallback STT provider")
+ _section(form, "Fallback")
+ self._stt_fb_check = ToggleSwitch("Enable fallback STT provider")
form.addRow(self._stt_fb_check)
self._stt_fb_group = QGroupBox("Fallback STT")
- fb_form = QFormLayout(self._stt_fb_group)
+ fb_form = _card_form(self._stt_fb_group)
self._stt_fb_key, self._stt_fb_url, self._stt_fb_model, self._stt_fb_headers = (
_add_provider_fields(fb_form)
@@ -443,14 +805,16 @@ def _build_stt_tab(self) -> None:
"Enter the base URL, not the full transcription route. "
"Screamer appends /audio/transcriptions."
)
- self._stt_prompt_fallback_check = QCheckBox("Send vocabulary prompt to fallback STT")
+ self._stt_prompt_fallback_check = ToggleSwitch("Send vocabulary prompt to fallback STT")
self._stt_prompt_fallback_check.setToolTip(self._stt_prompt_primary_check.toolTip())
fb_form.addRow(self._stt_prompt_fallback_check)
+ _style_form(fb_form)
self._stt_fb_group.setVisible(False)
self._stt_fb_check.toggled.connect(self._stt_fb_group.setVisible)
form.addRow(self._stt_fb_group)
+ _style_form(form)
self._tabs.addTab(tab, "STT")
def _refresh_language_choices(self, active: str | None = None) -> None:
@@ -535,11 +899,11 @@ def _build_llm_tab(self) -> None:
tab = QWidget()
form = QFormLayout(tab)
- self._llm_check = QCheckBox("Enable AI rewrite")
+ self._llm_check = ToggleSwitch("Enable AI rewrite")
form.addRow(self._llm_check)
self._llm_group = QGroupBox("LLM Settings")
- llm_form = QFormLayout(self._llm_group)
+ llm_form = _card_form(self._llm_group)
self._llm_key, self._llm_url, self._llm_model, self._llm_headers = _add_provider_fields(
llm_form
@@ -547,39 +911,43 @@ def _build_llm_tab(self) -> None:
self._llm_prompt = QPlainTextEdit()
self._llm_prompt.setMaximumHeight(120)
+ self._llm_prompt.setFont(theme.mono_font(12))
self._llm_prompt.setTabChangesFocus(True)
self._llm_prompt.textChanged.connect(self._on_profile_prompt_changed)
llm_form.addRow("System Prompt:", self._llm_prompt)
self._llm_reset_prompt_btn = QPushButton("Reset to Current Default")
self._llm_reset_prompt_btn.clicked.connect(self._on_reset_prompt)
- llm_form.addRow(self._llm_reset_prompt_btn)
+ llm_form.addRow(_button_row(self._llm_reset_prompt_btn))
prompt_hint = QLabel(
"Saved prompts are kept unchanged on upgrade. Reset explicitly selects the current "
"cleanup default. Models can still change meaning; review important dictation."
)
prompt_hint.setWordWrap(True)
- llm_form.addRow(prompt_hint)
+ llm_form.addRow(_hint(prompt_hint))
# --- LLM Fallback ---
- self._llm_fb_check = QCheckBox("Enable fallback LLM provider")
+ self._llm_fb_check = ToggleSwitch("Enable fallback LLM provider")
llm_form.addRow(self._llm_fb_check)
self._llm_fb_group = QGroupBox("Fallback LLM")
- fb_form = QFormLayout(self._llm_fb_group)
+ fb_form = _card_form(self._llm_fb_group)
self._llm_fb_key, self._llm_fb_url, self._llm_fb_model, self._llm_fb_headers = (
_add_provider_fields(fb_form)
)
+ _style_form(fb_form)
self._llm_fb_group.setVisible(False)
self._llm_fb_check.toggled.connect(self._llm_fb_group.setVisible)
llm_form.addRow(self._llm_fb_group)
+ _style_form(llm_form)
self._llm_group.setVisible(False)
self._llm_check.toggled.connect(self._llm_group.setVisible)
form.addRow(self._llm_group)
+ _style_form(form)
self._tabs.addTab(tab, "LLM")
def _on_reset_prompt(self) -> None:
@@ -615,71 +983,73 @@ def _build_profiles_tab(self) -> None:
"100-character names, 32 KiB UTF-8 prompts."
)
hint.setWordWrap(True)
- form.addRow(hint)
+ form.addRow(_hint(hint))
self._rewrite_warning = QLabel(
"Saved rewrite catalog is damaged. Its exact value is retained during unrelated "
"saves. Explicitly Reset or Replace to repair; Apply/OK saves the repair, "
"Cancel discards it. Rewrite resolution is unavailable until repaired."
)
self._rewrite_warning.setWordWrap(True)
- form.addRow(self._rewrite_warning)
+ form.addRow(_hint(self._rewrite_warning, "warning"))
self._rewrite_reset_btn = QPushButton("Reset damaged catalog…")
self._rewrite_replace_btn = QPushButton("Replace damaged catalog…")
self._rewrite_reset_btn.clicked.connect(lambda: self._on_catalog_repair("Reset"))
self._rewrite_replace_btn.clicked.connect(lambda: self._on_catalog_repair("Replace"))
- row = QHBoxLayout()
- row.addWidget(self._rewrite_reset_btn)
- row.addWidget(self._rewrite_replace_btn)
- form.addRow(row)
+ form.addRow(_button_row(self._rewrite_reset_btn, self._rewrite_replace_btn))
self._rewrite_mode_combo = QComboBox()
self._rewrite_mode_combo.addItem("Manual", "manual")
self._rewrite_mode_combo.addItem("Automatic (application mappings)", "automatic")
self._rewrite_default_combo = QComboBox()
self._rewrite_manual_combo = QComboBox()
+ _section(form, "Selection")
form.addRow("Selection mode:", self._rewrite_mode_combo)
form.addRow("Default profile:", self._rewrite_default_combo)
form.addRow("Manual profile:", self._rewrite_manual_combo)
self._rewrite_profile_combo = QComboBox()
self._rewrite_profile_combo.currentIndexChanged.connect(self._on_profile_selected)
+ _section(form, "Profiles")
form.addRow("Profile to edit:", self._rewrite_profile_combo)
self._rewrite_edit_prompt_btn = QPushButton("Edit selected prompt on LLM tab")
self._rewrite_edit_prompt_btn.clicked.connect(self._on_edit_profile_prompt)
- form.addRow(self._rewrite_edit_prompt_btn)
+ form.addRow("", _button_row(self._rewrite_edit_prompt_btn))
self._rewrite_name_input = QLineEdit()
form.addRow("Custom profile name:", self._rewrite_name_input)
self._rewrite_add_btn = QPushButton("Add custom")
self._rewrite_rename_btn = QPushButton("Rename selected")
- self._rewrite_delete_btn = QPushButton("Delete selected")
+ self._rewrite_delete_btn = theme.set_role(QPushButton("Delete selected"), "danger")
self._rewrite_add_btn.clicked.connect(self._on_profile_add)
self._rewrite_rename_btn.clicked.connect(self._on_profile_rename)
self._rewrite_delete_btn.clicked.connect(self._on_profile_delete)
- row = QHBoxLayout()
- for button in (self._rewrite_add_btn, self._rewrite_rename_btn, self._rewrite_delete_btn):
- row.addWidget(button)
- form.addRow(row)
+ form.addRow(
+ "",
+ _button_row(self._rewrite_add_btn, self._rewrite_rename_btn, self._rewrite_delete_btn),
+ )
+ _section(form, "Application mappings")
self._rewrite_mappings = QListWidget()
self._rewrite_mappings.setMaximumHeight(140)
self._rewrite_mappings.itemSelectionChanged.connect(self._on_mapping_selected)
form.addRow("Application mappings:", self._rewrite_mappings)
self._rewrite_path_input = QLineEdit()
self._rewrite_path_input.setPlaceholderText(r"C:\Program Files\Application\app.exe")
+ _mono(self._rewrite_path_input)
self._rewrite_mapping_profile_combo = QComboBox()
form.addRow("Full executable path:", self._rewrite_path_input)
form.addRow("Mapped profile:", self._rewrite_mapping_profile_combo)
self._rewrite_mapping_add_btn = QPushButton("Add mapping")
self._rewrite_mapping_edit_btn = QPushButton("Edit selected")
- self._rewrite_mapping_remove_btn = QPushButton("Remove selected")
+ self._rewrite_mapping_remove_btn = theme.set_role(QPushButton("Remove selected"), "danger")
self._rewrite_mapping_add_btn.clicked.connect(lambda: self._on_mapping_write(False))
self._rewrite_mapping_edit_btn.clicked.connect(lambda: self._on_mapping_write(True))
self._rewrite_mapping_remove_btn.clicked.connect(self._on_mapping_remove)
- row = QHBoxLayout()
- for button in (
- self._rewrite_mapping_add_btn,
- self._rewrite_mapping_edit_btn,
- self._rewrite_mapping_remove_btn,
- ):
- row.addWidget(button)
- form.addRow(row)
+ form.addRow(
+ "",
+ _button_row(
+ self._rewrite_mapping_add_btn,
+ self._rewrite_mapping_edit_btn,
+ self._rewrite_mapping_remove_btn,
+ ),
+ )
+ _style_form(form)
self._tabs.addTab(tab, "Profiles")
def _all_rewrite_profiles(self) -> list[RewriteProfile]:
@@ -1010,7 +1380,8 @@ def _build_audio_tab(self) -> None:
"or Intel Smart Sound. Avoid Monitor, Stereo Mix, HDMI, and output devices."
)
hint.setWordWrap(True)
- form.addRow(hint)
+ _section(form, "Input", first=True)
+ form.addRow(_hint(hint))
self._device_combo = QComboBox()
self._populate_devices()
@@ -1018,6 +1389,7 @@ def _build_audio_tab(self) -> None:
self._refresh_devices_btn.setEnabled(self._refresh_devices_fn is not None)
self._refresh_devices_btn.clicked.connect(self.refresh_devices)
device_row = QHBoxLayout()
+ device_row.setSpacing(8)
device_row.addWidget(self._device_combo, 1)
device_row.addWidget(self._refresh_devices_btn)
form.addRow("Input Device:", device_row)
@@ -1026,24 +1398,29 @@ def _build_audio_tab(self) -> None:
"Refresh and reselect after device changes. Device IDs/names do not prove physical identity."
)
policy.setWordWrap(True)
- form.addRow(policy)
+ form.addRow(_hint(policy))
+ _section(form, "Silence detection")
self._calibrate_btn = QPushButton(_CALIBRATE_LABEL)
self._calibrate_btn.clicked.connect(self._on_calibrate)
if self._calibrate_fn is None:
self._calibrate_btn.setEnabled(False)
- form.addRow(self._calibrate_btn)
+ form.addRow(_button_row(self._calibrate_btn))
self._rms_spin = QDoubleSpinBox()
self._rms_spin.setRange(0.0, 32767.0)
self._rms_spin.setDecimals(1)
self._rms_spin.setSingleStep(1.0)
self._rms_spin.setSpecialValueText("Disabled")
- form.addRow("RMS Threshold:", self._rms_spin)
+ self._rms_spin.setMinimumWidth(140)
+ _mono(self._rms_spin)
+ form.addRow("RMS Threshold:", _leading(self._rms_spin))
- self._rms_label = QLabel("Threshold: —")
+ self._rms_label = _hint(QLabel("Threshold: —"), "meta")
+ self._rms_label.setFont(theme.mono_font(12))
form.addRow(self._rms_label)
+ _style_form(form)
self._tabs.addTab(tab, "Audio")
# --- Vocabulary tab ------------------------------------------------
@@ -1059,43 +1436,43 @@ def _build_vocabulary_tab(self) -> None:
"rendered guidance. Blank terms are discarded; duplicate spellings keep the first."
)
hint.setWordWrap(True)
- form.addRow(hint)
+ form.addRow(_hint(hint))
self._vocabulary_warning = QLabel(
"Saved vocabulary is damaged. No terms are being used. The original value is "
"preserved during unrelated saves. Explicitly Reset or Replace to repair it; "
"Apply/OK then replaces the original. Cancel discards the repair."
)
self._vocabulary_warning.setWordWrap(True)
- form.addRow(self._vocabulary_warning)
+ form.addRow(_hint(self._vocabulary_warning, "warning"))
self._vocabulary_reset_btn = QPushButton("Reset damaged vocabulary…")
self._vocabulary_reset_btn.clicked.connect(lambda: self._on_vocabulary_repair("Reset"))
self._vocabulary_replace_btn = QPushButton("Replace damaged vocabulary…")
self._vocabulary_replace_btn.clicked.connect(lambda: self._on_vocabulary_repair("Replace"))
- repairs = QHBoxLayout()
- repairs.addWidget(self._vocabulary_reset_btn)
- repairs.addWidget(self._vocabulary_replace_btn)
- form.addRow(repairs)
+ form.addRow(_button_row(self._vocabulary_reset_btn, self._vocabulary_replace_btn))
+ _section(form, "Terms")
self._vocabulary_list = QListWidget()
self._vocabulary_list.setMaximumHeight(200)
self._vocabulary_list.itemSelectionChanged.connect(self._on_vocabulary_selected)
form.addRow("Preferred terms:", self._vocabulary_list)
self._vocabulary_input = QLineEdit()
self._vocabulary_input.setPlaceholderText("One preferred spelling, e.g. PySide6")
+ _mono(self._vocabulary_list, self._vocabulary_input)
form.addRow("Term:", self._vocabulary_input)
self._vocabulary_add_btn = QPushButton("Add")
self._vocabulary_add_btn.clicked.connect(self._on_vocabulary_add)
self._vocabulary_edit_btn = QPushButton("Edit selected")
self._vocabulary_edit_btn.clicked.connect(self._on_vocabulary_edit)
- self._vocabulary_remove_btn = QPushButton("Remove selected")
+ self._vocabulary_remove_btn = theme.set_role(QPushButton("Remove selected"), "danger")
self._vocabulary_remove_btn.clicked.connect(self._on_vocabulary_remove)
- buttons = QHBoxLayout()
- for button in (
- self._vocabulary_add_btn,
- self._vocabulary_edit_btn,
- self._vocabulary_remove_btn,
- ):
- buttons.addWidget(button)
- form.addRow(buttons)
+ form.addRow(
+ "",
+ _button_row(
+ self._vocabulary_add_btn,
+ self._vocabulary_edit_btn,
+ self._vocabulary_remove_btn,
+ ),
+ )
+ _style_form(form)
self._tabs.addTab(tab, "Vocabulary")
def _vocabulary_values(self) -> list[str]:
@@ -1612,6 +1989,7 @@ def _add_provider_fields(
headers = QLineEdit()
headers.setPlaceholderText('{"X-Custom": "value"}')
form.addRow("Custom Headers:", headers)
+ _mono(key, url, model, headers)
return key, url, model, headers
@@ -1661,6 +2039,7 @@ class HotkeyCaptureEdit(QLineEdit):
def __init__(self) -> None:
super().__init__()
self.setReadOnly(True)
+ self.setFont(theme.mono_font(12, QFont.Weight.Medium))
self._recording = False
def is_recording(self) -> bool:
@@ -1670,6 +2049,7 @@ def start_recording(self) -> None:
if self._recording:
return
self._recording = True
+ _set_recording_look(self, True)
self.setText("press the new hotkey…")
self.setFocus(Qt.OtherFocusReason)
app = QCoreApplication.instance()
@@ -1681,6 +2061,7 @@ def stop_recording(self) -> None:
if not self._recording:
return
self._recording = False
+ _set_recording_look(self, False)
app = QCoreApplication.instance()
if app is not None:
app.removeEventFilter(self)
@@ -1689,6 +2070,37 @@ def stop_recording(self) -> None:
def show_hotkey(self, hotkey: Hotkey) -> None:
self.setText(hotkey.to_label())
+ def paintEvent(self, event) -> None:
+ """Draw the stored chord as keycaps; recording uses the plain prompt text."""
+ if self._recording or not self.text():
+ super().paintEvent(event)
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ option = QStyleOptionFrame()
+ self.initStyleOption(option)
+ self.style().drawPrimitive(QStyle.PrimitiveElement.PE_PanelLineEdit, option, painter, self)
+ font = self.font()
+ painter.setFont(font)
+ fm = QFontMetrics(font)
+ cap_h = min(self.height() - 10, fm.height() + 8)
+ x = 10.0
+ y = (self.height() - cap_h) / 2
+ for token in _keycap_tokens(self.text()):
+ width = fm.horizontalAdvance(token) + 16
+ if x + width > self.width() - 8:
+ break
+ cap = QRectF(x, y, width, cap_h)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QColor(theme.LINE_STRONG))
+ painter.drawRoundedRect(cap, 5, 5)
+ painter.setBrush(QColor(theme.HOVER))
+ painter.drawRoundedRect(cap.adjusted(0, 0, 0, -2), 5, 5)
+ painter.setPen(QColor(theme.TEXT))
+ painter.drawText(cap.adjusted(0, 0, 0, -2), Qt.AlignmentFlag.AlignCenter, token)
+ x += width + 6
+ painter.end()
+
def keyPressEvent(self, event: QKeyEvent) -> None:
if not self._recording:
super().keyPressEvent(event)
@@ -1726,6 +2138,28 @@ def eventFilter(self, watched, event) -> bool:
return True
+def _keycap_tokens(label: str) -> list[str]:
+ """Split a ``Hotkey.to_label()`` string into keycap captions ("Ctrl++" -> Ctrl, +)."""
+ tokens: list[str] = []
+ current = ""
+ for char in label:
+ if char == "+" and current:
+ tokens.append(current)
+ current = ""
+ else:
+ current += char
+ if current:
+ tokens.append(current)
+ return tokens
+
+
+def _set_recording_look(edit: QLineEdit, recording: bool) -> None:
+ edit.setProperty("hushRecording", recording)
+ edit.style().unpolish(edit)
+ edit.style().polish(edit)
+ edit.update()
+
+
def _combo_index(combo: QComboBox, data: str) -> int:
for i in range(combo.count()):
if combo.itemData(i) == data:
@@ -1748,6 +2182,7 @@ def _clean_device_name(name: str) -> str:
from PySide6.QtWidgets import QApplication
app = QApplication(sys.argv)
+ theme.apply_theme(app)
cfg = load_config()
cfg = import_from_env(cfg)
diff --git a/src/snackbar.py b/src/snackbar.py
index b74879e..8440529 100644
--- a/src/snackbar.py
+++ b/src/snackbar.py
@@ -7,15 +7,19 @@
from __future__ import annotations
-from PySide6.QtCore import Property, QPoint, QPropertyAnimation, QRect, QSize, Qt
-from PySide6.QtGui import QColor, QFontMetrics, QGuiApplication, QPainter, QPainterPath
+from collections import deque
+
+from PySide6.QtCore import Property, QPoint, QPointF, QPropertyAnimation, QRect, QRectF, QSize, Qt
+from PySide6.QtGui import QColor, QFont, QFontMetrics, QGuiApplication, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import QWidget
+from src import theme
+
# Keyed by ``TrayState.value`` so this stays decoupled from icons.py.
# Value = (label, dot RGB). Absent key (e.g. "idle") -> hidden.
_CONTENT: dict[str, tuple[str, tuple[int, int, int]]] = {
- "recording": ("Recording", (229, 57, 53)), # red
- "processing": ("Processing", (255, 179, 0)), # amber
+ "recording": ("Recording", theme.rgb(theme.EMBER)),
+ "processing": ("Processing", theme.rgb(theme.TIDE)),
}
@@ -42,10 +46,13 @@ class RecordingSnackbar(QWidget):
_MARGIN = 48 # px above the screen's available bottom edge
_PAD_X = 18 # horizontal inner padding
- _PAD_Y = 11 # vertical inner padding
- _DOT_R = 6 # dot radius
- _GAP = 11 # gap between dot and text
- _RADIUS = 15 # pill corner radius
+ _PAD_Y = 12 # vertical inner padding
+ _DOT_R = 5 # dot radius
+ _GAP = 12 # gap between dot and text
+ _RADIUS = 16 # pill corner radius
+ _WAVE_H = 20 # waveform meter height
+ _BAR_W = 3 # waveform bar width
+ _BAR_GAP = 2 # gap between waveform bars
def __init__(self) -> None:
super().__init__(None)
@@ -60,8 +67,11 @@ def __init__(self) -> None:
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
+ self.setFont(theme.ui_font(13, QFont.Weight.Medium))
self._label = "Recording"
- self._dot = QColor(229, 57, 53)
+ self._dot = QColor(theme.EMBER)
+ # Recent input levels drawn as a waveform; display only.
+ self._levels: deque[float] = deque(maxlen=96)
self._pulse = 1.0 # 0.25..1.0, drives dot alpha
self._input_label: str | None = None
self._input_level = 0.0
@@ -107,6 +117,8 @@ def set_input_status(self, device_label: str, level: float, has_callback_data: b
self._input_label = device_label
self._input_level = max(0.0, min(level, 1.0)) if has_callback_data else 0.0
self._has_callback_data = has_callback_data
+ if has_callback_data:
+ self._levels.append(self._input_level)
self._resize_to_content()
self._reposition()
self.update()
@@ -131,6 +143,7 @@ def show_state(self, label: str, dot_rgb: tuple[int, int, int]) -> None:
self._input_label = "Input device unknown" if label == "Recording" else None
self._input_level = 0.0
self._has_callback_data = False
+ self._levels.clear()
self._resize_to_content()
self._reposition()
# Cancel any in-flight fade first so a pending fade-out (from a recent
@@ -189,7 +202,7 @@ def _resize_to_content(self) -> None:
prefix_w + status_padding,
min(status_w, max_status_w),
)
- height += text_h + 14
+ height += text_h + 10 + self._WAVE_H
self.setFixedSize(width, height)
def _reposition(self) -> None:
@@ -203,49 +216,74 @@ def paintEvent(self, event) -> None: # noqa: N802 (Qt override name)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
- rect = self.rect()
+ # Dark glass card with a hairline edge.
+ rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
path = QPainterPath()
- path.addRoundedRect(
- float(rect.x()),
- float(rect.y()),
- float(rect.width()),
- float(rect.height()),
- float(self._RADIUS),
- float(self._RADIUS),
- )
- painter.fillPath(path, QColor(28, 28, 30, 220)) # dark translucent pill
+ path.addRoundedRect(rect, float(self._RADIUS), float(self._RADIUS))
+ painter.fillPath(path, theme.qcolor(theme.SURFACE, 0.95))
+ painter.setPen(QPen(QColor(255, 255, 255, 18), 1.0))
+ painter.drawPath(path)
- # Pulsing dot.
- dot = QColor(self._dot)
- dot.setAlphaF(max(0.0, min(1.0, self._pulse)))
- cx = rect.x() + self._PAD_X + self._DOT_R
+ # Pulsing dot with a soft breathing halo.
+ pulse = max(0.0, min(1.0, self._pulse))
fm = QFontMetrics(self.font())
title_height = max(fm.height(), 2 * self._DOT_R)
- cy = self._PAD_Y + title_height // 2
+ center = QPointF(self._PAD_X + self._DOT_R, self._PAD_Y + title_height / 2)
+ halo = QColor(self._dot)
+ halo.setAlphaF(0.22 * pulse)
painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(halo)
+ halo_r = self._DOT_R + 2.0 + 3.0 * (1.0 - pulse)
+ painter.drawEllipse(center, halo_r, halo_r)
+ dot = QColor(self._dot)
+ dot.setAlphaF(0.35 + 0.65 * pulse)
painter.setBrush(dot)
- painter.drawEllipse(QPoint(cx, cy), self._DOT_R, self._DOT_R)
+ painter.drawEllipse(center, float(self._DOT_R), float(self._DOT_R))
# Label.
- painter.setPen(QColor(245, 245, 247))
- text_x = cx + self._DOT_R + self._GAP
+ painter.setPen(QColor(theme.TEXT))
+ text_x = self._PAD_X + 2 * self._DOT_R + self._GAP
painter.drawText(
- QRect(text_x, self._PAD_Y, rect.width() - text_x - self._PAD_X, title_height),
+ QRect(text_x, self._PAD_Y, self.width() - text_x - self._PAD_X, title_height),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
self._label,
)
if self._input_label is not None:
- input_width = rect.width() - 2 * self._PAD_X
- painter.setPen(QColor(190, 190, 195))
+ input_width = self.width() - 2 * self._PAD_X
+ status_y = self._PAD_Y + title_height + 4
+ painter.setPen(QColor(theme.TEXT_2))
painter.drawText(
- QRect(self._PAD_X, self._PAD_Y + title_height, input_width, fm.height()),
+ QRect(self._PAD_X, status_y, input_width, fm.height()),
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
self.input_status_text(),
)
- meter_y = self._PAD_Y + title_height + fm.height() + 4
- painter.fillRect(QRect(self._PAD_X, meter_y, input_width, 5), QColor(75, 75, 80))
- painter.fillRect(
- QRect(self._PAD_X, meter_y, round(input_width * self._input_level), 5),
- QColor(90, 205, 150),
+ self._paint_waveform(
+ painter,
+ QRectF(self._PAD_X, status_y + fm.height() + 6, input_width, self._WAVE_H),
)
painter.end()
+
+ def _paint_waveform(self, painter: QPainter, area: QRectF) -> None:
+ """Recent fixed full-scale RMS levels as bars, newest on the right."""
+ step = self._BAR_W + self._BAR_GAP
+ count = max(1, int((area.width() + self._BAR_GAP) // step))
+ levels = list(self._levels)[-count:]
+ levels = [None] * (count - len(levels)) + levels
+ mid = area.center().y()
+ painter.setPen(Qt.PenStyle.NoPen)
+ for i, level in enumerate(levels):
+ x = area.left() + i * step
+ if level is None:
+ color = theme.qcolor(theme.LINE_STRONG)
+ height = 2.0
+ else:
+ age = (i + 1) / count # fade older bars toward the left
+ color = QColor(self._dot)
+ color.setAlphaF(0.30 + 0.70 * age)
+ height = max(2.0, level * area.height())
+ painter.setBrush(color)
+ painter.drawRoundedRect(
+ QRectF(x, mid - height / 2, self._BAR_W, height),
+ self._BAR_W / 2,
+ self._BAR_W / 2,
+ )
diff --git a/src/theme.py b/src/theme.py
new file mode 100644
index 0000000..e8429ba
--- /dev/null
+++ b/src/theme.py
@@ -0,0 +1,480 @@
+"""Hush: Screamer's visual design system (tokens, fonts, stylesheet, window polish).
+
+Appearance only — nothing here changes application behavior. Imports cleanly on
+any OS; the Win32 title-bar polish is guarded at runtime.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import logging
+import sys
+from pathlib import Path
+from string import Template
+
+from PySide6.QtCore import QEvent, QObject, QRectF, Qt
+from PySide6.QtGui import (
+ QColor,
+ QFont,
+ QFontDatabase,
+ QGuiApplication,
+ QPainter,
+ QPainterPath,
+ QPalette,
+)
+from PySide6.QtWidgets import QAbstractSpinBox, QApplication, QMenu, QWidget
+
+log = logging.getLogger(__name__)
+
+ASSETS_DIR = Path(__file__).resolve().parent / "assets"
+
+# --- Color tokens ---------------------------------------------------------
+# Surfaces: ink black with a faint blue undertone, stepped by elevation.
+CANVAS = "#0E0F12"
+SURFACE = "#15171B"
+RAISED = "#1C1F24"
+HOVER = "#23272D"
+LINE = "#262A30"
+LINE_STRONG = "#353A42"
+TEXT = "#ECEDEE"
+TEXT_2 = "#A3A8B0"
+TEXT_3 = "#6B717A"
+
+# Signal colors: each carries exactly one meaning.
+EMBER = "#FF7A4D" # brand + live recording
+EMBER_HOVER = "#FF8F68"
+EMBER_PRESSED = "#E86A3F"
+EMBER_TEXT = "#FFB394" # ember text on dark surfaces
+ON_EMBER = "#1A0E09"
+TIDE = "#7CC4E8" # processing
+MOSS = "#5FD4A0" # success
+ROSE = "#F0607A" # error / destructive
+AMBER = "#F2B759" # warning
+
+
+def rgb(hex_color: str) -> tuple[int, int, int]:
+ value = hex_color.lstrip("#")
+ return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
+
+
+def qcolor(hex_color: str, alpha: float = 1.0) -> QColor:
+ color = QColor(hex_color)
+ color.setAlphaF(alpha)
+ return color
+
+
+# --- Typography -----------------------------------------------------------
+
+UI_FAMILIES = ["Geist", "Segoe UI Variable Text", "Segoe UI", "Inter", "sans-serif"]
+MONO_FAMILIES = ["Geist Mono", "Cascadia Mono", "Consolas", "monospace"]
+_fonts_loaded = False
+
+
+def load_fonts() -> None:
+ """Register the bundled Geist families once (missing files fall back silently)."""
+ global _fonts_loaded
+ # QFontDatabase needs a live QGuiApplication; without one, stay unloaded.
+ if _fonts_loaded or QGuiApplication.instance() is None:
+ return
+ _fonts_loaded = True
+ for path in sorted((ASSETS_DIR / "fonts").glob("*.ttf")):
+ if QFontDatabase.addApplicationFont(str(path)) < 0:
+ log.warning("Could not load bundled font %s", path.name)
+
+
+def ui_font(
+ pixel_size: int = 13,
+ weight: QFont.Weight = QFont.Weight.Normal,
+ letter_spacing: float = 0.0,
+) -> QFont:
+ font = QFont()
+ font.setFamilies(UI_FAMILIES)
+ font.setPixelSize(pixel_size)
+ font.setWeight(weight)
+ if letter_spacing:
+ font.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 100.0 + letter_spacing)
+ font.setHintingPreference(QFont.HintingPreference.PreferNoHinting)
+ return font
+
+
+def mono_font(pixel_size: int = 13, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
+ font = QFont()
+ font.setFamilies(MONO_FAMILIES)
+ font.setPixelSize(pixel_size)
+ font.setWeight(weight)
+ font.setStyleHint(QFont.StyleHint.Monospace)
+ return font
+
+
+# --- Brand mark -----------------------------------------------------------
+
+# Three rounded bars on a 16-unit grid: (x, top, bottom). Reads as a waveform.
+_MARK_BARS = ((2.0, 4.5, 11.5), (6.5, 2.0, 14.0), (11.0, 3.5, 12.5))
+_MARK_BAR_WIDTH = 3.0
+
+
+def draw_mark(painter: QPainter, rect: QRectF, color: QColor) -> None:
+ """Paint the Screamer mark into *rect* (square)."""
+ scale = min(rect.width(), rect.height()) / 16.0
+ ox = rect.x() + (rect.width() - 16.0 * scale) / 2
+ oy = rect.y() + (rect.height() - 16.0 * scale) / 2
+ width = _MARK_BAR_WIDTH * scale
+ path = QPainterPath()
+ for x, top, bottom in _MARK_BARS:
+ path.addRoundedRect(
+ QRectF(ox + x * scale, oy + top * scale, width, (bottom - top) * scale),
+ width / 2,
+ width / 2,
+ )
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
+ painter.fillPath(path, color)
+ painter.restore()
+
+
+# --- Palette + stylesheet -------------------------------------------------
+
+
+def _palette() -> QPalette:
+ pal = QPalette()
+ roles = QPalette.ColorRole
+ for role, value in (
+ (roles.Window, CANVAS),
+ (roles.WindowText, TEXT),
+ (roles.Base, RAISED),
+ (roles.AlternateBase, SURFACE),
+ (roles.Text, TEXT),
+ (roles.Button, RAISED),
+ (roles.ButtonText, TEXT),
+ (roles.BrightText, TEXT),
+ (roles.ToolTipBase, RAISED),
+ (roles.ToolTipText, TEXT),
+ (roles.PlaceholderText, TEXT_3),
+ (roles.Link, EMBER),
+ (roles.Light, LINE_STRONG),
+ (roles.Midlight, LINE),
+ (roles.Mid, LINE),
+ (roles.Dark, CANVAS),
+ (roles.Shadow, "#000000"),
+ (roles.HighlightedText, TEXT),
+ ):
+ pal.setColor(role, QColor(value))
+ pal.setColor(roles.Highlight, qcolor(EMBER, 0.35))
+ disabled = QPalette.ColorGroup.Disabled
+ for role in (roles.WindowText, roles.Text, roles.ButtonText):
+ pal.setColor(disabled, role, QColor(TEXT_3))
+ return pal
+
+
+_QSS = Template(
+ """
+QDialog, QMessageBox, QInputDialog { background: $canvas; }
+QLabel { background: transparent; }
+QToolTip {
+ background: $raised; color: $text; border: 1px solid $line_strong; padding: 6px 8px;
+}
+
+QFrame#hushSidebar { background: $surface; border: none; border-right: 1px solid $line; }
+QFrame#hushFooter { background: $canvas; border: none; border-top: 1px solid $line; }
+QWidget#hushPage, QWidget#hushPageBody { background: $canvas; }
+QScrollArea#hushScroll { background: transparent; border: none; }
+QFrame#hushDivider { background: $line; border: none; }
+
+QLabel[hush="title"] { color: $text; }
+QLabel[hush="subtitle"] { color: $text_2; }
+QLabel[hush="section"] { color: $text_3; }
+QLabel[hush="brand"] { color: $text; }
+QLabel[hush="hint"] { color: $text_2; }
+QLabel[hush="meta"] { color: $text_2; }
+QLabel[hush="error"] { color: $rose; }
+QLabel[hush="warning"] {
+ color: $amber; background: rgba(242, 183, 89, 0.08);
+ border: 1px solid rgba(242, 183, 89, 0.28); border-radius: 8px; padding: 10px 12px;
+}
+
+QLineEdit, QPlainTextEdit, QAbstractSpinBox, QComboBox {
+ background: $raised; color: $text; border: 1px solid $line; border-radius: 7px;
+ padding: 6px 10px; selection-background-color: rgba(255, 122, 77, 0.35);
+ selection-color: $text;
+}
+QLineEdit, QAbstractSpinBox, QComboBox { min-height: 20px; }
+QPlainTextEdit { padding: 6px 8px; }
+QLineEdit:hover, QPlainTextEdit:hover, QAbstractSpinBox:hover, QComboBox:hover {
+ border-color: $line_strong;
+}
+QLineEdit:focus, QPlainTextEdit:focus, QAbstractSpinBox:focus, QComboBox:focus,
+QComboBox:on {
+ border-color: rgba(255, 122, 77, 0.75);
+}
+QLineEdit:disabled, QPlainTextEdit:disabled, QAbstractSpinBox:disabled, QComboBox:disabled {
+ color: $text_3; background: $surface; border-color: $line;
+}
+QLineEdit[hushRecording="true"] {
+ color: $ember_text; background: rgba(255, 122, 77, 0.08); border-color: $ember;
+}
+
+QComboBox { padding-right: 30px; combobox-popup: 0; }
+QComboBox::drop-down {
+ subcontrol-origin: padding; subcontrol-position: center right;
+ width: 28px; border: none; background: transparent;
+}
+QComboBox::down-arrow { image: url("$chevron_down"); width: 12px; height: 12px; }
+QComboBox QAbstractItemView {
+ background: $raised; color: $text; border: 1px solid $line_strong; padding: 4px;
+ outline: 0; selection-background-color: $hover; selection-color: $text;
+}
+QComboBox QAbstractItemView::item { min-height: 28px; padding: 0 8px; border-radius: 6px; }
+QComboBox QAbstractItemView::item:hover, QComboBox QAbstractItemView::item:selected {
+ background: $hover; color: $text;
+}
+
+QAbstractSpinBox { padding-right: 26px; }
+QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {
+ subcontrol-origin: border; width: 24px; border: none; background: transparent;
+}
+QAbstractSpinBox::up-button { subcontrol-position: top right; border-top-right-radius: 7px; }
+QAbstractSpinBox::down-button {
+ subcontrol-position: bottom right; border-bottom-right-radius: 7px;
+}
+QAbstractSpinBox::up-button:hover, QAbstractSpinBox::down-button:hover { background: $hover; }
+QAbstractSpinBox::up-arrow { image: url("$chevron_up"); width: 10px; height: 10px; }
+QAbstractSpinBox::down-arrow { image: url("$chevron_down"); width: 10px; height: 10px; }
+
+QPushButton {
+ background: $raised; color: $text; border: 1px solid $line; border-radius: 7px;
+ padding: 7px 14px; min-height: 18px;
+}
+QPushButton:hover { background: $hover; border-color: $line_strong; }
+QPushButton:pressed { background: $line; }
+QPushButton:focus { border-color: rgba(255, 122, 77, 0.6); }
+QPushButton:disabled { color: $text_3; background: $surface; border-color: $line; }
+QPushButton:checked {
+ color: $ember_text; background: rgba(255, 122, 77, 0.10); border-color: $ember;
+}
+QPushButton[hush="primary"], QDialogButtonBox QPushButton:default,
+QMessageBox QPushButton:default {
+ background: $ember; color: $on_ember; border: 1px solid $ember; font-weight: 600;
+}
+QPushButton[hush="primary"]:hover, QDialogButtonBox QPushButton:default:hover,
+QMessageBox QPushButton:default:hover {
+ background: $ember_hover; border-color: $ember_hover;
+}
+QPushButton[hush="primary"]:pressed, QDialogButtonBox QPushButton:default:pressed,
+QMessageBox QPushButton:default:pressed {
+ background: $ember_pressed; border-color: $ember_pressed;
+}
+QPushButton[hush="primary"]:focus, QDialogButtonBox QPushButton:default:focus,
+QMessageBox QPushButton:default:focus {
+ border-color: $ember_text;
+}
+QPushButton[hush="primary"]:disabled, QDialogButtonBox QPushButton:default:disabled {
+ background: rgba(255, 122, 77, 0.30); border-color: transparent; color: $on_ember;
+}
+QPushButton[hush="ghost"] { background: transparent; border-color: transparent; color: $text_2; }
+QPushButton[hush="ghost"]:hover { background: $hover; color: $text; }
+QPushButton[hush="ghost"]:pressed { background: $line; }
+QPushButton[hush="ghost"]:disabled { background: transparent; color: $text_3; }
+QPushButton[hush="danger"] { color: $rose; }
+QPushButton[hush="danger"]:hover {
+ background: rgba(240, 96, 122, 0.10); border-color: rgba(240, 96, 122, 0.40);
+}
+QPushButton[hush="danger"]:disabled { color: $text_3; }
+QDialogButtonBox { dialogbuttonbox-buttons-have-icons: 0; }
+QDialogButtonBox QPushButton { min-width: 64px; }
+
+QCheckBox, QRadioButton { spacing: 10px; background: transparent; color: $text; }
+QCheckBox:disabled, QRadioButton:disabled { color: $text_3; }
+QCheckBox::indicator {
+ width: 16px; height: 16px; border-radius: 4px; border: 1px solid #3A4048;
+ background: $raised;
+}
+QCheckBox::indicator:hover { border-color: $text_3; }
+QCheckBox::indicator:checked { background: $ember; border-color: $ember; image: url("$check"); }
+QCheckBox::indicator:disabled { background: $surface; border-color: $line; }
+QRadioButton::indicator { width: 16px; height: 16px; image: url("$radio_off"); }
+QRadioButton::indicator:checked { image: url("$radio_on"); }
+
+QGroupBox {
+ background: $surface; border: 1px solid $line; border-radius: 12px;
+ margin-top: 28px; color: $text_2;
+}
+QGroupBox::title {
+ subcontrol-origin: margin; subcontrol-position: top left; left: 0; top: 2px;
+ padding: 0; color: $text_2; background: transparent;
+}
+QGroupBox QGroupBox { background: $canvas; }
+
+QListWidget, QListView {
+ background: $surface; color: $text; border: 1px solid $line; border-radius: 10px;
+ padding: 4px; outline: 0;
+}
+QListWidget::item, QListView::item { padding: 6px 10px; border-radius: 6px; }
+QListWidget::item:hover, QListView::item:hover { background: $hover; }
+QListWidget::item:selected, QListView::item:selected {
+ background: rgba(255, 122, 77, 0.14); color: $text;
+}
+QListWidget:disabled { color: $text_3; }
+QListWidget#hushNav { background: transparent; border: none; padding: 0; }
+QListWidget#hushNav::item {
+ padding: 8px 12px; margin: 1px 0; color: $text_2; border-radius: 8px;
+}
+QListWidget#hushNav::item:hover { background: $hover; color: $text; }
+QListWidget#hushNav::item:selected { background: rgba(255, 122, 77, 0.12); color: $ember_text; }
+
+QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
+QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
+QScrollBar::handle:vertical { background: $line_strong; border-radius: 3px; min-height: 28px; }
+QScrollBar::handle:horizontal { background: $line_strong; border-radius: 3px; min-width: 28px; }
+QScrollBar::handle:hover { background: $text_3; }
+QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; border: none; }
+QScrollBar::add-page, QScrollBar::sub-page { background: transparent; }
+
+QMenu {
+ background: $surface; color: $text; border: 1px solid $line_strong; padding: 6px;
+}
+QMenu[hush="round"] { border-radius: 12px; }
+QMenu::item { padding: 7px 30px 7px 12px; border-radius: 7px; background: transparent; }
+QMenu::item:selected { background: $hover; }
+QMenu::item:disabled { color: $text_3; }
+QMenu::separator { height: 1px; background: $line; margin: 5px 8px; }
+QMenu::right-arrow { image: url("$chevron_right"); width: 12px; height: 12px; }
+QMenu QCheckBox, QMenu QRadioButton {
+ padding: 7px 12px; border-radius: 7px; background: transparent;
+}
+QMenu QCheckBox:hover, QMenu QRadioButton:hover { background: $hover; }
+"""
+)
+
+
+def stylesheet() -> str:
+ icons = ASSETS_DIR / "icons"
+ return _QSS.substitute(
+ canvas=CANVAS,
+ surface=SURFACE,
+ raised=RAISED,
+ hover=HOVER,
+ line=LINE,
+ line_strong=LINE_STRONG,
+ text=TEXT,
+ text_2=TEXT_2,
+ text_3=TEXT_3,
+ ember=EMBER,
+ ember_hover=EMBER_HOVER,
+ ember_pressed=EMBER_PRESSED,
+ ember_text=EMBER_TEXT,
+ on_ember=ON_EMBER,
+ rose=ROSE,
+ amber=AMBER,
+ **{
+ name.replace("-", "_"): (icons / f"{name}.svg").as_posix()
+ for name in (
+ "chevron-down",
+ "chevron-up",
+ "chevron-right",
+ "check",
+ "radio-on",
+ "radio-off",
+ )
+ },
+ )
+
+
+def set_role(widget: QWidget, role: str) -> QWidget:
+ """Tag *widget* with a Hush style role (matched by ``[hush="role"]`` selectors)."""
+ widget.setProperty("hush", role)
+ # Fresh widgets pick the role up on first polish; repolishing them early would
+ # discard fonts set right after this call.
+ if widget.testAttribute(Qt.WidgetAttribute.WA_WState_Polished):
+ style = widget.style()
+ style.unpolish(widget)
+ style.polish(widget)
+ return widget
+
+
+def polish_menu(menu: QMenu) -> QMenu:
+ """Give a top-level popup menu rounded, shadow-free corners."""
+ menu.setWindowFlags(
+ menu.windowFlags()
+ | Qt.WindowType.FramelessWindowHint
+ | Qt.WindowType.NoDropShadowWindowHint
+ )
+ menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
+ menu.setProperty("hush", "round")
+ return menu
+
+
+# --- Native window polish (Windows) ---------------------------------------
+
+_DWMWA_USE_IMMERSIVE_DARK_MODE = 20
+_DWMWA_BORDER_COLOR = 34
+_DWMWA_CAPTION_COLOR = 35
+_DWMWA_TEXT_COLOR = 36
+
+
+def _colorref(hex_color: str) -> int:
+ r, g, b = rgb(hex_color)
+ return r | (g << 8) | (b << 16)
+
+
+def polish_native_window(widget: QWidget) -> None:
+ """Dark title bar matching the canvas on Windows 10/11; no-op elsewhere."""
+ if sys.platform != "win32":
+ return
+ try:
+ dwm = ctypes.windll.dwmapi
+ hwnd = ctypes.c_void_p(int(widget.winId()))
+ for attribute, value in (
+ (_DWMWA_USE_IMMERSIVE_DARK_MODE, 1),
+ (_DWMWA_CAPTION_COLOR, _colorref(CANVAS)),
+ (_DWMWA_BORDER_COLOR, _colorref(LINE)),
+ (_DWMWA_TEXT_COLOR, _colorref(TEXT)),
+ ):
+ data = ctypes.c_int(value)
+ # Older builds reject unknown attributes with an HRESULT; that is fine.
+ dwm.DwmSetWindowAttribute(hwnd, attribute, ctypes.byref(data), ctypes.sizeof(data))
+ except Exception:
+ log.debug("Native title bar polish unavailable", exc_info=True)
+
+
+# Qt style sheets ignore padding-left on a spin box's inner editor, so the text
+# would sit on the border; inset it to line up with QLineEdit/QComboBox text.
+_SPIN_TEXT_INSET = 9
+
+
+class _WindowPolisher(QObject):
+ """Insets spin box text and, on Windows, polishes native title bars on first show."""
+
+ def eventFilter(self, watched, event) -> bool:
+ if event.type() == QEvent.Type.Polish and isinstance(watched, QAbstractSpinBox):
+ editor = watched.lineEdit()
+ if editor is not None:
+ editor.setTextMargins(_SPIN_TEXT_INSET, 0, 0, 0)
+ elif (
+ sys.platform == "win32"
+ and event.type() == QEvent.Type.Show
+ and isinstance(watched, QWidget)
+ and watched.isWindow()
+ and watched.windowType() in (Qt.WindowType.Window, Qt.WindowType.Dialog)
+ and not watched.property("_hushNativePolished")
+ ):
+ watched.setProperty("_hushNativePolished", True)
+ polish_native_window(watched)
+ return False
+
+
+_polisher: _WindowPolisher | None = None
+
+
+def apply_theme(app: QApplication) -> None:
+ """Install fonts, palette, stylesheet and native polish on *app* (idempotent)."""
+ global _polisher
+ if not isinstance(app, QApplication):
+ return # e.g. a test double standing in for the application
+ load_fonts()
+ app.setStyle("Fusion")
+ app.setFont(ui_font(13))
+ app.setPalette(_palette())
+ app.setStyleSheet(stylesheet())
+ if _polisher is None:
+ _polisher = _WindowPolisher(app)
+ app.installEventFilter(_polisher)
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index bcd4b56..a076a8c 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -635,3 +635,35 @@ def test_trailing_action_toggles_visibility(self) -> None:
if __name__ == "__main__":
unittest.main()
+
+
+class SidebarNavigationTests(unittest.TestCase):
+ """The sidebar replaced QTabWidget visually; tab indexes must keep their meaning."""
+
+ def test_tab_indexes_text_and_validation_jump_are_preserved(self) -> None:
+ dlg = SettingsDialog(AppConfig(), devices=[], calibrate_fn=None)
+ self.addCleanup(dlg.deleteLater)
+ tabs = dlg._tabs
+ self.assertEqual(
+ [tabs.tabText(i) for i in range(tabs.count())],
+ ["General", "STT", "LLM", "Audio", "Vocabulary", "Profiles"],
+ )
+ tabs.setCurrentIndex(2)
+ self.assertEqual(tabs.currentIndex(), 2)
+ tabs.setTabText(4, "Vocabulary ⚠")
+ self.assertEqual(tabs.tabText(4), "Vocabulary ⚠")
+ self.assertIn("⚠", tabs.nav.item(4).text())
+ with patch("src.settings_dialog.QMessageBox"):
+ self.assertFalse(dlg._show_validation_issue()) # empty STT config
+ self.assertEqual(tabs.currentIndex(), 1)
+
+ def test_toggle_switch_keeps_checkbox_behavior(self) -> None:
+ from PySide6.QtWidgets import QCheckBox
+
+ dlg = SettingsDialog(AppConfig(), devices=[], calibrate_fn=None)
+ self.addCleanup(dlg.deleteLater)
+ self.assertIsInstance(dlg._llm_check, QCheckBox)
+ dlg._llm_check.setChecked(False)
+ dlg._llm_check.click()
+ self.assertTrue(dlg._llm_check.isChecked())
+ self.assertFalse(dlg._llm_group.isHidden())
diff --git a/tests/test_snackbar.py b/tests/test_snackbar.py
index b632a86..ff0d14d 100644
--- a/tests/test_snackbar.py
+++ b/tests/test_snackbar.py
@@ -7,23 +7,23 @@
class SnackbarContentTests(unittest.TestCase):
- def test_recording_maps_to_red_label(self):
+ def test_recording_maps_to_ember_label(self):
from src.snackbar import snackbar_content_for
content = snackbar_content_for("recording")
self.assertIsNotNone(content)
label, rgb = content
self.assertEqual(label, "Recording")
- self.assertEqual(rgb, (229, 57, 53))
+ self.assertEqual(rgb, (255, 122, 77))
- def test_processing_maps_to_amber_label(self):
+ def test_processing_maps_to_tide_label(self):
from src.snackbar import snackbar_content_for
content = snackbar_content_for("processing")
self.assertIsNotNone(content)
label, rgb = content
self.assertEqual(label, "Processing")
- self.assertEqual(rgb, (255, 179, 0))
+ self.assertEqual(rgb, (124, 196, 232))
def test_idle_and_unknown_map_to_none(self):
from src.snackbar import snackbar_content_for
diff --git a/tests/test_snackbar_wiring.py b/tests/test_snackbar_wiring.py
index 606244f..051af02 100644
--- a/tests/test_snackbar_wiring.py
+++ b/tests/test_snackbar_wiring.py
@@ -31,13 +31,13 @@ class SnackbarWiringTests(unittest.TestCase):
def test_recording_state_shows_snackbar_with_content(self):
app = _bare_app()
app._apply_state(TrayState.RECORDING)
- app._snackbar.show_state.assert_called_once_with("Recording", (229, 57, 53))
+ app._snackbar.show_state.assert_called_once_with("Recording", (255, 122, 77))
app._snackbar.hide_state.assert_not_called()
def test_processing_state_shows_amber(self):
app = _bare_app()
app._apply_state(TrayState.PROCESSING)
- app._snackbar.show_state.assert_called_once_with("Processing", (255, 179, 0))
+ app._snackbar.show_state.assert_called_once_with("Processing", (124, 196, 232))
def test_idle_state_hides_snackbar(self):
app = _bare_app()