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()