diff --git a/.github/workflows/ui-screenshots.yml b/.github/workflows/ui-screenshots.yml new file mode 100644 index 0000000..78a7db3 --- /dev/null +++ b/.github/workflows/ui-screenshots.yml @@ -0,0 +1,58 @@ +name: UI screenshots + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + capture: + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: xcb + - os: macos-latest + platform: cocoa + - os: windows-latest + platform: windows + runs-on: ${{ matrix.os }} + env: + QT_QPA_PLATFORM: ${{ matrix.platform }} + QT_SCALE_FACTOR: "2" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install Linux display libraries + if: runner.os == 'Linux' + timeout-minutes: 5 + run: | + apt="-o Acquire::Retries=3 -o Acquire::http::Timeout=20" + sudo apt-get $apt update + sudo apt-get $apt install --no-install-recommends -y \ + xvfb xauth libegl1 libgl1 libxkbcommon0 libdbus-1-3 \ + libglib2.0-0 libfontconfig1 libfreetype6 libgssapi-krb5-2 \ + libxcb-cursor0 libxkbcommon-x11-0 libxcb-icccm4 \ + libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 libxcb-render-util0 + - name: Install PyQt6 + run: python -m pip install --quiet PyQt6 + - name: Capture with X11 + if: runner.os == 'Linux' + run: xvfb-run -a -s "-screen 0 2400x1600x24" python -m tests.render_ui --output ui-screenshots --expect-platform xcb + - name: Capture with the native platform + if: runner.os != 'Linux' + run: python -m tests.render_ui --output ui-screenshots --expect-platform ${{ matrix.platform }} + - name: Upload screenshots and rendering details + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: ui-screenshots-${{ runner.os }} + path: ui-screenshots/ + if-no-files-found: error + retention-days: 14 diff --git a/README.md b/README.md index 3a2b14e..1528475 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,17 @@ library, 3.11 or newer, and PyQt6. *[Türkçe README](README.tr.md)*

- Dikte settings, General tab + Dikte, Nord theme +
Nord (default)

+| Dracula | Classic dark | Classic light | +|---|---|---| +| Dikte, Dracula | Dikte, Classic dark | Dikte, Classic light | + | | | |---|---| +| General and themes | Nord, Dracula, dark, light | | API and models | Cleanup rules | | Agent | Meeting | | Audio file | Shortcuts | diff --git a/README.tr.md b/README.tr.md index 86a4b82..265fec6 100644 --- a/README.tr.md +++ b/README.tr.md @@ -12,11 +12,17 @@ Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. *[English README](README.md)*

- Dikte ayarları, Genel sekmesi + Dikte, Nord teması +
Nord (varsayılan)

+| Dracula | Klasik karanlık | Klasik beyaz | +|---|---|---| +| Dikte, Dracula | Dikte, Klasik karanlık | Dikte, Klasik beyaz | + | | | |---|---| +| Genel ve temalar | Nord, Dracula, dark, light | | API ve modeller | Temizleme kuralları | | Ajan | Toplantı | | Ses dosyası | Kısayollar | diff --git a/dikte/app.py b/dikte/app.py index f024ed4..ef91c4d 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -58,6 +58,7 @@ from .i18n import t # noqa: E402 from .meeting import MeetingPipeline # noqa: E402 from .overlay import Overlay # noqa: E402 from .settings_ui import SettingsWindow # noqa: E402 +from .home_ui import HomeWindow # noqa: E402 from .worker import Pipeline # noqa: E402 SERVER_NAME = ipc.SERVER_NAME @@ -138,6 +139,8 @@ class Dikte: self.meeting_base = "" self.meeting_message = "" self.settings_window = None + self.home_window = None + self.home_messages = {} # The single-instance server, handed over once run_app has opened it, so # that a restart can stop answering before the replacement starts. self.server = None @@ -165,13 +168,15 @@ class Dikte: self.overlay = Overlay(self.conf["overlay_corner"], screen_name=self.conf["overlay_screen"], - follow_pointer=self.conf["overlay_follows_pointer"]) + follow_pointer=self.conf["overlay_follows_pointer"], + theme_name=self.conf["theme"]) # The agent's indicator sits on top of the dictation one when both are # up, and drops into the corner when it is alone there. self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay, dismissable=True, screen_name=self.conf["overlay_screen"], - follow_pointer=self.conf["overlay_follows_pointer"]) + follow_pointer=self.conf["overlay_follows_pointer"], + theme_name=self.conf["theme"]) self.recorder = audio.Recorder() self.pipeline = Pipeline(self.conf) self.ask_pipeline = Pipeline(self.conf) @@ -195,7 +200,7 @@ class Dikte: self.pipeline.stage.connect(self._on_stage) self.pipeline.finished.connect(self._on_finished) self.pipeline.failed.connect(self._on_pipeline_failed) - self.ask_pipeline.stage.connect(self.ask_overlay.show_busy) + self.ask_pipeline.stage.connect(self._on_ask_stage) self.ask_pipeline.finished.connect(self._on_ask_finished) self.ask_pipeline.failed.connect(self._on_ask_error) self.ask_pipeline.cancelled.connect(self._on_ask_cancelled) @@ -294,6 +299,10 @@ class Dikte: self.unload_action.triggered.connect(self.unload_models) self.menu.addAction(self.unload_action) + self.home_action = QAction(t("Open Dikte"), self.menu) + self.home_action.triggered.connect(self.open_home) + self.menu.addAction(self.home_action) + self.settings_action = QAction(t("Settings…"), self.menu) self.settings_action.triggered.connect(self.open_settings) self.menu.addAction(self.settings_action) @@ -524,7 +533,7 @@ class Dikte: # than only able to press its buttons. def handle(self, request, reply): - cmd = str(request.get("cmd") or "settings").strip() + cmd = str(request.get("cmd") or "home").strip() if cmd in ("toggle", "start", "stop", "record"): self._dictation_request(cmd, request, reply) elif cmd == "ask": @@ -541,6 +550,7 @@ class Dikte: "ask-reset": self.reset_conversation, "meeting-cancel": self.cancel_meeting, "settings": self.open_settings, + "home": self.open_home, "reload": self.reload_settings, "restart": self.restart, "quit": self.app.quit, @@ -609,9 +619,28 @@ class Dikte: def _settle(self, kind, payload): """Tell whoever was waiting on this run how it ended.""" + self._home_settled(kind, payload) for reply in self._waiters.pop(kind, []): reply(payload) + def _home_settled(self, kind, payload): + if not hasattr(self, "home_messages"): + self.home_messages = {} + self.home_messages.pop(kind + "_stage", None) + if payload.get("cancelled"): + message = t("Stopped.") + elif payload.get("error"): + message = t("Failed: {error}", error=payload["error"]) + elif payload.get("warning"): + message = t("Completed with a warning: {error}", error=payload["warning"]) + else: + message = t("Transcript ready") if kind == DICTATION else "" + self.home_messages[kind] = message + window = getattr(self, "home_window", None) + if window is not None: + window.refresh_results() + window.refresh() + def _auto_stop(self, run): """The end of a `record --seconds`, if that recording is still the one.""" if self._run_id == run and self.state == RECORDING: @@ -656,6 +685,8 @@ class Dikte: def reload_settings(self): """Read the config file back after something outside changed it.""" self.conf.load() + if self.settings_window is not None: + self.settings_window.refresh_configuration() self._apply_settings() def _toggle(self): @@ -706,6 +737,9 @@ class Dikte: # recorder's. if self.state == RECORDING or self.recording: return + if isinstance(getattr(self, "home_messages", None), dict): + self.home_messages[DICTATION] = "" + self.home_messages.pop("dictation_stage", None) self.front_before = self._the_front() self.overlay.show_recording() self._begin_recording(DICTATION) @@ -720,6 +754,9 @@ class Dikte: def start_ask(self): if self.ask_state != IDLE or self.recording: return + if isinstance(getattr(self, "home_messages", None), dict): + self.home_messages[ASK] = "" + self.home_messages.pop("ask_stage", None) self.front_before = self._the_front() self.ask_overlay.show_recording(asking=True) self._begin_recording(ASK) @@ -1038,7 +1075,7 @@ class Dikte: self.overlay.show_error(t("Meeting failed: {error}", error=first_line)) self.tray.showMessage( t("Dikte: the meeting could not be written up"), - t("{error}\n\nThe recording has been kept. Settings → Minutes can " + t("{error}\n\nThe recording has been kept. Meeting → Minutes can " "try again.", error=error), QSystemTrayIcon.MessageIcon.Warning, 12000, ) @@ -1080,7 +1117,14 @@ class Dikte: self.pipeline.run(wav_path, duration, rms_values, paste=wants_paste, focus=focus) + def _on_ask_stage(self, message): + if isinstance(getattr(self, "home_messages", None), dict): + self.home_messages["ask_stage"] = message + self.ask_overlay.show_busy(message) + def _on_stage(self, message): + if isinstance(getattr(self, "home_messages", None), dict): + self.home_messages["dictation_stage"] = message # The corner belongs to the recording when one is on: the previous # run's progress must not wipe the waveform mid-sentence. if self.state != RECORDING: @@ -1105,19 +1149,18 @@ class Dikte: # a rejected key otherwise looks exactly like working dictation. if self.state != RECORDING: self.overlay.show_warning( - t("Pasted raw, cleanup failed: {error}", + t("Completed with a warning: {error}", error=warning.splitlines()[0]) ) self.tray.showMessage( - t("Dikte: cleanup failed"), warning, + t("Dikte: completed with a warning"), warning, QSystemTrayIcon.MessageIcon.Warning, 10000, ) elif self.state != RECORDING: # While a new recording is on, the flash is skipped: the text # arriving where the cursor is says everything it would have. - action = t("Pasted") if self.conf["auto_paste"] else t("Copied") self.overlay.show_done( - t("{action}: {preview}", action=action, preview=_preview(text)) + t("Transcript ready: {preview}", preview=_preview(text)) ) self._transcript_settled({"ok": True, "text": text, "raw": _raw, "warning": warning, @@ -1264,9 +1307,21 @@ class Dikte: # ---- settings --------------------------------------------------------- + def open_home(self): + if self.settings_window is None: + self._make_settings() + if getattr(self, "home_window", None) is None: + self.home_window = HomeWindow(self, self.settings_window) + self.home_window.show() + self.home_window.raise_() + self.home_window.activateWindow() + def open_settings(self): if self.settings_window is None: self._make_settings() + else: + self.settings_window.refresh_configuration() + self.settings_window.refresh_sources() self.settings_window.show() self.settings_window.raise_() self.settings_window.activateWindow() @@ -1281,8 +1336,9 @@ class Dikte: self.settings_window.finished.connect(self._settings_closed) def _settings_closed(self, *_): - # Don't drop the object while its own signal is still being delivered. - QTimer.singleShot(0, lambda: setattr(self, "settings_window", None)) + # Task pages share this controller and may still be processing a file. + # Closing the configuration dialog keeps both its edits and jobs alive. + pass def _reopen_settings(self): """Replace the settings window, so a language change reaches it too. @@ -1295,8 +1351,15 @@ class Dikte: old one stood, on the same tab. """ old = self.settings_window - if old is None: + if old is None or old._work_in_flight(): return + home = getattr(self, "home_window", None) + home_visible = home is not None and home.isVisible() + home_mode = home.mode if home is not None else "dictation" + home_geometry = home.geometry() if home is not None else None + if home is not None: + home.close() + self.home_window = None tab = old.tabs.currentIndex() geometry = old.geometry() # Replaced rather than merely closed: left connected, _settings_closed @@ -1313,9 +1376,28 @@ class Dikte: # window does not come up at the default size and jump. self.settings_window.setGeometry(geometry) self.settings_window.tabs.setCurrentIndex(tab) + self.settings_window.file_path = old.file_path + self.settings_window.file_label.setText(old.file_label.text()) + self.settings_window.file_output.setPlainText(old.file_output.toPlainText()) + self.settings_window.file_segments = getattr(old, "file_segments", []) + self.settings_window.file_save_srt.setEnabled(bool(self.settings_window.file_segments)) + self.settings_window.file_status.setText(old.file_status.text()) self.settings_window.show() self.settings_window.raise_() self.settings_window.activateWindow() + for signal, callback in ( + (self.meetings.progress, old._on_minutes_progress), + (self.meetings.finished, old._on_minutes_finished), + (self.meetings.failed, old._on_minutes_failed), + ): + signal.disconnect(callback) + if home is not None: + self.home_window = HomeWindow(self, self.settings_window) + self.home_window.setGeometry(home_geometry) + self.home_window.show_mode(home_mode) + if home_visible: + self.home_window.show() + home.deleteLater() def _apply_local(self): """Pass the local settings on, and hold the models ready if asked to. @@ -1353,10 +1435,13 @@ class Dikte: def _apply_settings(self): for indicator in (self.overlay, self.ask_overlay): + indicator.set_theme(self.conf["theme"]) indicator.corner = self.conf["overlay_corner"] indicator.screen_name = self.conf["overlay_screen"] indicator.follow_pointer = self.conf["overlay_follows_pointer"] self._apply_local() + if getattr(self, "home_window", None) is not None: + self.home_window.refresh() self._build_tray() self._refresh_tray() # Taken once here for _external: the answer cannot change under a @@ -1532,11 +1617,11 @@ def _hand_over(command): """Give the running instance the attention this start was asking for. A start carrying a verb forwards only that verb; a bare double start asks - for the Settings window as the sign of life the click was looking for. + for the daily workspace as the sign of life the click was looking for. Retried for a moment, because the copy that won the lock may not be listening yet. """ - verb = command or "settings" + verb = command or "home" deadline = time.monotonic() + 5 while time.monotonic() < deadline: if ipc.send(verb) is not None: @@ -1563,7 +1648,7 @@ def run_app(args): if command: ipc.send(command) else: - ipc.send("settings") + ipc.send("home") return 0 app = QApplication(sys.argv) @@ -1638,13 +1723,12 @@ def run_app(args): server.newConnection.connect(on_connection) app.aboutToQuit.connect(dikte.shutdown) - # No key for the chosen transcription provider means nothing can work yet, - # so the settings window is the only useful thing to open. - # A transcription provider that cannot run yet, whether that is a missing - # API key or a model nobody has downloaded, means nothing can work, so the - # settings window is the only useful thing to open. - if command == "settings" or not dikte.conf.transcribe_ready(): + # Explicit home requests and first setup open the daily workspace. + # A configured --gui background start stays quiet for login and restart. + if command == "settings": dikte.open_settings() + elif command == "home" or not dikte.conf.transcribe_ready(): + dikte.open_home() elif command == "toggle": QTimer.singleShot(0, dikte.toggle) elif command == "ask": diff --git a/dikte/cli.py b/dikte/cli.py index 7a8957c..940425f 100644 --- a/dikte/cli.py +++ b/dikte/cli.py @@ -45,7 +45,7 @@ NOT_RUNNING = 3 # Verbs that start the application when none is running, which is what a # shortcut registered with the desktop has always relied on: press the key on a # fresh login and Dikte comes up recording. -GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"} +GUI_VERBS = {"", "home", "settings", "toggle", "ask", "meeting"} # Asking a process that is not there to stop, cancel or quit is not a failure; # it is already in the state that was asked for. @@ -1275,7 +1275,7 @@ def build_parser(): updates.set_defaults(func=cmd_update) leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status) - for name, help_text in (("settings", "open the settings window"), + for name, help_text in (("home", "open Dikte"), ("settings", "open the settings window"), ("restart", "reload the running instance"), ("quit", "shut it down")): leaf(subs, name, help_text).set_defaults(func=cmd_plain) @@ -1306,8 +1306,8 @@ def run(argv): pass parser = build_parser() opts = parser.parse_args(argv) - # No verb at all is the plain `dikte`, which means the settings window. - opts.verb = opts.verb or "" + # No verb opens the daily workspace; settings remains an explicit verb. + opts.verb = opts.verb or "home" # Every path here either talks over the socket or drives one of the workers, # and both want an event loop under them; a window is what none of them want. _app = QCoreApplication.instance() or QCoreApplication(sys.argv[:1]) diff --git a/dikte/config.py b/dikte/config.py index c06377a..5ec5285 100644 --- a/dikte/config.py +++ b/dikte/config.py @@ -418,6 +418,7 @@ da senin soracağın soruya verilecek bir yanıt yok. ve varsayımını bir yan cümlede söyle""" DEFAULTS = { + "theme": "nord", "ui_language": "auto", # auto | tr | en "openai_api_key": "", "openai_base_url": "https://api.openai.com/v1", diff --git a/dikte/home_ui.py b/dikte/home_ui.py new file mode 100644 index 0000000..293a650 --- /dev/null +++ b/dikte/home_ui.py @@ -0,0 +1,543 @@ +"""Task-first native desktop window backed by the application controllers.""" + +import shutil + +from PyQt6.QtCore import Qt, QSize, QTimer +from PyQt6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap +from PyQt6.QtWidgets import ( + QApplication, QButtonGroup, QDialog, QFrame, QHBoxLayout, QLabel, + QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, QVBoxLayout, QWidget, +) + +from . import api, assistant, audio, cleanup, config as cfg, ggml +from .i18n import t +from . import theme + + +def _label(text="", name="", centered=False): + label = QLabel(text) + label.setTextFormat(Qt.TextFormat.PlainText) + label.setWordWrap(True) + label.setObjectName(name) + if centered: + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + return label + + +def _button(text, callback, name=""): + button = QPushButton(text) + button.setObjectName(name) + button.setAutoDefault(False) + button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + button.clicked.connect(callback) + return button + + +def microphone_icon(recording=False, color="#172434"): + """A scalable microphone outline, independent of the desktop icon theme.""" + pixmap = QPixmap(80, 80) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(QPen(QColor(color), 4, Qt.PenStyle.SolidLine, + Qt.PenCapStyle.RoundCap)) + if recording: + painter.setBrush(QColor(color)) + painter.drawRoundedRect(26, 26, 28, 28, 3, 3) + else: + painter.drawRoundedRect(32, 12, 16, 36, 8, 8) + painter.drawArc(22, 28, 36, 30, 180 * 16, 180 * 16) + painter.drawLine(40, 58, 40, 68) + painter.end() + return QIcon(pixmap) + + +def settings_icon(color="#B2C1D1"): + pixmap = QPixmap(48, 48) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(QPen(QColor(color), 3, Qt.PenStyle.SolidLine, + Qt.PenCapStyle.RoundCap)) + painter.translate(24, 24) + painter.drawEllipse(-12, -12, 24, 24) + painter.drawEllipse(-4, -4, 8, 8) + for _ in range(8): + painter.drawLine(0, -12, 0, -17) + painter.rotate(45) + painter.end() + return QIcon(pixmap) + + +def _model_location(model, provider, local_state=None): + model = model or t("Model not selected") + if provider == "local": + kind = ggml.accel_kind(local_state or {}) + location = t("Local GPU") if kind == "gpu" else t("Local CPU") if kind == "cpu" else t("Local") + else: + location = "CLI" if provider in ("claude", "codex", "agy") else "API" + return f"{model} ({location})" + + +def processing_locations(conf, mode="dictation", file_cleanup=None, file_timestamps=None): + """Display configured model IDs and observed local acceleration, never keys.""" + target = conf.transcribe_target() + local = ggml.state() + sound_model = target.model + timestamps = conf["file_timestamps"] if file_timestamps is None else file_timestamps + if mode == "meeting" or (mode == "file" and timestamps): + sound_model = api.timestamp_model(target.provider, target.model, target.file_model) + sound = _model_location(sound_model, target.provider, local.get("whisper")) + enabled = conf["cleanup_enabled"] + if mode == "file": + enabled = conf["file_cleanup"] if file_cleanup is None else file_cleanup + elif mode == "meeting": + enabled = conf["meeting_cleanup"] + elif mode == "ask": + enabled = conf["assistant_cleanup"] + provider = cleanup.provider(conf) + model = cleanup.model(conf) + if provider in ("codex", "agy") and not conf[f"cleanup_{provider}_model"].strip(): + model = t("{name} default model", name="Codex" if provider == "codex" else "Antigravity") + text = _model_location(model, provider, local.get("llama")) if enabled else t("Editing off") + location = t("Dictation: {sound} / Cleanup: {text}", sound=sound, text=text) + if mode == "meeting": + location += " / " + t("Minutes: {model}", model=_model_location(conf["meeting_model"], "openrouter")) + elif mode == "ask": + provider = assistant.provider(conf) + model = assistant.model(conf) + if provider in ("codex", "agy") and not conf[f"assistant_{provider}_model"].strip(): + model = t("{name} default model", name=assistant.display_name(conf)) + location += " / " + t("Assistant: {model}", model=_model_location(model, provider)) + return location + + +class HomeWindow(QWidget): + """Own navigation and presentation; recording and processing stay in Dikte.""" + + def __init__(self, controller, settings): + super().__init__() + self.controller = controller + self.conf = controller.conf + self.settings = settings + self.mode = "dictation" + self._last_result = None + self._last_answer = None + font = self.font() + font.setPointSizeF(max(10.5, font.pointSizeF())) + self.setFont(font) + self.setObjectName("home") + self.setWindowTitle("Dikte") + theme.apply(self, self.conf["theme"]) + self._theme_name = None + root = QVBoxLayout(self) + root.setContentsMargins(20, 16, 20, 12) + root.setSpacing(12) + navigation = QHBoxLayout() + navigation.setSpacing(6) + self.mode_group = QButtonGroup(self) + self.mode_buttons = {} + for name, title in (("dictation", "Dictation"), ("file", "File"), + ("meeting", "Meeting"), ("ask", "Assistant")): + button = _button(t(title), lambda checked=False, name=name: self.show_mode(name), "mode") + button.setCheckable(True) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.mode_group.addButton(button) + navigation.addWidget(button, 1) + self.mode_buttons[name] = button + self.settings_button = _button("", controller.open_settings, "settings") + self.settings_button.setIcon(settings_icon()) + self.settings_button.setIconSize(QSize(20, 20)) + self.settings_button.setFixedSize(34, 34) + self.settings_button.setToolTip(t("Settings")) + self.settings_button.setAccessibleName(t("Settings")) + navigation.addWidget(self.settings_button) + root.addLayout(navigation) + self.pages = QStackedWidget() + root.addWidget(self.pages, 1) + self.mode_pages = {} + self.mode_pages["dictation"] = self._scrolled(self._capture_page()) + self.mode_pages["file"] = settings.task_pages["file"] + self.mode_pages["meeting"] = self._scrolled(self._meeting_page()) + self.mode_pages["ask"] = self._scrolled(self._assistant_page()) + self.mode_pages["history"] = self._scrolled(self._history_page()) + for page in self.mode_pages.values(): + self.pages.addWidget(page) + self.footer = _label("", "footer", True) + root.addWidget(self.footer) + self._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self.refresh) + self._timer.start() + settings.applied.connect(self.refresh) + settings.file_cleanup.toggled.connect(self.refresh) + settings.file_timestamps.toggled.connect(self.refresh) + settings.transcriber.finished.connect(self.refresh_results) + settings.history.model().rowsRemoved.connect(self.refresh_results) + settings.history.model().modelReset.connect(self.refresh_results) + self.resize(620, 560) + screen = self.screen() + if screen: + room = screen.availableGeometry() + self.resize(min(620, room.width() - 40), min(560, room.height() - 80)) + self.setMinimumSize(420, 360) + self.show_mode("dictation") + self.refresh_results() + + @staticmethod + def _scrolled(page): + page.setMaximumWidth(680) + area = QScrollArea() + area.setWidgetResizable(True) + area.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) + area.setFrameShape(QFrame.Shape.NoFrame) + area.setWidget(page) + return area + + def _capture_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(0, 10, 0, 0) + layout.setSpacing(8) + self.capture_status = _label("", "heading", True) + layout.addWidget(self.capture_status) + self.capture_button = _button("", self._capture, "capture") + self._mic_icon = microphone_icon() + self._stop_icon = microphone_icon(recording=True) + self.capture_button.setIcon(self._mic_icon) + self.capture_button.setIconSize(QSize(44, 44)) + self.capture_button.setFixedSize(112, 112) + layout.addWidget(self.capture_button, 0, Qt.AlignmentFlag.AlignHCenter) + self.capture_shortcut = _label("", "muted", True) + layout.addWidget(self.capture_shortcut) + self.capture_models = _label("", "models", True) + layout.addWidget(self.capture_models) + controls = QHBoxLayout() + controls.addStretch() + self.pause_button = _button(t("Pause the recording"), self._pause) + self.cancel_button = _button(t("Discard the recording"), self._cancel_capture) + controls.addWidget(self.pause_button) + controls.addWidget(self.cancel_button) + controls.addStretch() + layout.addLayout(controls) + self.capture_error = _label() + layout.addWidget(self.capture_error) + card = QFrame() + card.setObjectName("result") + content = QVBoxLayout(card) + content.setContentsMargins(18, 12, 18, 12) + top = QHBoxLayout() + top.addWidget(_label(t("Latest text"))) + top.addStretch(1) + top.addWidget(_button(t("History"), lambda: self.show_mode("history"))) + content.addLayout(top) + self.latest_text = QPlainTextEdit() + self.latest_text.setReadOnly(True) + self.latest_text.setAccessibleName(t("Latest text")) + self.latest_text.setPlaceholderText(t("Your first transcript will appear here.")) + self.latest_text.setMinimumHeight(84) + self.latest_text.setMaximumHeight(100) + content.addWidget(self.latest_text) + self.latest_warning = _label() + content.addWidget(self.latest_warning) + actions = QHBoxLayout() + self.latest_time = _label("", "muted") + actions.addWidget(self.latest_time, 1) + self.copy_button = _button(t("Copy"), lambda: QApplication.clipboard().setText(self.latest_text.toPlainText())) + self.open_button = _button(t("Open text"), lambda: self._open_text(self.latest_text.toPlainText())) + actions.addWidget(self.copy_button) + actions.addWidget(self.open_button) + content.addLayout(actions) + layout.addWidget(card) + layout.addStretch(1) + return page + + def _meeting_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(0, 0, 0, 0) + self.meeting_status = _label("", "heading") + layout.addWidget(self.meeting_status) + self.meeting_hint = _label("", "muted") + layout.addWidget(self.meeting_hint) + actions = QVBoxLayout() + self.meeting_button = _button(t("Record a meeting"), self._meeting, "primary") + self.meeting_cancel = _button(t("Discard the meeting"), self._cancel_meeting) + actions.addWidget(self.meeting_button) + actions.addWidget(self.meeting_cancel) + layout.addLayout(actions) + self.meeting_error = _label() + layout.addWidget(self.meeting_error) + layout.addWidget(_label(t("Minutes"))) + minutes = self.settings.task_pages["minutes"] + minutes.setMinimumHeight(300) + layout.addWidget(minutes, 1) + minutes.show() + return page + + def _assistant_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(0, 0, 0, 0) + self.ask_status = _label("", "heading") + layout.addWidget(self.ask_status) + self.ask_scope = _label("", "muted") + layout.addWidget(self.ask_scope) + actions = QHBoxLayout() + self.ask_button = _button("", self._ask, "primary") + actions.addWidget(self.ask_button) + actions.addWidget(_button(t("Start a new conversation"), self.controller.reset_conversation)) + actions.addStretch() + layout.addLayout(actions) + self.ask_pause = _button(t("Pause the recording"), self._pause) + layout.addWidget(self.ask_pause) + self.ask_cancel = _button(t("Stop"), self._cancel_ask) + layout.addWidget(self.ask_cancel) + self.ask_error = _label() + layout.addWidget(self.ask_error) + self.ask_output = QPlainTextEdit() + self.ask_output.setReadOnly(True) + self.ask_output.setAccessibleName(t("Assistant reply")) + self.ask_output.setPlaceholderText(t("The assistant's reply will appear here.")) + self.ask_output.setMinimumHeight(160) + layout.addWidget(self.ask_output, 1) + layout.addWidget(_button(t("Copy"), lambda: QApplication.clipboard().setText(self.ask_output.toPlainText()))) + return page + + def _history_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(_button(t("Back to dictation"), lambda: self.show_mode("dictation"))) + layout.addWidget(self.settings.task_pages["history"], 1) + self.settings.task_pages["history"].show() + layout.addWidget(_button(t("Open selected text"), self._open_selected)) + self.settings.history.itemDoubleClicked.connect(self._open_selected) + return page + + def _open_selected(self, *_): + rows = self.settings._selected_rows() + if rows: + self._open_text("\n\n".join(row.get("text", "") for row in rows)) + + def _open_text(self, text): + if not text: + return + document = QDialog(self) + document.setWindowTitle(t("Transcript")) + document.resize(600, 500) + layout = QVBoxLayout(document) + editor = QPlainTextEdit() + editor.setReadOnly(True) + editor.setPlainText(text) + layout.addWidget(editor) + layout.addWidget(_button(t("Copy"), lambda: QApplication.clipboard().setText(text))) + document.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) + document.show() + + def show_mode(self, mode): + if mode not in self.mode_pages: + return + self.mode = mode + self.pages.setCurrentWidget(self.mode_pages[mode]) + self.mode_buttons["dictation" if mode == "history" else mode].setChecked(True) + if mode == "history": + self.settings._load_history() + elif mode == "meeting": + self.settings._load_minutes() + self.refresh() + + def showEvent(self, event): + super().showEvent(event) + self._timer.start() + self.refresh_results() + self.refresh() + + def hideEvent(self, event): + self._timer.stop() + super().hideEvent(event) + + def refresh_results(self, *_): + rows = cfg.read_history(self.conf["history_limit"]) + result = next((row for row in reversed(rows) if row.get("mode") != "ask"), {}) + answer = next((row for row in reversed(rows) if row.get("mode") == "ask"), {}) + self._set_result(result, answer) + + def _set_result(self, result, answer): + if result != self._last_result: + self._last_result = dict(result) + self.latest_text.setPlainText(result.get("text", "")) + self.latest_time.setText(result.get("ts", "")) + self.latest_warning.setText(result.get("cleanup_error", "")) + self.latest_warning.setVisible(bool(self.latest_warning.text())) + self.copy_button.setEnabled(bool(result.get("text"))) + self.open_button.setEnabled(bool(result.get("text"))) + if answer != self._last_answer: + self._last_answer = dict(answer) + self.ask_output.setPlainText(answer.get("text", "")) + + def refresh(self, *_): + app, conf = self.controller, self.conf + if self._theme_name != conf["theme"]: + self._theme_name = conf["theme"] + theme.apply(self, self._theme_name) + colors = theme.palette(self._theme_name) + self._mic_icon = microphone_icon(color=colors["accent_text"]) + self._stop_icon = microphone_icon(recording=True, color=colors["accent_text"]) + self.settings_button.setIcon(settings_icon(colors["muted"])) + messages = getattr(app, "home_messages", {}) + ready = conf.transcribe_ready() + recording = app.state == "recording" + busy = app.state == "busy" + title = t("Ready to speak") if ready else t("Set up transcription") + if recording: + seconds = int(app._recorded_seconds()) + title = t("Paused") if app.paused else t("Recording") + title += f" {seconds // 60:02d}:{seconds % 60:02d}" + elif busy: + title = messages.get("dictation_stage") or t("Transcribing…") + self.capture_status.setText(title) + action = t("Stop and transcribe") if recording else t("Start recording") if ready else t("Set up transcription") + self.capture_button.setAccessibleName(action) + self.capture_button.setIcon(self._stop_icon if recording else self._mic_icon) + self.capture_button.setToolTip(action) + self.capture_button.setEnabled(recording or not app.recording) + self.capture_shortcut.setText(" + ".join(part.strip() for part in conf["shortcut"].split("+"))) + self.pause_button.setVisible(recording) + self.cancel_button.setVisible(recording) + self.pause_button.setText(t("Resume the recording") if app.paused else t("Pause the recording")) + warning = "" + if conf["cleanup_enabled"] and conf["cleanup_provider"] == "local" and not conf.local_llm_ready(): + warning = t("The local editing model is missing. Set it up in Settings; the original transcript is kept if editing fails.") + self.capture_error.setText(messages.get("dictation", "") or warning) + self.capture_error.setVisible(bool(self.capture_error.text())) + details = processing_locations(conf, self.mode, self.settings.file_cleanup.isChecked(), + self.settings.file_timestamps.isChecked()) + self.capture_models.setText(details if self.mode == "dictation" else "") + self.footer.setText(details) + self.footer.setVisible(self.mode != "dictation") + self._refresh_meeting(messages, ready) + self._refresh_ask(messages, ready) + + def _refresh_meeting(self, messages, ready): + app = self.controller + state = app.meeting_state + supported = audio.sound().meetings + hint = t("Record your microphone and the other participants. Use headphones.") + if not supported: + hint = t("Meeting recording is not supported on this system. You can still transcribe a file.") + elif audio.sound() is audio.COREAUDIO: + hint = t("On macOS, set up BlackHole or Loopback and select the system audio source in Settings first.") + title = t("Meeting") + if state == "recording": + seconds = int(app.meeting_elapsed.elapsed() / 1000) + title = t("Recording") + f" {seconds // 60:02d}:{seconds % 60:02d}" + elif state == "working": + title = app.meeting_message or t("Writing the meeting up…") + if supported and not self.conf.openrouter_key(): + hint += "\n" + t("Connect OpenRouter in Settings to write minutes. The recording is kept if writing fails.") + self.meeting_status.setText(title) + self.meeting_hint.setText(hint) + self.meeting_button.setText(t("End the meeting and write it up") if state == "recording" else t("Record a meeting") if ready else t("Set up transcription")) + self.meeting_button.setEnabled(supported and state != "working") + self.meeting_cancel.setVisible(state == "recording") + self.meeting_error.setText(messages.get("meeting", "")) + self.meeting_error.setVisible(bool(self.meeting_error.text())) + + def _refresh_ask(self, messages, ready): + app, conf = self.controller, self.conf + name = assistant.display_name(conf) + provider = conf["assistant_provider"] + directory = assistant.working_dir(conf) + if provider == "claude": + permission = {"auto": t("Automatic permission decisions"), + "manual": t("Only actions that need no permission"), + "bypassPermissions": t("All permissions allowed")}.get(conf["assistant_permission_mode"], conf["assistant_permission_mode"]) + elif provider == "codex": + permission = {"workspace-write": t("Read files; write in the working directory"), + "read-only": t("Read only"), + "danger-full-access": t("No sandbox at all")}.get(conf["assistant_codex_sandbox"], conf["assistant_codex_sandbox"]) + elif provider == "agy": + permission = t("Uses the CLI's configured permissions") + else: + permission = t("Chat provider; no local command execution") + self.ask_scope.setText(t("{name}\nPermissions: {permission}\nWorking directory: {directory}\nShortcut: {shortcut}\nThis button sends a spoken command to the assistant. Its reply is copied without automatic pasting.", name=name, permission=permission, directory=directory if provider in ("claude", "codex", "agy") else t("Not used"), shortcut=conf["assistant_shortcut"] or t("Not assigned"))) + state = app.ask_state + self.ask_status.setText(t("Paused") if state == "recording" and app.paused else t("Recording") if state == "recording" else messages.get("ask_stage", t("Working…")) if state == "busy" else t("Assistant")) + available = self._assistant_available() + self.ask_button.setText(t("Stop and send command") if state == "recording" else t("Set up transcription") if not ready else t("Record a command") if available else t("Set up assistant")) + self.ask_button.setEnabled(state == "recording" or (state == "idle" and not app.recording)) + self.ask_pause.setVisible(state == "recording") + self.ask_pause.setText(t("Resume the recording") if app.paused else t("Pause the recording")) + self.ask_cancel.setVisible(state != "idle") + self.ask_error.setText(messages.get("ask", "") or ("" if available else t("Install the selected assistant CLI or configure its connection in Settings."))) + self.ask_error.setVisible(bool(self.ask_error.text())) + + def _assistant_available(self): + provider = self.conf["assistant_provider"] + binary = assistant.executable(provider) + if binary: + return bool(shutil.which(binary)) + return bool(self.conf.opencode_key() if provider == "opencode" else self.conf.openrouter_key()) + + def _capture(self): + app = self.controller + if app.state == "recording": + # Clicking Stop puts this window in front of the original target. + app.paste_override["dictation"] = False + app.stop() + elif not app.recording: + if not self.conf.transcribe_ready(): + self.settings.tabs.setCurrentIndex(self.settings.api_tab_index) + app.open_settings() + return + app.paste_override["dictation"] = False + app.start() + if app.state != "recording": + app.paste_override.pop("dictation", None) + self.refresh() + + def _ask(self): + app = self.controller + if app.ask_state == "recording": + app.paste_override["ask"] = False + app.stop_ask() + elif app.ask_state == "idle" and not app.recording: + if not self.conf.transcribe_ready(): + app.open_settings() + return + if not self._assistant_available(): + self.settings.tabs.setCurrentIndex(4) + app.open_settings() + return + app.paste_override["ask"] = False + app.start_ask() + if app.ask_state != "recording": + app.paste_override.pop("ask", None) + self.refresh() + + def _pause(self): + self.controller._toggle_pause() + self.refresh() + + def _cancel_capture(self): + if self.controller.state == "recording": + self.controller._cancel() + self.refresh() + + def _cancel_ask(self): + self.controller.cancel_ask() + self.refresh() + + def _meeting(self): + if not audio.sound().meetings: + return + if not self.conf.transcribe_ready() and self.controller.meeting_state == "idle": + self.controller.open_settings() + else: + self.controller._toggle_meeting() + self.refresh() + + def _cancel_meeting(self): + self.controller.cancel_meeting() + self.refresh() diff --git a/dikte/i18n.py b/dikte/i18n.py index b0b9ebf..f930830 100644 --- a/dikte/i18n.py +++ b/dikte/i18n.py @@ -60,6 +60,75 @@ def name(text, /, case=""): TR = { + "Theme": "Tema", + "Classic dark": "Klasik karanlık", + "Classic light": "Klasik beyaz", + "Local": "Yerel", + "Local GPU": "Yerel GPU", + "Local CPU": "Yerel CPU", + "Model not selected": "Model seçilmedi", + "{name} default model": "{name} varsayılan modeli", + "Dictation: {sound} / Cleanup: {text}": "Dikte: {sound} / Temizleme: {text}", + "Minutes: {model}": "Tutanak: {model}", + "Assistant: {model}": "Asistan: {model}", + "Completed with a warning: {error}": "Uyarıyla tamamlandı: {error}", + "Dikte: completed with a warning": "Dikte: uyarıyla tamamlandı", + "Connect OpenRouter in Settings to write minutes. The recording is kept if writing fails.": "Tutanak yazmak için ayarlardan OpenRouter bağlantısını kurun. Tutanak yazılamazsa kayıt korunur.", + "Set up assistant": "Asistanı ayarla", + "Install the selected assistant CLI or configure its connection in Settings.": "Seçili asistanın CLI uygulamasını kurun veya ayarlardan bağlantısını yapılandırın.", + "The local editing model is missing. Set it up in Settings; the original transcript is kept if editing fails.": "Yerel düzenleme modeli eksik. Ayarlardan yapılandırın; düzenleme başarısız olursa ham metin korunur.", + # Compact desktop workspace. + 'Audio: {sound}\nText: {text}': 'Ses: {sound}\nMetin: {text}', + 'This computer': 'Bu bilgisayar', + 'Editing off': 'Düzenleme kapalı', + 'Record your microphone and the other participants. Use headphones.': 'Mikrofonunuzu ve diğer katılımcıları kaydedin. Kulaklık kullanın.', + 'Minutes: OpenRouter': 'Tutanak: OpenRouter', + 'Use the shortcut in the app where you want to write.': 'Yazmak istediğiniz uygulamada kısayola basın.', + 'Button recordings stay here and are copied to the clipboard.': 'Düğmeyle başlattığınız kayıtların metni burada kalır ve panoya kopyalanır.', + 'Latest text': 'Son metin', + 'Your first transcript will appear here.': 'İlk kaydınızın metni burada görünecek.', + 'Open text': 'Metni aç', + 'Assistant reply': 'Asistan yanıtı', + "The assistant's reply will appear here.": 'Asistanın yanıtı burada görünecek.', + 'Ready to speak': 'Konuşmaya hazır', + 'Set up transcription': 'Yazıya çevirmeyi ayarla', + 'Automatic language': 'Dil otomatik', + '{mic} / {language}': '{mic} / {language}', + 'Meeting recording is not supported on this system. You can still transcribe a file.': 'Bu sistemde toplantı kaydı desteklenmiyor. Ses dosyalarını yazıya çevirebilirsiniz.', + '{name}\nPermissions: {permission}\nWorking directory: {directory}\nShortcut: {shortcut}\nThis button sends a spoken command to the assistant. Its reply is copied without automatic pasting.': '{name}\nYetkiler: {permission}\nÇalışma dizini: {directory}\nKısayol: {shortcut}\nBu düğme asistana sesli komut gönderir. Yanıtı otomatik yapıştırılmadan kopyalanır.', + '{name} CLI (provider connection)': '{name} CLI (sağlayıcı bağlantısı)', + 'Assistant: {name}': 'Asistan: {name}', + 'Settings': 'Ayarlar', + 'Back to dictation': 'Dikteye dön', + 'Open selected text': 'Seçili metni aç', + 'Paused': 'Duraklatıldı', + 'Choose a connection or download a local model in Settings.': 'Ayarlardan bir bağlantı seçin veya yerel model indirin.', + 'On macOS, set up BlackHole or Loopback and select the system audio source in Settings first.': 'macOS üzerinde önce BlackHole veya Loopback kurun ve ayarlardan sistem sesi kaynağını seçin.', + 'Stop and send command': 'Bitir ve komutu gönder', + "Uses the CLI's configured permissions": 'CLI için yapılandırılmış yetkileri kullanır', + 'Chat provider; no local command execution': 'Sohbet sağlayıcısı; yerel komut çalıştırmaz', + 'Record a command': 'Sesli komut kaydet', + 'Automatic permission decisions': 'İzin kararları otomatik', + 'Only actions that need no permission': 'Yalnız izin gerektirmeyen işlemler', + 'All permissions allowed': 'Tüm izinler açık', + 'Not used': 'Kullanılmıyor', + 'Not assigned': 'Atanmamış', + 'Assistant': 'Asistan', + 'Read files; write in the working directory': 'Dosyaları oku; çalışma dizinine yaz', + 'Open Dikte': 'Dikteyi aç', + '{error}\n\nThe recording has been kept. Meeting → Minutes can try again.': '{error}\n\nKayıt korundu. Toplantı → Tutanaklar bölümünden yeniden deneyebilirsiniz.', + 'Original text kept, cleanup failed: {error}': 'Ham metin korundu, düzenleme başarısız: {error}', + 'Transcript ready: {preview}': 'Metin hazır: {preview}', + 'Editing did not finish. The original text was kept. {error}': 'Düzenleme tamamlanamadı. Ham metin korundu. {error}', + 'Transcript ready': 'Metin hazır', + 'Text editing and dictionary': 'Metin düzenleme ve sözlük', + 'Settings category': 'Ayar kategorisi', + 'Apply changes': 'Değişiklikleri uygula', + 'Discard changes': 'Değişikliklerden vazgeç', + 'Unsaved changes': 'Kaydedilmemiş değişiklikler', + 'Dictionary': 'Sözlük', + 'File': 'Dosya', + # --- tray --------------------------------------------------------- "Start recording": "Kaydı başlat", "Stop and transcribe": "Kaydı bitir ve yaz", diff --git a/dikte/icons/check-light.svg b/dikte/icons/check-light.svg new file mode 100644 index 0000000..47e04cf --- /dev/null +++ b/dikte/icons/check-light.svg @@ -0,0 +1 @@ + diff --git a/dikte/icons/check.svg b/dikte/icons/check.svg new file mode 100644 index 0000000..3b9baff --- /dev/null +++ b/dikte/icons/check.svg @@ -0,0 +1 @@ + diff --git a/dikte/icons/chevron-down-light.svg b/dikte/icons/chevron-down-light.svg new file mode 100644 index 0000000..b69b232 --- /dev/null +++ b/dikte/icons/chevron-down-light.svg @@ -0,0 +1 @@ + diff --git a/dikte/icons/chevron-down.svg b/dikte/icons/chevron-down.svg new file mode 100644 index 0000000..c6b4f49 --- /dev/null +++ b/dikte/icons/chevron-down.svg @@ -0,0 +1 @@ + diff --git a/dikte/icons/chevron-up-light.svg b/dikte/icons/chevron-up-light.svg new file mode 100644 index 0000000..9fb4fa4 --- /dev/null +++ b/dikte/icons/chevron-up-light.svg @@ -0,0 +1 @@ + diff --git a/dikte/icons/chevron-up.svg b/dikte/icons/chevron-up.svg new file mode 100644 index 0000000..1a066d4 --- /dev/null +++ b/dikte/icons/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/dikte/overlay.py b/dikte/overlay.py index 79601c7..2e4969e 100644 --- a/dikte/overlay.py +++ b/dikte/overlay.py @@ -9,33 +9,18 @@ from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QF from PyQt6.QtWidgets import QWidget, QApplication from . import mac_window +from . import theme BARS = 22 -HEIGHT = 56 +HEIGHT = 48 MIN_WIDTH = 210 MAX_WIDTH = 460 MARGIN = 28 GAP = 10 # between two indicators sharing a corner FOLLOW_EVERY = 8 # ticks between two looks for the pointer: about four a second -BG = QColor(22, 24, 29, 238) -BORDER = QColor(255, 255, 255, 28) -TEXT = QColor(235, 237, 242) -MUTED = QColor(150, 156, 168) -REC = QColor(240, 78, 82) -BUSY = QColor(120, 170, 255) -OK = QColor(80, 205, 140) -ERR = QColor(240, 100, 90) -WARN = QColor(240, 180, 80) -THEM = QColor(110, 190, 255) # the other side of a meeting - -ASK = QColor(150, 140, 255) # recording a command rather than a dictation -# Recording, but nothing is going in. The same amber a warning gets, and for -# the same reason: it is the colour that stops you walking away from it. -HELD = WARN - -STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY, - "done": OK, "warning": WARN, "error": ERR} +STATE_ROLES = {"recording": "rec", "asking": "ask", "meeting": "rec", "busy": "accent", + "done": "ok", "warning": "warn", "error": "err"} LIVE = ("recording", "asking", "meeting") @@ -94,8 +79,9 @@ class Overlay(QWidget): under way at the same time and still both be visible.""" def __init__(self, corner="bottom-left", below=None, dismissable=False, - screen_name="", follow_pointer=False): + screen_name="", follow_pointer=False, theme_name=theme.DEFAULT): super().__init__(None) + self.set_theme(theme_name) self.corner = corner self.screen_name = screen_name # Whether it goes on following the pointer once it is up, rather than @@ -388,6 +374,15 @@ class Overlay(QWidget): # ---- painting -------------------------------------------------- + def set_theme(self, name): + self.colors = {key: QColor(value) for key, value in theme.palette(name).items()} + states = (dict(rec="#D52E3F", ok="#187B4B", err="#BD2735", warn="#946000", + them="#2460A0", ask="#7048B4") if name == "light" else + dict(rec="#F04E52", ok="#50CD8C", err="#F0645A", warn="#F0B450", + them="#6EBEFF", ask="#968CFF")) + self.colors.update({key: QColor(value) for key, value in states.items()}) + self.update() + def paintEvent(self, _event): if self.state == "hidden": return # translucent window, nothing drawn means nothing shown @@ -397,14 +392,14 @@ class Overlay(QWidget): rect = QRectF(0.5, 0.5, self.width() - 1, self.height() - 1) path = QPainterPath() - path.addRoundedRect(rect, 15, 15) - painter.fillPath(path, BG) - painter.setPen(QPen(BORDER, 1)) + path.addRoundedRect(rect, 12, 12) + painter.fillPath(path, self.colors["base"]) + painter.setPen(QPen(self.colors["border"], 1)) painter.drawPath(path) - accent = STATE_COLORS.get(self.state, MUTED) + accent = self.colors[STATE_ROLES.get(self.state, "muted")] if self._held: - accent = HELD + accent = self.colors["warn"] self._draw_indicator(painter, accent) if self.state in LIVE: @@ -480,13 +475,13 @@ class Overlay(QWidget): gap = (right - left - BARS * bar_w) / max(1, BARS - 1) return left, bar_w, bar_w + gap - @staticmethod - def _bar_colour(shaped, accent): - color = QColor(accent if shaped > 0.04 else MUTED) + def _bar_colour(self, shaped, accent): + color = QColor(accent if shaped > 0.04 else self.colors["muted"]) color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2)) return color - def _draw_waveform(self, painter, accent=REC): + def _draw_waveform(self, painter, accent=None): + accent = accent if accent is not None else self.colors["rec"] if self.state == "meeting": self._draw_dual_waveform(painter) return @@ -512,7 +507,7 @@ class Overlay(QWidget): painter.setPen(Qt.PenStyle.NoPen) for i, (mine, theirs) in enumerate(zip(self.levels, self.levels2)): x = left + i * step - for level, accent, up in ((mine, REC, True), (theirs, THEM, False)): + for level, accent, up in ((mine, self.colors["rec"], True), (theirs, self.colors["them"], False)): shaped = min(1.0, level ** 0.55) h = 2.0 + shaped * 12.0 y = mid - 1.5 - h if up else mid + 1.5 @@ -524,7 +519,7 @@ class Overlay(QWidget): font.setPointSizeF(10.0) font.setFamilies(["monospace"]) painter.setFont(font) - painter.setPen(MUTED) + painter.setPen(self.colors["muted"]) mins, secs = divmod(int(self.seconds), 60) hours, mins = divmod(mins, 60) text = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}" @@ -543,7 +538,7 @@ class Overlay(QWidget): """A faint cross on the right: without it there is nothing to say the box can be clicked away, and a feature nobody can see is not one.""" cx, cy = self.width() - 18.0, self.height() / 2 - pen = QPen(QColor(MUTED), 1.6) + pen = QPen(QColor(self.colors["muted"]), 1.6) pen.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(pen) painter.setBrush(Qt.BrushStyle.NoBrush) @@ -552,7 +547,7 @@ class Overlay(QWidget): def _draw_message(self, painter): painter.setFont(self._label_font()) - painter.setPen({"error": ERR, "warning": WARN}.get(self.state, TEXT)) + painter.setPen({"error": self.colors["err"], "warning": self.colors["warn"]}.get(self.state, self.colors["text"])) # Leave the cross its corner rather than running the text under it. box = QRectF(46, 0, self.width() - 60 - (18 if self._can_dismiss else 0), self.height()) diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py index 0603aa5..44c6e8e 100644 --- a/dikte/settings_ui.py +++ b/dikte/settings_ui.py @@ -6,13 +6,13 @@ import shutil import sys import threading -from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal +from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, QSignalBlocker, pyqtSignal from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, - QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, + QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTabWidget, QVBoxLayout, QWidget, ) from . import __version__ @@ -32,6 +32,7 @@ from . import paste from . import update from .filetranscribe import FileTranscriber from .i18n import t +from . import theme UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")] LANGUAGES = [ @@ -382,6 +383,8 @@ class LocalModelBox(QGroupBox): layout.setContentsMargins(0, 0, 0, 0) for index, widget in enumerate(widgets): layout.addWidget(widget, 1 if index == 0 else 0) + if widgets and all(isinstance(widget, QPushButton) for widget in widgets): + layout.addStretch() holder = QWidget() holder.setLayout(layout) return holder @@ -397,13 +400,17 @@ class LocalModelBox(QGroupBox): def load(self, model, repo=""): """Show what is stored. What else is on offer is asked for on the way up. - Nothing is fetched here: building the settings window is not the same as - opening it, and a list nobody is looking at is not worth a request. What - is already on this disk is shown straight away either way. + Hidden boxes defer fetching until shown. A catalog already fetched for + this repository survives Apply; a visible box on a new repository + refreshes immediately. Installed files are available in either case. """ + target_repo = repo or (ggml.suggested_llm()[0] if self._repos is not None else "") + reuse = self._answered and self._chosen_in == target_repo and self.repository() == target_repo + items = self._current_items() if reuse else [] + self._later.stop() self._wanted = model - self._pending = True - self._answered = False + self._pending = not reuse + self._answered = reuse self._show_program() self._chosen_in = "" if self._repos is not None: @@ -413,7 +420,10 @@ class LocalModelBox(QGroupBox): self.repo.setCurrentText(self._chosen_in) self.repo.blockSignals(False) self._fill_repos_box(suggested) - self._fill_models([]) + self._fill_models(items) + if self._pending and self.isVisible(): + self._pending = False + self._fetch_models(self.repository()) def showEvent(self, event): super().showEvent(event) @@ -793,6 +803,10 @@ class LocalModelBox(QGroupBox): def _fill_models_from_current(self): """Redraw the rows without asking anybody anything again.""" + self._wanted = self.selected() + self._fill_models(self._current_items()) + + def _current_items(self): # By name, because the recommended model has a row of its own at the # top as well as one in its group, and reading the rows back twice # would double it in the list every time a download finished. @@ -802,8 +816,7 @@ class LocalModelBox(QGroupBox): if item is not None and item.name not in seen: seen.add(item.name) items.append(item) - self._wanted = self.selected() - self._fill_models(items) + return items def _delete(self): name = self.selected() @@ -920,6 +933,7 @@ class SettingsWindow(QDialog): self._release_url = update.RELEASES_PAGE self.transcriber = FileTranscriber(conf, self) self.setWindowTitle(t("Dikte Settings")) + theme.apply(self, conf["theme"]) # One for the whole window, parented to it so it outlives the boxes it # watches and goes when they do. @@ -930,23 +944,46 @@ class SettingsWindow(QDialog): tabs.addTab(self._scrolled(self._display_tab()), t("Display")) self.api_tab_index = tabs.addTab( self._scrolled(self._api_tab()), t("API and models")) - tabs.addTab(self._scrolled(self._prompt_tab()), t("Cleanup rules")) + tabs.addTab(self._scrolled(self._prompt_tab()), t("Text editing and dictionary")) tabs.addTab(self._scrolled(self._assistant_tab()), t("Agent")) tabs.addTab(self._scrolled(self._meeting_tab()), t("Meeting")) - tabs.addTab(self._scrolled(self._minutes_tab()), t("Minutes")) - tabs.addTab(self._scrolled(self._file_tab()), t("Audio file")) tabs.addTab(self._scrolled(self._shortcut_tab()), t("Shortcuts")) - tabs.addTab(self._scrolled(self._history_tab()), t("History")) + self.task_pages = { + "file": self._scrolled(self._file_tab()), + "minutes": self._scrolled(self._minutes_tab()), + "history": self._scrolled(self._history_tab()), + } + for page in self.task_pages.values(): + page.setParent(self) + page.hide() + retention = QGroupBox(t("History")) + retention_form = QFormLayout(retention) + retention_form.addRow(t("Keep at most"), self.history_limit) + general_layout = tabs.widget(0).widget().layout() + general_layout.addRow(retention) + tabs.tabBar().hide() + self.categories = QComboBox() + self.categories.setAccessibleName(t("Settings category")) + for index in range(tabs.count()): + self.categories.addItem(tabs.tabText(index)) + self.categories.currentIndexChanged.connect(tabs.setCurrentIndex) + tabs.currentChanged.connect(self.categories.setCurrentIndex) # Save keeps the window open, so the window is closed with the titlebar # cross (or Escape) instead. A "Cancel" next to it would be a lie: the # settings are already on disk by then. buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save) - buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Save")) + buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Apply changes")) + discard = buttons.addButton(t("Discard changes"), QDialogButtonBox.ButtonRole.ResetRole) + discard.clicked.connect(self._discard_changes) + self.dirty_label = QLabel("") + self.dirty_label.setObjectName("muted") buttons.accepted.connect(self._save) layout = QVBoxLayout(self) + layout.addWidget(self.categories) layout.addWidget(tabs) + layout.addWidget(self.dirty_label) layout.addWidget(buttons) self._size_to_screen(680, 640) @@ -967,6 +1004,26 @@ class SettingsWindow(QDialog): self.meetings.finished.connect(self._on_minutes_finished) self.meetings.failed.connect(self._on_minutes_failed) self._load() + for button in self.findChildren(QPushButton): + button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + compact = self.tabs.findChildren(QSpinBox) + [ + self.theme_choice, + self.ui_language, self.language, self.paste_shortcut, self.corner, + self.cleanup_reasoning, self.local_llm_reasoning, + self.assistant_reasoning, self.meeting_reasoning, + ] + for box in compact: + box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self._saved_form = self._form_values() + for box in self.tabs.findChildren((QLineEdit, QComboBox, QCheckBox, QSpinBox, QPlainTextEdit)): + if isinstance(box, (QLineEdit, QPlainTextEdit)): + box.textChanged.connect(self._show_dirty) + elif isinstance(box, QComboBox): + box.currentTextChanged.connect(self._show_dirty) + elif isinstance(box, QCheckBox): + box.toggled.connect(self._show_dirty) + else: + box.valueChanged.connect(self._show_dirty) self._load_codex_models() self._load_agy_models() self._load_hosted_models() @@ -987,6 +1044,64 @@ class SettingsWindow(QDialog): self._local_state_timer.timeout.connect(self._show_local_state) self._show_local_state() + def _form_values(self): + values = [] + for box in self.tabs.findChildren((QLineEdit, QComboBox, QCheckBox, QSpinBox, QPlainTextEdit)): + if isinstance(box, QPlainTextEdit): + if not box.isReadOnly(): + values.append(box.toPlainText()) + elif isinstance(box, QLineEdit): + values.append(box.text()) + elif isinstance(box, QComboBox): + value = box.currentData() + values.append(box.currentText() if value is None else value) + elif isinstance(box, QCheckBox): + values.append(box.isChecked()) + else: + values.append(box.value()) + models = dict(self._models) + provider = self.transcribe_provider.currentData() + if provider in models: + models[provider] = self.transcribe_model.currentText().strip() + values.append(models) + return values + + def refresh_configuration(self): + """Reload clean forms while keeping unsaved edits for a later merge.""" + if self._form_values() == self._saved_form: + self._load() + self._saved_form = self._form_values() + self._show_dirty() + + def refresh_sources(self): + """Discover new devices without replacing an in-progress selection.""" + self._sources = audio.list_sources() + monitors = audio.list_monitors() + for box, title, sources in ( + (self.mic, "Default microphone", self._sources), + (self.meeting_mic, "Same as dictation", self._sources), + (self.meeting_system, "Current output", monitors), + ): + selected = box.currentData() or "" + with QSignalBlocker(box): + box.clear() + box.addItem(t(title), "") + for name, description in sources: + box.addItem(description, name) + if selected and box.findData(selected) < 0: + box.addItem(t("{name} (not connected)", name=selected), selected) + self._select_data(box, selected) + self._show_dirty() + + def _show_dirty(self, *_): + dirty = self._form_values() != getattr(self, "_saved_form", []) + self.dirty_label.setText(t("Unsaved changes") if dirty else "") + + def _discard_changes(self): + self._load() + self._saved_form = self._form_values() + self._show_dirty() + def showEvent(self, event): super().showEvent(event) self._show_local_state() @@ -1036,8 +1151,10 @@ class SettingsWindow(QDialog): # that height on as the window's minimum, and a tall one (the API tab # is the tallest, and taller still under a large interface font) then # pushes Save off the bottom of the screen with no way to shrink back. + page.setMaximumWidth(680) area = QScrollArea() area.setWidgetResizable(True) + area.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) area.setFrameShape(QScrollArea.Shape.NoFrame) area.setWidget(page) for box in page.findChildren((QComboBox, QAbstractSpinBox)): @@ -1161,6 +1278,13 @@ class SettingsWindow(QDialog): page = QWidget() form = QFormLayout(page) + self.theme_choice = QComboBox() + for name, label in theme.NAMES.items(): + self.theme_choice.addItem(t(label), name) + self.theme_choice.currentIndexChanged.connect( + lambda: theme.apply(self, self.theme_choice.currentData())) + form.addRow(t("Theme"), self.theme_choice) + self.indicator_screen = QComboBox() # The active screen rather than the pointer, for the reason in # overlay._compositor_screen: it is what a compositor will answer for, @@ -1477,6 +1601,7 @@ class SettingsWindow(QDialog): "glossary, so it can repair the ones that still come out wrong.")) hint.setWordWrap(True) layout.addWidget(hint) + layout.addWidget(QLabel(t("Dictionary"))) self.transcribe_prompt = QPlainTextEdit() self.transcribe_prompt.setMaximumHeight(90) layout.addWidget(self.transcribe_prompt) @@ -1828,7 +1953,7 @@ class SettingsWindow(QDialog): self.minutes_list = QListWidget() self.minutes_list.setWordWrap(True) - self.minutes_list.setMaximumHeight(170) + self.minutes_list.setMaximumHeight(110) self.minutes_list.currentItemChanged.connect(self._show_minutes) layout.addWidget(self.minutes_list) @@ -1839,6 +1964,7 @@ class SettingsWindow(QDialog): self.minutes_view = QPlainTextEdit() self.minutes_view.setReadOnly(True) self.minutes_view.setPlaceholderText(t("Pick a meeting to read it.")) + self.minutes_view.setMinimumHeight(120) layout.addWidget(self.minutes_view, 1) copy = QPushButton(t("Copy")) @@ -1861,10 +1987,10 @@ class SettingsWindow(QDialog): row = QHBoxLayout() row.addWidget(copy) row.addWidget(self.minutes_retry) - row.addStretch(1) row.addWidget(folder) row.addWidget(delete) row.addWidget(reload_) + row.addStretch() layout.addLayout(row) return page @@ -2053,11 +2179,6 @@ class SettingsWindow(QDialog): "Once the history passes this many entries, the oldest one is dropped " "every time a new one arrives. Set it to 0 to keep everything." )) - limit_row = QHBoxLayout() - limit_row.addWidget(QLabel(t("Keep at most"))) - limit_row.addWidget(self.history_limit) - limit_row.addStretch(1) - layout.addLayout(limit_row) copy = QPushButton(t("Copy selected to clipboard")) copy.clicked.connect(self._copy_history) @@ -2070,10 +2191,13 @@ class SettingsWindow(QDialog): row = QHBoxLayout() row.addWidget(copy) row.addWidget(delete) - row.addStretch(1) - row.addWidget(clear) - row.addWidget(reload_) + row.addStretch() layout.addLayout(row) + more = QHBoxLayout() + more.addWidget(clear) + more.addWidget(reload_) + more.addStretch() + layout.addLayout(more) return page @staticmethod @@ -2167,6 +2291,8 @@ class SettingsWindow(QDialog): layout.setContentsMargins(0, 0, 0, 0) for index, widget in enumerate(widgets): layout.addWidget(widget, 1 if index == 0 else 0) + if widgets and all(isinstance(widget, QPushButton) for widget in widgets): + layout.addStretch() holder = QWidget() holder.setLayout(layout) return holder @@ -2176,7 +2302,9 @@ class SettingsWindow(QDialog): def _load(self): conf = self.conf self._select_data(self.ui_language, conf["ui_language"]) - self._select_data(self.mic, conf["mic_target"]) + self._select_data(self.theme_choice, conf["theme"]) + theme.apply(self, self.theme_choice.currentData()) + self._select_source(self.mic, conf["mic_target"]) self._select_data(self.language, conf["language"]) self.auto_paste.setChecked(conf["auto_paste"]) self.paste_shortcut.setCurrentText(conf["paste_shortcut"]) @@ -2269,8 +2397,8 @@ class SettingsWindow(QDialog): conf["assistant_prompt"] or self._loaded_defaults["assistant"] ) - self._select_data(self.meeting_mic, conf["meeting_mic_target"]) - self._select_data(self.meeting_system, conf["meeting_system_target"]) + self._select_source(self.meeting_mic, conf["meeting_mic_target"]) + self._select_source(self.meeting_system, conf["meeting_system_target"]) self.meeting_self_name.setText(conf["meeting_self_name"]) self.meeting_other_name.setText(conf["meeting_other_name"]) self.meeting_participants.setPlainText(conf["meeting_participants"]) @@ -2285,9 +2413,11 @@ class SettingsWindow(QDialog): conf["meeting_prompt"] or self._loaded_defaults["meeting"] ) - self.file_timestamps.setChecked(conf["file_timestamps"]) - self.file_cleanup.setChecked(conf["file_cleanup"]) - self.file_path = "" + with QSignalBlocker(self.file_timestamps), QSignalBlocker(self.file_cleanup): + self.file_timestamps.setChecked(conf["file_timestamps"]) + self.file_cleanup.setChecked(conf["file_cleanup"]) + if not hasattr(self, "file_path"): + self.file_path = "" for which, (box, _status, _missing) in self._shortcut_rows.items(): box.setCurrentText(conf[hotkey.SHORTCUTS[which].setting]) @@ -2300,10 +2430,13 @@ class SettingsWindow(QDialog): self._refresh_assistant_status() self._load_history() self._load_minutes() + self._loaded_config = dict(conf.data) def _save(self): conf = self.conf + before = dict(conf.data) conf["ui_language"] = self.ui_language.currentData() or "auto" + conf["theme"] = self.theme_choice.currentData() or theme.DEFAULT conf["mic_target"] = self.mic.currentData() or "" conf["language"] = self.language.currentData() or "auto" conf["auto_paste"] = self.auto_paste.isChecked() @@ -2450,12 +2583,18 @@ class SettingsWindow(QDialog): or hotkey.default_combo(which)) conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["history_limit"] = self.history_limit.value() + # A retained form may predate a CLI reload. Only its edits take priority; + # unchanged fields keep the current runtime value. + for key, value in before.items(): + if conf.data.get(key) == self._loaded_config.get(key): + conf.data[key] = value try: conf.save() except OSError as exc: # An antivirus or a sync tool holding the file for a beat is a # message, not an exit: an exception out of a Qt slot takes the # whole application down. + conf.data = before QMessageBox.warning(self, "Dikte", t("Could not save the settings: {error}", error=exc)) @@ -2465,7 +2604,9 @@ class SettingsWindow(QDialog): cfg.trim_history(conf["history_limit"]) except OSError as exc: print(f"dikte: could not trim the history ({exc})") - self._load_history() # the trim may just have dropped rows from the list + self._load() + self._saved_form = self._form_values() + self._show_dirty() self.applied.emit() # conf.save() has switched the language t() speaks, so the message box # already answers in the new one; the labels around it were translated @@ -2489,6 +2630,12 @@ class SettingsWindow(QDialog): or self.local_whisper._downloading or self.local_llm._downloading) + @staticmethod + def _select_source(combo, value): + if value and combo.findData(value) < 0: + combo.addItem(t("{name} (not connected)", name=value), value) + SettingsWindow._select_data(combo, value) + @staticmethod def _select_data(combo, value): index = combo.findData(value) diff --git a/dikte/theme.py b/dikte/theme.py new file mode 100644 index 0000000..4984092 --- /dev/null +++ b/dikte/theme.py @@ -0,0 +1,136 @@ +"""Built-in desktop palettes shared by windows and recording indicators.""" + +from pathlib import Path + +from PyQt6.QtGui import QColor, QPalette + +DEFAULT = "nord" +NAMES = {"nord": "Nord", "dark": "Classic dark", "light": "Classic light", "dracula": "Dracula"} + +# Nord keeps the original blue workspace colors for existing installations. +PALETTES = { + "nord": dict(base="#172434", surface="#213247", border="#3C5168", text="#F1F5FA", + muted="#B2C1D1", accent="#A5C7FA", accent_text="#172434", hover="#2B4059", + disabled_text="#8998A9", disabled_bg="#1C2B3D", capture_disabled="#687B93"), + "dark": dict(base="#101010", surface="#202020", border="#474747", text="#F5F5F5", + muted="#BBBBBB", accent="#DDDDDD", accent_text="#101010", hover="#303030", + disabled_text="#888888", disabled_bg="#181818", capture_disabled="#606060"), + "light": dict(base="#FFFFFF", surface="#F2F4F7", border="#B6BEC9", text="#1D2633", + muted="#526174", accent="#285DB5", accent_text="#FFFFFF", hover="#E2E7EF", + disabled_text="#687385", disabled_bg="#E9EDF2", capture_disabled="#91A8CD"), + "dracula": dict(base="#282A36", surface="#303341", border="#626787", text="#F8F8F2", + muted="#BBC0D9", accent="#BD93F9", accent_text="#282A36", hover="#44475A", + disabled_text="#9096B0", disabled_bg="#282A36", capture_disabled="#706483"), +} + + +def palette(name=DEFAULT): + return PALETTES.get(name, PALETTES[DEFAULT]) + + +_STYLE = """ +QWidget { color: @text; } +QDialog, QWidget#home, QScrollArea, QScrollArea > QWidget > QWidget { + background: @base; +} +QLabel { background: transparent; } +QLabel#brand { font-size: 21px; font-weight: 700; } +QLabel#heading { font-size: 24px; font-weight: 500; } +QLabel#muted, QLabel#footer { color: @muted; } +QLabel#footer { padding: 4px 0; } +QLabel#models { color: @muted; font-size: 12px; } +QFrame#result { background: @surface; border-radius: 12px; } +QPushButton { + background: @surface; border: 1px solid @border; border-radius: 7px; + padding: 6px 10px; min-height: 18px; +} +QPushButton:hover { background: @hover; } +QPushButton:pressed, QPushButton:checked { background: @border; } +QPushButton:focus, QComboBox:focus, QLineEdit:focus, QPlainTextEdit:focus, +QListWidget:focus { border: 2px solid @accent; } +QPushButton:disabled { color: @disabled_text; background: @disabled_bg; } +QPushButton#primary { background: @accent; color: @accent_text; font-weight: 600; } +QPushButton#primary:disabled { background: @disabled_bg; color: @disabled_text; border-color: @border; } +QPushButton#capture { + background: @accent; color: @accent_text; border: 6px solid @surface; + border-radius: 56px; padding: 0; + min-width: 100px; max-width: 100px; min-height: 100px; max-height: 100px; +} +QPushButton#capture:focus { border-color: @text; } +QPushButton#capture:disabled { background: @capture_disabled; } +QPushButton#mode { border: 1px solid @border; background: @surface; padding: 6px 4px; } +QPushButton#mode:hover { background: @hover; } +QPushButton#mode:checked { background: @accent; color: @accent_text; border-color: @accent; } +QPushButton#mode:focus { border: 2px solid @accent; } +QPushButton#settings { padding: 0; min-height: 30px; min-width: 32px; } +QGroupBox { border: 1px solid @border; border-radius: 8px; margin-top: 14px; padding: 8px; } +QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 5px; } +QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QSpinBox { + background: @surface; border: 1px solid @border; border-radius: 5px; + padding: 5px 7px; selection-background-color: @accent; selection-color: @accent_text; +} +QComboBox { padding-right: 28px; min-height: 18px; } +QComboBox::drop-down { + subcontrol-origin: border; subcontrol-position: top right; + width: 26px; border: none; background: transparent; +} +QComboBox::down-arrow { image: url("__ICONS__/chevron-down@arrow_suffix.svg"); width: 12px; height: 12px; } +QSpinBox { padding: 2px 20px 2px 6px; min-height: 18px; } +QSpinBox > QLineEdit { border: none; background: transparent; padding: 0; } +QSpinBox::up-button, QSpinBox::down-button { + subcontrol-origin: border; width: 20px; border: none; background: transparent; +} +QSpinBox::up-button { subcontrol-position: top right; border-top-right-radius: 5px; } +QSpinBox::down-button { subcontrol-position: bottom right; border-bottom-right-radius: 5px; } +QSpinBox::up-button:hover, QSpinBox::down-button:hover { background: @border; } +QSpinBox::up-arrow { image: url("__ICONS__/chevron-up@arrow_suffix.svg"); width: 10px; height: 10px; } +QSpinBox::down-arrow { image: url("__ICONS__/chevron-down@arrow_suffix.svg"); width: 10px; height: 10px; } +QCheckBox { spacing: 7px; } +QCheckBox::indicator { + width: 14px; height: 14px; border: 1px solid @border; + border-radius: 4px; background: @surface; +} +QCheckBox::indicator:hover { border-color: @accent; } +QCheckBox::indicator:checked { + background: @accent; border-color: @accent; + image: url("__ICONS__/check@arrow_suffix.svg"); +} +QCheckBox:focus::indicator { border-color: @text; } +QCheckBox:disabled { color: @disabled_text; } +QCheckBox::indicator:disabled { background: @disabled_bg; border-color: @disabled_text; } +QCheckBox::indicator:checked:disabled { background: @disabled_text; } +QComboBox QAbstractItemView { background: @surface; color: @text; selection-background-color: @border; } +QTabWidget::pane { border: none; } +QTabBar::tab { background: @surface; padding: 10px; } +QTabBar::tab:selected { background: @border; } +QMenu { background: @surface; color: @text; border: 1px solid @border; } +QMenu::item:selected { background: @border; } +QToolTip { background: @surface; color: @text; border: 1px solid @accent; } +QScrollBar:vertical { background: @base; width: 12px; } +QScrollBar::handle:vertical { background: @border; min-height: 24px; border-radius: 6px; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: @base; } +""" + + +def stylesheet(name=DEFAULT): + colors = palette(name) + result = _STYLE.replace("@arrow_suffix", "-light" if name == "light" else "") + for role, color in sorted(colors.items(), key=lambda item: -len(item[0])): + result = result.replace("@" + role, color) + return result.replace("__ICONS__", (Path(__file__).parent / "icons").as_posix()) + + +def apply(widget, name=DEFAULT): + colors = palette(name) + native = QPalette(widget.palette()) + for role, color in ( + (QPalette.ColorRole.Window, "base"), (QPalette.ColorRole.Base, "surface"), + (QPalette.ColorRole.AlternateBase, "hover"), (QPalette.ColorRole.Button, "surface"), + (QPalette.ColorRole.WindowText, "text"), (QPalette.ColorRole.Text, "text"), + (QPalette.ColorRole.ButtonText, "text"), (QPalette.ColorRole.PlaceholderText, "muted"), + (QPalette.ColorRole.Highlight, "accent"), (QPalette.ColorRole.HighlightedText, "accent_text"), + ): + native.setColor(role, QColor(colors[color])) + widget.setPalette(native) + widget.setStyleSheet(stylesheet(name)) diff --git a/docs/home-dark.webp b/docs/home-dark.webp new file mode 100644 index 0000000..63cab04 Binary files /dev/null and b/docs/home-dark.webp differ diff --git a/docs/home-dracula.webp b/docs/home-dracula.webp new file mode 100644 index 0000000..3828305 Binary files /dev/null and b/docs/home-dracula.webp differ diff --git a/docs/home-light.webp b/docs/home-light.webp new file mode 100644 index 0000000..b991b91 Binary files /dev/null and b/docs/home-light.webp differ diff --git a/docs/home.webp b/docs/home.webp new file mode 100644 index 0000000..2009ec6 Binary files /dev/null and b/docs/home.webp differ diff --git a/docs/settings-agent.webp b/docs/settings-agent.webp index d693658..8c5dad9 100644 Binary files a/docs/settings-agent.webp and b/docs/settings-agent.webp differ diff --git a/docs/settings-api.webp b/docs/settings-api.webp index 142a435..d4c45e5 100644 Binary files a/docs/settings-api.webp and b/docs/settings-api.webp differ diff --git a/docs/settings-audio-file.webp b/docs/settings-audio-file.webp index f5d77f0..743ff3b 100644 Binary files a/docs/settings-audio-file.webp and b/docs/settings-audio-file.webp differ diff --git a/docs/settings-cleanup.webp b/docs/settings-cleanup.webp index 725b0b0..2ea2554 100644 Binary files a/docs/settings-cleanup.webp and b/docs/settings-cleanup.webp differ diff --git a/docs/settings-display.webp b/docs/settings-display.webp new file mode 100644 index 0000000..322cfb8 Binary files /dev/null and b/docs/settings-display.webp differ diff --git a/docs/settings-general.webp b/docs/settings-general.webp index 18bcef7..0ec9e53 100644 Binary files a/docs/settings-general.webp and b/docs/settings-general.webp differ diff --git a/docs/settings-meeting.webp b/docs/settings-meeting.webp index 13b59e4..16f3295 100644 Binary files a/docs/settings-meeting.webp and b/docs/settings-meeting.webp differ diff --git a/docs/settings-shortcuts.webp b/docs/settings-shortcuts.webp index 73ba99a..89d98b2 100644 Binary files a/docs/settings-shortcuts.webp and b/docs/settings-shortcuts.webp differ diff --git a/packaging/dikte.spec b/packaging/dikte.spec index f977777..62d8466 100644 --- a/packaging/dikte.spec +++ b/packaging/dikte.spec @@ -49,6 +49,7 @@ UNUSED_QT = [ analysis = Analysis( # noqa: F821 [str(ROOT / "packaging" / "entry.py")], pathex=[str(ROOT)], + datas=[(str(ROOT / "dikte" / "icons" / "*.svg"), "dikte/icons")], hiddenimports=["PyQt6.QtNetwork"], # tkinter is the other GUI toolkit CPython ships and would be dead weight; # dikte's own tests have no business in a build at all. diff --git a/tests/render_ui.py b/tests/render_ui.py new file mode 100644 index 0000000..996f3f5 --- /dev/null +++ b/tests/render_ui.py @@ -0,0 +1,169 @@ +"""Capture native Qt client areas with isolated settings and sample data. + +Run with python -m tests.render_ui --output DIRECTORY. CI explicitly selects +xcb, cocoa or windows before importing the test package; the default local +test backend remains offscreen. No audio, API request or input is generated. +Window decorations, native file dialogs and compositor effects are excluded. +""" + +import argparse +import html +import json +import platform +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +from PyQt6.QtCore import QCoreApplication, QEvent, QT_VERSION_STR, Qt +from PyQt6.QtGui import QFontInfo +from PyQt6.QtWidgets import QApplication, QLineEdit, QScrollArea + +from tests.test_ui import Settings +from dikte import config as cfg, i18n, overlay, settings_ui, theme +from dikte.home_ui import HomeWindow + +SAMPLE = "Bir sonraki sürüm için kayıt kontrollerini tamamlayalım. Ayarları gözden geçirip uygulamayı üç platformda da deneyelim." +CASES = ( + ("dictation", 620, 560), ("dictation-wide", 1900, 1000), + ("file", 620, 640), ("meeting", 680, 760), ("ask", 680, 640), + ("settings-general", 720, 760), ("settings-display", 720, 640), + ("settings-api", 720, 760), ("settings-assistant", 720, 760), + ("settings-shortcuts", 720, 640), ("overlay", 220, 48), +) +SETTINGS_PAGES = {"general": 0, "display": 1, "api": 2, "assistant": 4, "shortcuts": 6} + + +def settle(): + for _ in range(5): + QApplication.processEvents() + + +def capture(widget, path, width, height): + # Native styles and fonts still render; monitor size does not constrain + # wide-window cases, and the runner's other windows cannot cover them. + widget.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen) + widget.resize(width, height) + widget.show() + settle() + pixmap = widget.grab() + if pixmap.isNull() or not pixmap.save(str(path), "PNG"): + raise RuntimeError(f"Could not capture {path.name}") + font = QFontInfo(widget.font()) + result = { + "file": path.name, "requested_size": [width, height], + "logical_size": [widget.width(), widget.height()], + "pixel_size": [pixmap.width(), pixmap.height()], + "device_pixel_ratio": pixmap.devicePixelRatio(), + "font": font.family(), "font_points": font.pointSizeF(), + "horizontal_overflow": [], + } + for area in widget.findChildren(QScrollArea): + if area.isVisible() and area.horizontalScrollBar().maximum() > 0: + result["horizontal_overflow"].append(area.horizontalScrollBar().maximum()) + widget.hide() + print(f"Captured {path.name}: {result['logical_size']}, {font.family()}", flush=True) + return result + + +def capture_theme(name, output): + harness = Settings("runTest") + # Keep the actual platform's application branches as well as Qt's style. + harness.platform = sys.platform + harness.setUp() + try: + harness.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_sources_once", return_value=[])) + conf = harness.config( + theme=name, ui_language="tr", transcribe_provider="openrouter", + openrouter_api_key="screenshot-only", openrouter_transcribe_model="openai/whisper-1", + cleanup_provider="openrouter", assistant_provider="openrouter", + ) + i18n.set_language("tr") + cfg.append_history({"ts": "2026-09-09 14:32:00", "duration": 18, + "elapsed": 2, "text": SAMPLE, "raw": SAMPLE}) + settings = harness.window(conf) + settings.keep_audio.setText("Ses kayıtlarını sakla (örnek kayıt klasörü)") + settings.assistant_dir.setPlaceholderText("Proje klasörü") + for field in settings.findChildren(QLineEdit): + if field.isReadOnly() and "__main__.py toggle" in field.text(): + field.setText("dikte toggle") + settings._saved_form = settings._form_values() + settings._show_dirty() + controller = SimpleNamespace( + conf=conf, state="idle", ask_state="idle", meeting_state="idle", + recording=False, paused=False, home_messages={}, meeting_message="", + paste_override={}, meeting_elapsed=SimpleNamespace(elapsed=lambda: 12000), + _recorded_seconds=lambda: 12, open_settings=settings.show, + ) + for method in ("reset_conversation", "_toggle_pause", "_cancel", "cancel_ask", + "cancel_meeting", "_toggle_meeting", "start", "stop", "start_ask", "stop_ask"): + setattr(controller, method, mock.Mock()) + home = HomeWindow(controller, settings) + harness.addCleanup(home.deleteLater) + harness.addCleanup(home.close) + home._timer.stop() + settings.file_label.setText("örnek-kayıt.wav") + settings.file_output.setPlainText(SAMPLE) + home.ask_output.setPlainText("Örnek yanıt: Önce arayüzü doğrulayalım, ardından sürümü hazırlayalım.") + indicator = overlay.Overlay(theme_name=name) + harness.addCleanup(indicator.deleteLater) + harness.addCleanup(indicator.close) + images = [] + for case, width, height in CASES: + if case.startswith("settings-"): + settings.tabs.setCurrentIndex(SETTINGS_PAGES[case.removeprefix("settings-")]) + widget = settings + elif case == "overlay": + indicator.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen) + indicator.show_recording() + indicator._anim.stop() + indicator.set_seconds(12) + indicator.levels = [0.15, 0.3, 0.7, 0.4] * (overlay.BARS // 4) + [0.2] * (overlay.BARS % 4) + widget = indicator + width, height = indicator.width(), indicator.height() + else: + home.show_mode("dictation" if case == "dictation-wide" else case) + widget = home + images.append(capture(widget, output / f"{name}-{case}.png", width, height)) + return images + finally: + harness.doCleanups() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--expect-platform") + args = parser.parse_args() + app = QApplication.instance() + backend = app.platformName() + if args.expect_platform and backend != args.expect_platform: + parser.error(f"Expected {args.expect_platform}, got {backend}") + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + manifest = { + "system": platform.system(), "python": platform.python_version(), + "qt": QT_VERSION_STR, "qpa_backend": backend, + "qt_style": app.style().objectName(), "language": "tr", + "scope": "Native Qt client-area renders with sample data and isolated settings. No real recording, API call, window decorations, native file dialogs or compositor validation.", + "images": [], + } + for name in theme.NAMES: + manifest["images"].extend(capture_theme(name, output)) + (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + title = f"Dikte: {manifest['system']} / {backend} / Qt {QT_VERSION_STR}" + cards = "".join( + f'
{html.escape(entry["file"])}
' + for entry in manifest["images"] + ) + (output / "index.html").write_text( + '' + html.escape(title) + '' + '' + '

' + html.escape(title) + '

' + html.escape(manifest["scope"]) + '

' + cards + '
', + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_cli.py b/tests/test_cli.py index cd954f9..34257fe 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -148,8 +148,8 @@ class Parser(unittest.TestCase): def parse(self, *argv): return cli.build_parser().parse_args(list(argv)) - def test_no_verb_at_all_is_the_settings_window(self): - # argparse leaves the dest as None; run() is what turns it into "". + def test_no_verb_uses_the_plain_gui_command(self): + # argparse leaves the dest as None; run() selects home. opts = self.parse() self.assertIsNone(opts.verb) self.assertEqual(opts.func, cli.cmd_plain) @@ -700,10 +700,14 @@ class WithoutAnInstance(DikteTest): launch.assert_called_once_with("toggle") def test_every_verb_that_opens_a_window_can_start_it(self): - for verb in ("settings", "toggle", "ask", "meeting"): + for verb in ("home", "settings", "toggle", "ask", "meeting"): with self.subTest(verb=verb): self.assertTrue(self.run_verb([verb])[3].called) + def test_bare_command_opens_the_daily_workspace(self): + _, _, _, launch = self.run_verb([]) + launch.assert_called_once_with("home") + def test_a_verb_asked_to_wait_starts_nothing(self): """There would be no run to wait for; the process would just be replaced.""" _, _, _, launch = self.run_verb(["toggle", "--wait"]) diff --git a/tests/test_home_ui.py b/tests/test_home_ui.py new file mode 100644 index 0000000..34c499c --- /dev/null +++ b/tests/test_home_ui.py @@ -0,0 +1,404 @@ +"""Native workspace navigation, capture boundaries and persisted results.""" + +from types import SimpleNamespace +from unittest import mock + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication, QMessageBox, QPushButton + +from dikte import config as cfg, home_ui, i18n +from dikte.app import Dikte +from tests import test_ui +from tests.support import DikteTest + + +class Home(DikteTest): + def setUp(self): + super().setUp() + self.fixture = test_ui.Settings("runTest") + self.fixture.setUp() + self.addCleanup(self.fixture.doCleanups) + self.conf = self.fixture.config(transcribe_provider="openai", openai_api_key="test", + cleanup_enabled=False) + self.settings = self.fixture.window(self.conf) + self.controller = SimpleNamespace( + conf=self.conf, state="idle", ask_state="idle", meeting_state="idle", + meeting_message="", home_messages={}, recording=False, paused=False, + paste_override={}, open_settings=mock.Mock(), reset_conversation=mock.Mock(), + _recorded_seconds=lambda: 65, meeting_elapsed=SimpleNamespace(elapsed=lambda: 90000), + ) + for name in ("start", "stop", "start_ask", "stop_ask", "_toggle_pause", "_cancel", + "cancel_ask", "_toggle_meeting", "cancel_meeting"): + setattr(self.controller, name, mock.Mock()) + self.window = home_ui.HomeWindow(self.controller, self.settings) + self.addCleanup(self.window.deleteLater) + self.addCleanup(self.window.close) + self.window.show() + QApplication.processEvents() + + def test_daily_tasks_are_reachable_outside_configuration(self): + for mode in ("dictation", "file", "meeting", "ask", "history"): + self.window.show_mode(mode) + QApplication.processEvents() + self.assertEqual(self.window.pages.currentWidget(), self.window.mode_pages[mode]) + self.window.show_mode("meeting") + self.assertTrue(self.settings.minutes_view.isVisible()) + self.window.show_mode("history") + self.assertTrue(self.settings.history.isVisible()) + self.assertEqual(self.settings.tabs.count(), 7) + self.assertFalse(self.settings.tabs.tabBar().isVisible()) + + def test_native_chrome_and_capture_geometry(self): + self.assertFalse(self.window.windowFlags() & Qt.WindowType.FramelessWindowHint) + self.assertEqual(self.window.capture_button.width(), self.window.capture_button.height()) + self.assertGreaterEqual(self.window.capture_button.height(), 100) + + def test_theme_preview_discard_and_save_keep_runtime_separate(self): + self.assertEqual(self.conf["theme"], "nord") + self.settings.theme_choice.setCurrentIndex(self.settings.theme_choice.findData("light")) + self.assertEqual(self.conf["theme"], "nord") + self.assertIn("#FFFFFF", self.settings.styleSheet()) + self.assertNotEqual(self.window.styleSheet(), self.settings.styleSheet()) + self.settings._discard_changes() + self.assertEqual(self.settings.theme_choice.currentData(), "nord") + self.settings.theme_choice.setCurrentIndex(self.settings.theme_choice.findData("dracula")) + self.settings._save() + self.assertEqual(cfg.Config()["theme"], "dracula") + self.assertEqual(self.window._theme_name, "dracula") + self.assertIn("#282A36", self.window.styleSheet()) + + def test_theme_colors_are_per_overlay_and_unknown_name_falls_back(self): + from dikte import overlay, theme + dark = overlay.Overlay(theme_name="dark") + light = overlay.Overlay(theme_name="light") + self.addCleanup(dark.deleteLater) + self.addCleanup(light.deleteLater) + self.assertEqual(light.colors["base"].name(), "#ffffff") + self.assertEqual(dark.colors["base"].name(), "#101010") + dark.set_theme("dracula") + self.assertEqual(light.colors["base"].name(), "#ffffff") + self.assertEqual(theme.stylesheet("unknown"), theme.stylesheet("nord")) + + def test_wide_meeting_page_keeps_actions_compact(self): + self.window.resize(900, 700) + self.window.show_mode("meeting") + QApplication.processEvents() + self.assertLess(self.window.meeting_button.width(), 300) + self.assertLessEqual(self.settings.minutes_view.width(), 680) + + def test_wide_windows_center_every_task_and_settings_page(self): + self.window.resize(1900, 1000) + for mode, area in self.window.mode_pages.items(): + self.window.show_mode(mode) + QApplication.processEvents() + with self.subTest(mode=mode): + self.assertLessEqual(area.widget().width(), 680) + self.assertAlmostEqual(area.widget().geometry().center().x(), + area.viewport().rect().center().x(), delta=1) + self.settings.resize(1900, 1000) + self.settings.show() + for index in range(self.settings.tabs.count()): + self.settings.tabs.setCurrentIndex(index) + QApplication.processEvents() + area = self.settings.tabs.widget(index) + with self.subTest(settings=index): + self.assertAlmostEqual(area.widget().geometry().center().x(), + area.viewport().rect().center().x(), delta=1) + + def test_meeting_and_assistant_actions_share_one_row(self): + self.window.resize(620, 760) + for mode, labels in ( + ("meeting", ("Copy", "Write it up", "Open the folder", "Delete selected", "Reload")), + ("ask", (self.window.ask_button.text(), "Start a new conversation")), + ): + self.window.show_mode(mode) + QApplication.processEvents() + page = self.window.mode_pages[mode] + buttons = {b.text(): b for b in page.findChildren(QPushButton)} + positions = [buttons[label].mapTo(page, buttons[label].rect().center()).y() + for label in labels] + with self.subTest(mode=mode): + self.assertLessEqual(max(positions) - min(positions), 1) + + def test_empty_state_does_not_invent_a_transcript(self): + self.assertEqual(self.window.latest_text.toPlainText(), "") + self.assertFalse(self.window.copy_button.isEnabled()) + self.assertFalse(self.window.open_button.isEnabled()) + + def test_clearing_history_removes_the_latest_preview(self): + cfg.append_history({"text": "Remove this preview"}) + self.window.refresh_results() + self.window.show_mode("history") + with mock.patch.object(self.settings, "_confirm", return_value=True): + self.settings._clear_history() + self.window.show_mode("dictation") + self.assertEqual(self.window.latest_text.toPlainText(), "") + self.assertFalse(self.window.copy_button.isEnabled()) + + def test_missing_local_model_opens_setup_without_recording(self): + self.conf["transcribe_provider"] = "local" + self.window.refresh() + self.assertEqual(self.window.capture_status.text(), "Set up transcription") + self.window._capture() + self.controller.start.assert_not_called() + self.controller.open_settings.assert_called_once() + self.assertEqual(self.settings.tabs.currentIndex(), self.settings.api_tab_index) + self.assertEqual(self.controller.paste_override, {}) + + def test_button_capture_never_automatically_pastes(self): + def start(): + self.assertIs(self.controller.paste_override["dictation"], False) + self.controller.state = "recording" + self.controller.recording = True + self.controller.start.side_effect = start + self.window._capture() + self.controller.start.assert_called_once() + self.assertFalse(self.controller.paste_override["dictation"]) + self.assertIn("01:05", self.window.capture_status.text()) + self.assertTrue(self.window.pause_button.isVisible()) + self.window._capture() + self.controller.stop.assert_called_once() + + def test_failed_capture_does_not_leak_a_paste_override(self): + self.window._capture() + self.assertEqual(self.controller.paste_override, {}) + + def test_busy_capture_can_queue_but_does_not_steal_assistant_microphone(self): + self.controller.state = "busy" + self.window.refresh() + self.assertTrue(self.window.capture_button.isEnabled()) + self.controller.ask_state = "recording" + self.controller.recording = True + self.window.refresh() + self.assertFalse(self.window.capture_button.isEnabled()) + self.window._capture() + self.controller.start.assert_not_called() + + def test_pause_cancel_and_failures_are_visible(self): + self.controller.state = "recording" + self.controller.recording = True + self.controller.paused = True + self.window.refresh() + self.assertIn("Paused", self.window.capture_status.text()) + self.window._pause() + self.controller._toggle_pause.assert_called_once() + self.window._cancel_capture() + self.controller._cancel.assert_called_once() + self.controller.home_messages["dictation"] = "Microphone permission denied" + self.window.refresh() + self.assertIn("permission denied", self.window.capture_error.text()) + + def test_real_latest_dictation_is_separate_from_assistant_answer(self): + cfg.append_history({"ts": "2026-09-09 12:00:00", "text": "Actual transcript"}) + cfg.append_history({"mode": "ask", "text": "Actual answer"}) + self.window.refresh_results() + self.assertEqual(self.window.latest_text.toPlainText(), "Actual transcript") + self.assertEqual(self.window.ask_output.toPlainText(), "Actual answer") + cursor = self.window.latest_text.textCursor() + cursor.setPosition(3) + self.window.latest_text.setTextCursor(cursor) + self.window.refresh() + self.window.refresh_results() + self.assertEqual(self.window.latest_text.textCursor().position(), 3) + cfg.clear_history() + self.window.refresh_results() + self.assertEqual(self.window.latest_text.toPlainText(), "") + + def test_processing_summary_uses_full_models_and_actual_acceleration(self): + self.conf["transcribe_provider"] = "local" + self.conf["local_model"] = "ggml-large-v3-turbo-q5_0.bin" + self.conf["cleanup_enabled"] = True + self.conf["cleanup_provider"] = "local" + self.conf["local_llm_model"] = "gemma-3-4b-it-Q4_K_M.gguf" + self.conf["local_gpu"] = True + with mock.patch.object(home_ui.ggml, "state", return_value={ + "whisper": {"running": True, "backend": "CPU"}, + "llama": {"running": True, "backend": "Vulkan"}, + }): + text = home_ui.processing_locations(self.conf) + self.assertIn("ggml-large-v3-turbo-q5_0.bin (Local CPU)", text) + self.assertIn("gemma-3-4b-it-Q4_K_M.gguf (Local GPU)", text) + self.assertNotIn("API", text) + with mock.patch.object(home_ui.ggml, "state", return_value={}): + text = home_ui.processing_locations(self.conf) + self.assertNotIn("GPU", text) + self.assertIn("(Local)", text) + self.assertIn(self.conf["meeting_model"], home_ui.processing_locations(self.conf, "meeting")) + self.conf["assistant_cleanup"] = True + self.conf["cleanup_provider"] = "gemini" + self.conf["assistant_provider"] = "codex" + text = home_ui.processing_locations(self.conf, "ask") + self.assertIn(self.conf["cleanup_gemini_model"] + " (API)", text) + self.assertIn("Codex default model (CLI)", text) + + def test_timestamped_file_summary_uses_the_timestamp_model(self): + self.conf["transcribe_provider"] = "openrouter" + self.conf["openrouter_transcribe_model"] = "google/gemini-audio" + self.conf["openrouter_file_model"] = "openai/whisper-1" + text = home_ui.processing_locations(self.conf, "file", file_timestamps=True) + self.assertIn("openai/whisper-1 (API)", text) + self.assertNotIn("google/gemini-audio", text) + + def test_meeting_summary_uses_segment_model_even_without_file_timestamps(self): + self.conf["transcribe_provider"] = "openai" + self.conf["transcribe_model"] = "gpt-4o-transcribe" + text = home_ui.processing_locations(self.conf, "meeting", file_timestamps=False) + self.assertIn("whisper-1 (API)", text) + self.assertNotIn("gpt-4o-transcribe", text) + self.conf["transcribe_provider"] = "openrouter" + self.conf["openrouter_file_model"] = "mistralai/voxtral-small-24b-2507" + text = home_ui.processing_locations(self.conf, "meeting", file_timestamps=False) + self.assertIn("mistralai/voxtral-small-24b-2507 (API)", text) + + def test_unsupported_meeting_is_disabled(self): + self.enterContext(mock.patch.object(home_ui.audio, "sound", return_value=SimpleNamespace(meetings=False))) + self.window.show_mode("meeting") + self.assertFalse(self.window.meeting_button.isEnabled()) + self.assertIn("not supported", self.window.meeting_hint.text()) + self.window._meeting() + self.controller._toggle_meeting.assert_not_called() + + def test_assistant_scope_uses_actual_shortcut_and_permissions(self): + self.conf["assistant_provider"] = "codex" + self.conf["assistant_shortcut"] = "Ctrl+Alt+A" + self.conf["assistant_dir"] = self.root + self.conf["assistant_codex_sandbox"] = "danger-full-access" + self.window.show_mode("ask") + self.assertIn("Ctrl+Alt+A", self.window.ask_scope.text()) + self.assertIn(self.root, self.window.ask_scope.text()) + self.assertIn("No sandbox at all", self.window.ask_scope.text()) + self.assertEqual(self.window.ask_button.text(), "Set up assistant") + self.window._ask() + self.controller.start_ask.assert_not_called() + self.controller.open_settings.assert_called_once() + + def test_failed_settings_save_keeps_runtime_config_and_form_edits(self): + before = dict(self.conf.data) + self.settings.auto_paste.setChecked(not self.conf["auto_paste"]) + self.assertEqual(self.settings.dirty_label.text(), "Unsaved changes") + with mock.patch.object(self.conf, "save", side_effect=OSError("disk full")), mock.patch.object(QMessageBox, "warning"): + self.settings._save() + self.assertEqual(self.conf.data, before) + self.assertNotEqual(self.settings.auto_paste.isChecked(), self.conf["auto_paste"]) + self.settings.file_path = "/tmp/chosen.wav" + self.settings._discard_changes() + self.assertEqual(self.settings.dirty_label.text(), "") + self.assertEqual(self.settings.file_path, "/tmp/chosen.wav") + + def test_small_window_keeps_navigation_and_footer_accessible(self): + self.window.resize(460, 460) + QApplication.processEvents() + for mode in ("dictation", "file", "meeting", "ask", "history"): + self.window.show_mode(mode) + QApplication.processEvents() + if self.window.footer.isVisible(): + self.assertTrue(self.window.rect().contains(self.window.footer.geometry())) + self.assertTrue(self.window.rect().contains(self.window.mode_buttons["dictation"].geometry().topLeft())) + + def test_apply_merges_unrelated_cli_changes_and_keeps_user_edits(self): + self.settings.auto_paste.setChecked(False) + self.conf["shortcut"] = "Ctrl+Shift+F9" + self.conf["groq_transcribe_model"] = "external-model" + self.settings.refresh_configuration() + self.assertFalse(self.settings.auto_paste.isChecked()) + self.settings._save() + self.assertFalse(self.conf["auto_paste"]) + self.assertEqual(self.conf["shortcut"], "Ctrl+Shift+F9") + self.assertEqual(self.conf["groq_transcribe_model"], "external-model") + self.assertEqual(self.settings._shortcut_rows["toggle"][0].currentText(), "Ctrl+Shift+F9") + self.assertEqual(self.settings.dirty_label.text(), "") + + def test_clean_form_refreshes_from_cli_without_changing_file_result(self): + self.settings.file_output.setPlainText("Existing file result") + self.conf["shortcut"] = "Ctrl+Alt+F9" + self.settings.refresh_configuration() + self.assertEqual(self.settings._shortcut_rows["toggle"][0].currentText(), "Ctrl+Alt+F9") + self.assertEqual(self.settings.file_output.toPlainText(), "Existing file result") + self.assertEqual(self.settings.dirty_label.text(), "") + + def test_cached_provider_model_edits_remain_dirty_after_switching_back(self): + self.settings._select_data(self.settings.transcribe_provider, "groq") + self.settings.transcribe_model.setCurrentText("my-groq-model") + self.settings._select_data(self.settings.transcribe_provider, "openai") + self.assertEqual(self.settings.dirty_label.text(), "Unsaved changes") + self.settings._save() + self.assertEqual(self.conf["groq_transcribe_model"], "my-groq-model") + + def test_cli_selected_new_source_is_resolved_when_settings_reopens(self): + self.conf["mic_target"] = "new-usb" + self.settings.refresh_configuration() + with mock.patch.object(home_ui.audio, "list_sources", return_value=[("new-usb", "New USB microphone")]), mock.patch.object(home_ui.audio, "list_monitors", return_value=[]): + self.settings.refresh_sources() + self.assertEqual(self.settings.mic.currentData(), "new-usb") + self.assertEqual(self.settings.mic.currentText(), "New USB microphone") + self.settings._save() + self.assertEqual(self.conf["mic_target"], "new-usb") + + def test_language_rebuild_preserves_file_result_and_active_mode(self): + from dikte.meeting import MeetingPipeline + controller = Dikte.__new__(Dikte) + controller.__dict__.update(vars(self.controller)) + controller.meetings = MeetingPipeline(self.conf) + controller._make_settings() + old_settings = controller.settings_window + old_home = home_ui.HomeWindow(controller, old_settings) + controller.home_window = old_home + old_home.show_mode("file") + old_home.show() + old_settings.file_path = "/tmp/chosen.wav" + old_settings.file_label.setText("chosen.wav") + old_settings.file_output.setPlainText("Retained transcript") + old_settings.file_segments = [{"text": "Retained transcript", "start": 0, "end": 2}] + i18n.set_language("tr") + controller._reopen_settings() + self.addCleanup(controller.settings_window.deleteLater) + self.addCleanup(controller.settings_window.close) + self.addCleanup(controller.home_window.deleteLater) + self.addCleanup(controller.home_window.close) + self.assertEqual(controller.home_window.mode, "file") + self.assertEqual(controller.settings_window.file_path, "/tmp/chosen.wav") + self.assertEqual(controller.settings_window.file_output.toPlainText(), "Retained transcript") + self.assertTrue(controller.settings_window.file_save_srt.isEnabled()) + self.assertEqual(controller.home_window.mode_buttons["file"].text(), "Dosya") + + def test_disconnected_configured_source_survives_an_unrelated_apply(self): + self.conf["mic_target"] = "disconnected-usb" + self.settings.refresh_configuration() + self.assertEqual(self.settings.mic.currentData(), "disconnected-usb") + self.settings.auto_paste.setChecked(False) + self.settings._save() + self.assertEqual(self.conf["mic_target"], "disconnected-usb") + + def test_source_refresh_keeps_selection_and_discovers_hotplugged_devices(self): + self.settings.mic.addItem("Old microphone", "old") + self.settings.mic.setCurrentIndex(self.settings.mic.findData("old")) + with mock.patch.object(home_ui.audio, "list_sources", return_value=[("usb", "USB microphone")]), mock.patch.object(home_ui.audio, "list_monitors", return_value=[("loop", "Loopback")]): + self.settings.refresh_sources() + self.assertEqual(self.settings.mic.currentData(), "old") + self.assertGreaterEqual(self.settings.mic.findData("usb"), 0) + self.assertGreaterEqual(self.settings.meeting_mic.findData("usb"), 0) + self.assertGreaterEqual(self.settings.meeting_system.findData("loop"), 0) + + def test_missing_assistant_directory_displays_the_actual_fallback(self): + self.conf["assistant_provider"] = "codex" + self.conf["assistant_dir"] = "/does/not/exist/dikte-test" + self.window.show_mode("ask") + self.assertNotIn(self.conf["assistant_dir"], self.window.ask_scope.text()) + self.assertIn(home_ui.assistant.working_dir(self.conf), self.window.ask_scope.text()) + + def test_turkish_task_labels_and_runtime_status(self): + i18n.set_language("tr") + self.window.refresh() + self.assertEqual(self.window.capture_status.text(), "Konuşmaya hazır") + self.assertIn("Dikte:", self.window.capture_models.text()) + self.assertIn("Temizleme:", self.window.capture_models.text()) + + def test_completed_run_refreshes_workspace_without_changing_controller_state(self): + controller = Dikte.__new__(Dikte) + controller.home_messages = {} + controller.home_window = self.window + controller._waiters = {} + cfg.append_history({"text": "Finished"}) + controller._settle("dictation", {"ok": True, "text": "Finished"}) + self.assertEqual(self.window.latest_text.toPlainText(), "Finished") + self.assertEqual(controller.home_messages["dictation"], "Transcript ready") diff --git a/tests/test_ui.py b/tests/test_ui.py index 2f0cf01..95644cf 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -179,10 +179,11 @@ class Settings(DikteTest): Qt.KeyboardModifier.NoModifier, Qt.ScrollPhase.NoScrollPhase, False) - def test_the_window_opens_with_every_tab_on_it(self): + def test_settings_keeps_configuration_and_exposes_separate_task_pages(self): window = self.window(cfg.Config()) tabs = window.findChildren(settings_ui.QTabWidget)[0] - self.assertEqual(tabs.count(), 10) + self.assertEqual(tabs.count(), 7) + self.assertEqual(set(window.task_pages), {"file", "minutes", "history"}) self.assertEqual(window.windowTitle(), "Dikte Settings") def test_no_tab_can_stretch_the_window_past_a_small_screen(self): @@ -1198,7 +1199,7 @@ class Overlay(DikteTest): mock.patch.object(QApplication, "screenAt") as screen_at: widget._reposition() screen_at.assert_not_called() - self.assertEqual(widget.pos(), QPoint(1948, 995)) + self.assertEqual(widget.pos(), QPoint(1948, 1003)) def _screen(self, name, area): screen = mock.Mock() @@ -1224,7 +1225,7 @@ class Overlay(DikteTest): mock.patch.object(QApplication, "screenAt") as screen_at: widget._reposition() screen_at.assert_not_called() - self.assertEqual(widget.pos(), QPoint(1948, 995)) + self.assertEqual(widget.pos(), QPoint(1948, 1003)) def test_the_pointer_decides_when_the_compositor_will_not_say(self): """Every desktop but Plasma, and Plasma while KWin is being replaced.""" @@ -1236,7 +1237,7 @@ class Overlay(DikteTest): return_value=screens[0]) as screen_at: widget._reposition() screen_at.assert_called() - self.assertEqual(widget.pos(), QPoint(28, 995)) + self.assertEqual(widget.pos(), QPoint(28, 1003)) def _two_screens(self): return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)), @@ -1258,10 +1259,10 @@ class Overlay(DikteTest): with mock.patch.object(overlay_module, "_kwin", kwin), \ mock.patch.object(QApplication, "screens", return_value=screens): widget.show_recording() - self.assertEqual(widget.pos(), QPoint(1948, 995)) + self.assertEqual(widget.pos(), QPoint(1948, 1003)) kwin.call.return_value.arguments.return_value = ["DP-1"] self._ticks_on(widget, screens, kwin) - self.assertEqual(widget.pos(), QPoint(28, 995)) + self.assertEqual(widget.pos(), QPoint(28, 1003)) def test_it_stays_where_it_appeared_unless_it_was_told_otherwise(self): """Left off, because an indicator that jumps desks mid-sentence is one @@ -1274,7 +1275,7 @@ class Overlay(DikteTest): widget.show_recording() kwin.call.return_value.arguments.return_value = ["DP-1"] self._ticks_on(widget, screens, kwin) - self.assertEqual(widget.pos(), QPoint(1948, 995)) + self.assertEqual(widget.pos(), QPoint(1948, 1003)) def test_a_named_screen_is_never_left_for_the_pointer(self): """Naming one is the whole answer; following it would undo the naming.""" @@ -1285,7 +1286,7 @@ class Overlay(DikteTest): widget.show_recording() self._ticks_on(widget, screens, kwin) kwin.call.assert_not_called() - self.assertEqual(widget.pos(), QPoint(28, 995)) + self.assertEqual(widget.pos(), QPoint(28, 1003)) def test_the_one_on_top_goes_where_the_one_underneath_is(self): """Asking for itself would put the pair on two monitors, with this one @@ -1299,8 +1300,8 @@ class Overlay(DikteTest): kwin.call.return_value.arguments.return_value = ["DP-1"] second = self.overlay(below=first) second.show_busy("Asking Claude…") - self.assertEqual(first.pos(), QPoint(1948, 995)) - self.assertEqual(second.pos(), QPoint(1948, 929)) + self.assertEqual(first.pos(), QPoint(1948, 1003)) + self.assertEqual(second.pos(), QPoint(1948, 945)) def test_the_compositor_is_asked_only_now_and_then(self): """Every tick would be thirty conversations a second about a hand @@ -1404,6 +1405,7 @@ class LocalModels(DikteTest): def setUp(self): super().setUp() + self.enterContext(mock.patch.object(settings_ui.LocalModelBox, "_fetch_models")) # A machine Dikte is actually installed on would otherwise answer the # "nothing can transcribe" question from its real binary and model. self.patch_attr(ggml, "BIN_DIR", self.path("bin")) @@ -1687,6 +1689,25 @@ class LocalModels(DikteTest): time.sleep(0.05) _app.processEvents() self.assertEqual(fetch.call_count, 1) + + def test_reloading_settings_keeps_the_fetched_model_choices(self): + repo = "ggml-org/SmolLM3-3B-GGUF" + box = self.window(self.config(local_llm_repo=repo)).local_llm + box._on_listed([("models", [self._item("first.gguf"), self._item("second.gguf")], repo)], "") + box.load("first.gguf", repo) + self.assertGreaterEqual(box.model.findData("second.gguf"), 0) + self.assertEqual(box.selected(), "first.gguf") + + def test_reload_before_repository_debounce_does_not_reuse_previous_catalog(self): + first_repo = "ggml-org/SmolLM3-3B-GGUF" + second_repo = "ggml-org/gemma-3-4b-it-GGUF" + box = self.window(self.config(local_llm_repo=first_repo)).local_llm + box._on_listed([("models", [self._item("first.gguf"), self._item("second.gguf")], first_repo)], "") + box.repo.setCurrentText(second_repo) + box.load("first.gguf", second_repo) + self.assertLess(box.model.findData("second.gguf"), 0) + self.assertTrue(box._pending) + self.assertFalse(box._answered) def test_the_models_are_grouped_by_the_model_rather_than_by_size(self): # Sorted by size alone, the turbo files land between the two medium # ones, half a screen from the model they are a copy of.