Compare commits

..
34 changed files with 1762 additions and 105 deletions
+66
View File
@@ -0,0 +1,66 @@
name: UI screenshots
run-name: "UI screenshots (${{ inputs.pr && format('PR #{0}', inputs.pr) || github.ref_name }})"
on:
workflow_dispatch:
inputs:
pr:
description: "PR number to capture (leave empty for the selected branch)"
required: false
type: string
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
with:
ref: ${{ inputs.pr && format('refs/pull/{0}/head', inputs.pr) || github.ref }}
persist-credentials: false
- 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: 90
+7 -1
View File
@@ -13,11 +13,17 @@ library, 3.11 or newer, and PyQt6.
*[Türkçe README](README.tr.md)* *[Türkçe README](README.tr.md)*
<p align="center"> <p align="center">
<img src="docs/settings-general.webp" width="820" alt="Dikte settings, General tab"> <img src="docs/home.webp" width="620" alt="Dikte, Nord theme">
<br><sub>Nord (default)</sub>
</p> </p>
| Dracula | Classic dark | Classic light |
|---|---|---|
| <img src="docs/home-dracula.webp" width="270" alt="Dikte, Dracula"> | <img src="docs/home-dark.webp" width="270" alt="Dikte, Classic dark"> | <img src="docs/home-light.webp" width="270" alt="Dikte, Classic light"> |
| | | | | |
|---|---| |---|---|
| <img src="docs/settings-general.webp" width="410" alt="General and themes"> | <img src="docs/settings-display.webp" width="410" alt="Nord, Dracula, dark, light"> |
| <img src="docs/settings-api.webp" width="410" alt="API and models"> | <img src="docs/settings-cleanup.webp" width="410" alt="Cleanup rules"> | | <img src="docs/settings-api.webp" width="410" alt="API and models"> | <img src="docs/settings-cleanup.webp" width="410" alt="Cleanup rules"> |
| <img src="docs/settings-agent.webp" width="410" alt="Agent"> | <img src="docs/settings-meeting.webp" width="410" alt="Meeting"> | | <img src="docs/settings-agent.webp" width="410" alt="Agent"> | <img src="docs/settings-meeting.webp" width="410" alt="Meeting"> |
| <img src="docs/settings-audio-file.webp" width="410" alt="Audio file"> | <img src="docs/settings-shortcuts.webp" width="410" alt="Shortcuts"> | | <img src="docs/settings-audio-file.webp" width="410" alt="Audio file"> | <img src="docs/settings-shortcuts.webp" width="410" alt="Shortcuts"> |
+7 -1
View File
@@ -12,11 +12,17 @@ Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
*[English README](README.md)* *[English README](README.md)*
<p align="center"> <p align="center">
<img src="docs/settings-general.webp" width="820" alt="Dikte ayarları, Genel sekmesi"> <img src="docs/home.webp" width="620" alt="Dikte, Nord teması">
<br><sub>Nord (varsayılan)</sub>
</p> </p>
| Dracula | Klasik karanlık | Klasik beyaz |
|---|---|---|
| <img src="docs/home-dracula.webp" width="270" alt="Dikte, Dracula"> | <img src="docs/home-dark.webp" width="270" alt="Dikte, Klasik karanlık"> | <img src="docs/home-light.webp" width="270" alt="Dikte, Klasik beyaz"> |
| | | | | |
|---|---| |---|---|
| <img src="docs/settings-general.webp" width="410" alt="Genel ve temalar"> | <img src="docs/settings-display.webp" width="410" alt="Nord, Dracula, dark, light"> |
| <img src="docs/settings-api.webp" width="410" alt="API ve modeller"> | <img src="docs/settings-cleanup.webp" width="410" alt="Temizleme kuralları"> | | <img src="docs/settings-api.webp" width="410" alt="API ve modeller"> | <img src="docs/settings-cleanup.webp" width="410" alt="Temizleme kuralları"> |
| <img src="docs/settings-agent.webp" width="410" alt="Ajan"> | <img src="docs/settings-meeting.webp" width="410" alt="Toplantı"> | | <img src="docs/settings-agent.webp" width="410" alt="Ajan"> | <img src="docs/settings-meeting.webp" width="410" alt="Toplantı"> |
| <img src="docs/settings-audio-file.webp" width="410" alt="Ses dosyası"> | <img src="docs/settings-shortcuts.webp" width="410" alt="Kısayollar"> | | <img src="docs/settings-audio-file.webp" width="410" alt="Ses dosyası"> | <img src="docs/settings-shortcuts.webp" width="410" alt="Kısayollar"> |
+105 -21
View File
@@ -58,6 +58,7 @@ from .i18n import t # noqa: E402
from .meeting import MeetingPipeline # noqa: E402 from .meeting import MeetingPipeline # noqa: E402
from .overlay import Overlay # noqa: E402 from .overlay import Overlay # noqa: E402
from .settings_ui import SettingsWindow # noqa: E402 from .settings_ui import SettingsWindow # noqa: E402
from .home_ui import HomeWindow # noqa: E402
from .worker import Pipeline # noqa: E402 from .worker import Pipeline # noqa: E402
SERVER_NAME = ipc.SERVER_NAME SERVER_NAME = ipc.SERVER_NAME
@@ -138,6 +139,8 @@ class Dikte:
self.meeting_base = "" self.meeting_base = ""
self.meeting_message = "" self.meeting_message = ""
self.settings_window = None self.settings_window = None
self.home_window = None
self.home_messages = {}
# The single-instance server, handed over once run_app has opened it, so # The single-instance server, handed over once run_app has opened it, so
# that a restart can stop answering before the replacement starts. # that a restart can stop answering before the replacement starts.
self.server = None self.server = None
@@ -165,13 +168,15 @@ class Dikte:
self.overlay = Overlay(self.conf["overlay_corner"], self.overlay = Overlay(self.conf["overlay_corner"],
screen_name=self.conf["overlay_screen"], 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 # 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. # up, and drops into the corner when it is alone there.
self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay, self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay,
dismissable=True, dismissable=True,
screen_name=self.conf["overlay_screen"], 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.recorder = audio.Recorder()
self.pipeline = Pipeline(self.conf) self.pipeline = Pipeline(self.conf)
self.ask_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.stage.connect(self._on_stage)
self.pipeline.finished.connect(self._on_finished) self.pipeline.finished.connect(self._on_finished)
self.pipeline.failed.connect(self._on_pipeline_failed) 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.finished.connect(self._on_ask_finished)
self.ask_pipeline.failed.connect(self._on_ask_error) self.ask_pipeline.failed.connect(self._on_ask_error)
self.ask_pipeline.cancelled.connect(self._on_ask_cancelled) self.ask_pipeline.cancelled.connect(self._on_ask_cancelled)
@@ -294,6 +299,10 @@ class Dikte:
self.unload_action.triggered.connect(self.unload_models) self.unload_action.triggered.connect(self.unload_models)
self.menu.addAction(self.unload_action) 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 = QAction(t("Settings…"), self.menu)
self.settings_action.triggered.connect(self.open_settings) self.settings_action.triggered.connect(self.open_settings)
self.menu.addAction(self.settings_action) self.menu.addAction(self.settings_action)
@@ -524,7 +533,7 @@ class Dikte:
# than only able to press its buttons. # than only able to press its buttons.
def handle(self, request, reply): 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"): if cmd in ("toggle", "start", "stop", "record"):
self._dictation_request(cmd, request, reply) self._dictation_request(cmd, request, reply)
elif cmd == "ask": elif cmd == "ask":
@@ -541,6 +550,7 @@ class Dikte:
"ask-reset": self.reset_conversation, "ask-reset": self.reset_conversation,
"meeting-cancel": self.cancel_meeting, "meeting-cancel": self.cancel_meeting,
"settings": self.open_settings, "settings": self.open_settings,
"home": self.open_home,
"reload": self.reload_settings, "reload": self.reload_settings,
"restart": self.restart, "restart": self.restart,
"quit": self.app.quit, "quit": self.app.quit,
@@ -609,9 +619,28 @@ class Dikte:
def _settle(self, kind, payload): def _settle(self, kind, payload):
"""Tell whoever was waiting on this run how it ended.""" """Tell whoever was waiting on this run how it ended."""
self._home_settled(kind, payload)
for reply in self._waiters.pop(kind, []): for reply in self._waiters.pop(kind, []):
reply(payload) 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): def _auto_stop(self, run):
"""The end of a `record --seconds`, if that recording is still the one.""" """The end of a `record --seconds`, if that recording is still the one."""
if self._run_id == run and self.state == RECORDING: if self._run_id == run and self.state == RECORDING:
@@ -656,6 +685,8 @@ class Dikte:
def reload_settings(self): def reload_settings(self):
"""Read the config file back after something outside changed it.""" """Read the config file back after something outside changed it."""
self.conf.load() self.conf.load()
if self.settings_window is not None:
self.settings_window.refresh_configuration()
self._apply_settings() self._apply_settings()
def _toggle(self): def _toggle(self):
@@ -706,6 +737,9 @@ class Dikte:
# recorder's. # recorder's.
if self.state == RECORDING or self.recording: if self.state == RECORDING or self.recording:
return 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.front_before = self._the_front()
self.overlay.show_recording() self.overlay.show_recording()
self._begin_recording(DICTATION) self._begin_recording(DICTATION)
@@ -720,6 +754,9 @@ class Dikte:
def start_ask(self): def start_ask(self):
if self.ask_state != IDLE or self.recording: if self.ask_state != IDLE or self.recording:
return 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.front_before = self._the_front()
self.ask_overlay.show_recording(asking=True) self.ask_overlay.show_recording(asking=True)
self._begin_recording(ASK) self._begin_recording(ASK)
@@ -1038,7 +1075,7 @@ class Dikte:
self.overlay.show_error(t("Meeting failed: {error}", error=first_line)) self.overlay.show_error(t("Meeting failed: {error}", error=first_line))
self.tray.showMessage( self.tray.showMessage(
t("Dikte: the meeting could not be written up"), 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), "try again.", error=error),
QSystemTrayIcon.MessageIcon.Warning, 12000, QSystemTrayIcon.MessageIcon.Warning, 12000,
) )
@@ -1080,7 +1117,14 @@ class Dikte:
self.pipeline.run(wav_path, duration, rms_values, self.pipeline.run(wav_path, duration, rms_values,
paste=wants_paste, focus=focus) 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): 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 # The corner belongs to the recording when one is on: the previous
# run's progress must not wipe the waveform mid-sentence. # run's progress must not wipe the waveform mid-sentence.
if self.state != RECORDING: if self.state != RECORDING:
@@ -1105,19 +1149,18 @@ class Dikte:
# a rejected key otherwise looks exactly like working dictation. # a rejected key otherwise looks exactly like working dictation.
if self.state != RECORDING: if self.state != RECORDING:
self.overlay.show_warning( self.overlay.show_warning(
t("Pasted raw, cleanup failed: {error}", t("Completed with a warning: {error}",
error=warning.splitlines()[0]) error=warning.splitlines()[0])
) )
self.tray.showMessage( self.tray.showMessage(
t("Dikte: cleanup failed"), warning, t("Dikte: completed with a warning"), warning,
QSystemTrayIcon.MessageIcon.Warning, 10000, QSystemTrayIcon.MessageIcon.Warning, 10000,
) )
elif self.state != RECORDING: elif self.state != RECORDING:
# While a new recording is on, the flash is skipped: the text # While a new recording is on, the flash is skipped: the text
# arriving where the cursor is says everything it would have. # 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( 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, self._transcript_settled({"ok": True, "text": text, "raw": _raw,
"warning": warning, "warning": warning,
@@ -1264,9 +1307,21 @@ class Dikte:
# ---- settings --------------------------------------------------------- # ---- 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): def open_settings(self):
if self.settings_window is None: if self.settings_window is None:
self._make_settings() self._make_settings()
else:
self.settings_window.refresh_configuration()
self.settings_window.refresh_sources()
self.settings_window.show() self.settings_window.show()
self.settings_window.raise_() self.settings_window.raise_()
self.settings_window.activateWindow() self.settings_window.activateWindow()
@@ -1281,8 +1336,9 @@ class Dikte:
self.settings_window.finished.connect(self._settings_closed) self.settings_window.finished.connect(self._settings_closed)
def _settings_closed(self, *_): def _settings_closed(self, *_):
# Don't drop the object while its own signal is still being delivered. # Task pages share this controller and may still be processing a file.
QTimer.singleShot(0, lambda: setattr(self, "settings_window", None)) # Closing the configuration dialog keeps both its edits and jobs alive.
pass
def _reopen_settings(self): def _reopen_settings(self):
"""Replace the settings window, so a language change reaches it too. """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 one stood, on the same tab.
""" """
old = self.settings_window old = self.settings_window
if old is None: if old is None or old._work_in_flight():
return 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() tab = old.tabs.currentIndex()
geometry = old.geometry() geometry = old.geometry()
# Replaced rather than merely closed: left connected, _settings_closed # 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. # window does not come up at the default size and jump.
self.settings_window.setGeometry(geometry) self.settings_window.setGeometry(geometry)
self.settings_window.tabs.setCurrentIndex(tab) 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.show()
self.settings_window.raise_() self.settings_window.raise_()
self.settings_window.activateWindow() 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): def _apply_local(self):
"""Pass the local settings on, and hold the models ready if asked to. """Pass the local settings on, and hold the models ready if asked to.
@@ -1353,10 +1435,13 @@ class Dikte:
def _apply_settings(self): def _apply_settings(self):
for indicator in (self.overlay, self.ask_overlay): for indicator in (self.overlay, self.ask_overlay):
indicator.set_theme(self.conf["theme"])
indicator.corner = self.conf["overlay_corner"] indicator.corner = self.conf["overlay_corner"]
indicator.screen_name = self.conf["overlay_screen"] indicator.screen_name = self.conf["overlay_screen"]
indicator.follow_pointer = self.conf["overlay_follows_pointer"] indicator.follow_pointer = self.conf["overlay_follows_pointer"]
self._apply_local() self._apply_local()
if getattr(self, "home_window", None) is not None:
self.home_window.refresh()
self._build_tray() self._build_tray()
self._refresh_tray() self._refresh_tray()
# Taken once here for _external: the answer cannot change under a # 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. """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 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 Retried for a moment, because the copy that won the lock may not be
listening yet. listening yet.
""" """
verb = command or "settings" verb = command or "home"
deadline = time.monotonic() + 5 deadline = time.monotonic() + 5
while time.monotonic() < deadline: while time.monotonic() < deadline:
if ipc.send(verb) is not None: if ipc.send(verb) is not None:
@@ -1563,7 +1648,7 @@ def run_app(args):
if command: if command:
ipc.send(command) ipc.send(command)
else: else:
ipc.send("settings") ipc.send("home")
return 0 return 0
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -1638,13 +1723,12 @@ def run_app(args):
server.newConnection.connect(on_connection) server.newConnection.connect(on_connection)
app.aboutToQuit.connect(dikte.shutdown) app.aboutToQuit.connect(dikte.shutdown)
# No key for the chosen transcription provider means nothing can work yet, # Explicit home requests and first setup open the daily workspace.
# so the settings window is the only useful thing to open. # A configured --gui background start stays quiet for login and restart.
# A transcription provider that cannot run yet, whether that is a missing if command == "settings":
# 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():
dikte.open_settings() dikte.open_settings()
elif command == "home" or not dikte.conf.transcribe_ready():
dikte.open_home()
elif command == "toggle": elif command == "toggle":
QTimer.singleShot(0, dikte.toggle) QTimer.singleShot(0, dikte.toggle)
elif command == "ask": elif command == "ask":
+4 -4
View File
@@ -45,7 +45,7 @@ NOT_RUNNING = 3
# Verbs that start the application when none is running, which is what a # 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 # shortcut registered with the desktop has always relied on: press the key on a
# fresh login and Dikte comes up recording. # 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; # 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. # it is already in the state that was asked for.
@@ -1275,7 +1275,7 @@ def build_parser():
updates.set_defaults(func=cmd_update) updates.set_defaults(func=cmd_update)
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status) 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"), ("restart", "reload the running instance"),
("quit", "shut it down")): ("quit", "shut it down")):
leaf(subs, name, help_text).set_defaults(func=cmd_plain) leaf(subs, name, help_text).set_defaults(func=cmd_plain)
@@ -1306,8 +1306,8 @@ def run(argv):
pass pass
parser = build_parser() parser = build_parser()
opts = parser.parse_args(argv) opts = parser.parse_args(argv)
# No verb at all is the plain `dikte`, which means the settings window. # No verb opens the daily workspace; settings remains an explicit verb.
opts.verb = opts.verb or "" opts.verb = opts.verb or "home"
# Every path here either talks over the socket or drives one of the workers, # 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. # and both want an event loop under them; a window is what none of them want.
_app = QCoreApplication.instance() or QCoreApplication(sys.argv[:1]) _app = QCoreApplication.instance() or QCoreApplication(sys.argv[:1])
+1
View File
@@ -418,6 +418,7 @@ da senin soracağın soruya verilecek bir yanıt yok.
ve varsayımını bir yan cümlede söyle""" ve varsayımını bir yan cümlede söyle"""
DEFAULTS = { DEFAULTS = {
"theme": "nord",
"ui_language": "auto", # auto | tr | en "ui_language": "auto", # auto | tr | en
"openai_api_key": "", "openai_api_key": "",
"openai_base_url": "https://api.openai.com/v1", "openai_base_url": "https://api.openai.com/v1",
+544
View File
@@ -0,0 +1,544 @@
"""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()
actions = self.settings.history_actions
actions.insertWidget(actions.count() - 1, _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()
+69
View File
@@ -60,6 +60,75 @@ def name(text, /, case=""):
TR = { 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 --------------------------------------------------------- # --- tray ---------------------------------------------------------
"Start recording": "Kaydı başlat", "Start recording": "Kaydı başlat",
"Stop and transcribe": "Kaydı bitir ve yaz", "Stop and transcribe": "Kaydı bitir ve yaz",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><path d="m3.3 7 2.4 2.5 5-5" fill="none" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 215 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><path d="m3.3 7 2.4 2.5 5-5" fill="none" stroke="#172434" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 215 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path d="m3 4.5 3 3 3-3" fill="none" stroke="#526174" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 211 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path d="m3 4.5 3 3 3-3" fill="none" stroke="#B2C1D1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 211 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path d="m3 7.5 3-3 3 3" fill="none" stroke="#526174" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 211 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path d="m3 7.5 3-3 3 3" fill="none" stroke="#B2C1D1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 211 B

+28 -33
View File
@@ -9,33 +9,18 @@ from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QF
from PyQt6.QtWidgets import QWidget, QApplication from PyQt6.QtWidgets import QWidget, QApplication
from . import mac_window from . import mac_window
from . import theme
BARS = 22 BARS = 22
HEIGHT = 56 HEIGHT = 48
MIN_WIDTH = 210 MIN_WIDTH = 210
MAX_WIDTH = 460 MAX_WIDTH = 460
MARGIN = 28 MARGIN = 28
GAP = 10 # between two indicators sharing a corner GAP = 10 # between two indicators sharing a corner
FOLLOW_EVERY = 8 # ticks between two looks for the pointer: about four a second FOLLOW_EVERY = 8 # ticks between two looks for the pointer: about four a second
BG = QColor(22, 24, 29, 238) STATE_ROLES = {"recording": "rec", "asking": "ask", "meeting": "rec", "busy": "accent",
BORDER = QColor(255, 255, 255, 28) "done": "ok", "warning": "warn", "error": "err"}
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}
LIVE = ("recording", "asking", "meeting") LIVE = ("recording", "asking", "meeting")
@@ -94,8 +79,9 @@ class Overlay(QWidget):
under way at the same time and still both be visible.""" under way at the same time and still both be visible."""
def __init__(self, corner="bottom-left", below=None, dismissable=False, 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) super().__init__(None)
self.set_theme(theme_name)
self.corner = corner self.corner = corner
self.screen_name = screen_name self.screen_name = screen_name
# Whether it goes on following the pointer once it is up, rather than # Whether it goes on following the pointer once it is up, rather than
@@ -388,6 +374,15 @@ class Overlay(QWidget):
# ---- painting -------------------------------------------------- # ---- 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): def paintEvent(self, _event):
if self.state == "hidden": if self.state == "hidden":
return # translucent window, nothing drawn means nothing shown 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) rect = QRectF(0.5, 0.5, self.width() - 1, self.height() - 1)
path = QPainterPath() path = QPainterPath()
path.addRoundedRect(rect, 15, 15) path.addRoundedRect(rect, 12, 12)
painter.fillPath(path, BG) painter.fillPath(path, self.colors["base"])
painter.setPen(QPen(BORDER, 1)) painter.setPen(QPen(self.colors["border"], 1))
painter.drawPath(path) painter.drawPath(path)
accent = STATE_COLORS.get(self.state, MUTED) accent = self.colors[STATE_ROLES.get(self.state, "muted")]
if self._held: if self._held:
accent = HELD accent = self.colors["warn"]
self._draw_indicator(painter, accent) self._draw_indicator(painter, accent)
if self.state in LIVE: if self.state in LIVE:
@@ -480,13 +475,13 @@ class Overlay(QWidget):
gap = (right - left - BARS * bar_w) / max(1, BARS - 1) gap = (right - left - BARS * bar_w) / max(1, BARS - 1)
return left, bar_w, bar_w + gap return left, bar_w, bar_w + gap
@staticmethod def _bar_colour(self, shaped, accent):
def _bar_colour(shaped, accent): color = QColor(accent if shaped > 0.04 else self.colors["muted"])
color = QColor(accent if shaped > 0.04 else MUTED)
color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2)) color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2))
return color 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": if self.state == "meeting":
self._draw_dual_waveform(painter) self._draw_dual_waveform(painter)
return return
@@ -512,7 +507,7 @@ class Overlay(QWidget):
painter.setPen(Qt.PenStyle.NoPen) painter.setPen(Qt.PenStyle.NoPen)
for i, (mine, theirs) in enumerate(zip(self.levels, self.levels2)): for i, (mine, theirs) in enumerate(zip(self.levels, self.levels2)):
x = left + i * step 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) shaped = min(1.0, level ** 0.55)
h = 2.0 + shaped * 12.0 h = 2.0 + shaped * 12.0
y = mid - 1.5 - h if up else mid + 1.5 y = mid - 1.5 - h if up else mid + 1.5
@@ -524,7 +519,7 @@ class Overlay(QWidget):
font.setPointSizeF(10.0) font.setPointSizeF(10.0)
font.setFamilies(["monospace"]) font.setFamilies(["monospace"])
painter.setFont(font) painter.setFont(font)
painter.setPen(MUTED) painter.setPen(self.colors["muted"])
mins, secs = divmod(int(self.seconds), 60) mins, secs = divmod(int(self.seconds), 60)
hours, mins = divmod(mins, 60) hours, mins = divmod(mins, 60)
text = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}" 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 """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.""" box can be clicked away, and a feature nobody can see is not one."""
cx, cy = self.width() - 18.0, self.height() / 2 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) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen) painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush) painter.setBrush(Qt.BrushStyle.NoBrush)
@@ -552,7 +547,7 @@ class Overlay(QWidget):
def _draw_message(self, painter): def _draw_message(self, painter):
painter.setFont(self._label_font()) 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. # 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), box = QRectF(46, 0, self.width() - 60 - (18 if self._can_dismiss else 0),
self.height()) self.height())
+173 -28
View File
@@ -6,13 +6,13 @@ import shutil
import sys import sys
import threading 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.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog, QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
) )
from . import __version__ from . import __version__
@@ -32,6 +32,7 @@ from . import paste
from . import update from . import update
from .filetranscribe import FileTranscriber from .filetranscribe import FileTranscriber
from .i18n import t from .i18n import t
from . import theme
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")] UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
LANGUAGES = [ LANGUAGES = [
@@ -382,6 +383,8 @@ class LocalModelBox(QGroupBox):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
for index, widget in enumerate(widgets): for index, widget in enumerate(widgets):
layout.addWidget(widget, 1 if index == 0 else 0) 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 = QWidget()
holder.setLayout(layout) holder.setLayout(layout)
return holder return holder
@@ -397,13 +400,17 @@ class LocalModelBox(QGroupBox):
def load(self, model, repo=""): def load(self, model, repo=""):
"""Show what is stored. What else is on offer is asked for on the way up. """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 Hidden boxes defer fetching until shown. A catalog already fetched for
opening it, and a list nobody is looking at is not worth a request. What this repository survives Apply; a visible box on a new repository
is already on this disk is shown straight away either way. 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._wanted = model
self._pending = True self._pending = not reuse
self._answered = False self._answered = reuse
self._show_program() self._show_program()
self._chosen_in = "" self._chosen_in = ""
if self._repos is not None: if self._repos is not None:
@@ -413,7 +420,10 @@ class LocalModelBox(QGroupBox):
self.repo.setCurrentText(self._chosen_in) self.repo.setCurrentText(self._chosen_in)
self.repo.blockSignals(False) self.repo.blockSignals(False)
self._fill_repos_box(suggested) 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): def showEvent(self, event):
super().showEvent(event) super().showEvent(event)
@@ -793,6 +803,10 @@ class LocalModelBox(QGroupBox):
def _fill_models_from_current(self): def _fill_models_from_current(self):
"""Redraw the rows without asking anybody anything again.""" """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 # 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 # 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. # 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: if item is not None and item.name not in seen:
seen.add(item.name) seen.add(item.name)
items.append(item) items.append(item)
self._wanted = self.selected() return items
self._fill_models(items)
def _delete(self): def _delete(self):
name = self.selected() name = self.selected()
@@ -920,6 +933,7 @@ class SettingsWindow(QDialog):
self._release_url = update.RELEASES_PAGE self._release_url = update.RELEASES_PAGE
self.transcriber = FileTranscriber(conf, self) self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings")) self.setWindowTitle(t("Dikte Settings"))
theme.apply(self, conf["theme"])
# One for the whole window, parented to it so it outlives the boxes it # One for the whole window, parented to it so it outlives the boxes it
# watches and goes when they do. # watches and goes when they do.
@@ -930,23 +944,46 @@ class SettingsWindow(QDialog):
tabs.addTab(self._scrolled(self._display_tab()), t("Display")) tabs.addTab(self._scrolled(self._display_tab()), t("Display"))
self.api_tab_index = tabs.addTab( self.api_tab_index = tabs.addTab(
self._scrolled(self._api_tab()), t("API and models")) 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._assistant_tab()), t("Agent"))
tabs.addTab(self._scrolled(self._meeting_tab()), t("Meeting")) 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._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 # 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 # cross (or Escape) instead. A "Cancel" next to it would be a lie: the
# settings are already on disk by then. # settings are already on disk by then.
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save) 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) buttons.accepted.connect(self._save)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addWidget(self.categories)
layout.addWidget(tabs) layout.addWidget(tabs)
layout.addWidget(self.dirty_label)
layout.addWidget(buttons) layout.addWidget(buttons)
self._size_to_screen(680, 640) self._size_to_screen(680, 640)
@@ -967,6 +1004,26 @@ class SettingsWindow(QDialog):
self.meetings.finished.connect(self._on_minutes_finished) self.meetings.finished.connect(self._on_minutes_finished)
self.meetings.failed.connect(self._on_minutes_failed) self.meetings.failed.connect(self._on_minutes_failed)
self._load() 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_codex_models()
self._load_agy_models() self._load_agy_models()
self._load_hosted_models() self._load_hosted_models()
@@ -987,6 +1044,64 @@ class SettingsWindow(QDialog):
self._local_state_timer.timeout.connect(self._show_local_state) self._local_state_timer.timeout.connect(self._show_local_state)
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): def showEvent(self, event):
super().showEvent(event) super().showEvent(event)
self._show_local_state() 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 # 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 # 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. # pushes Save off the bottom of the screen with no way to shrink back.
page.setMaximumWidth(680)
area = QScrollArea() area = QScrollArea()
area.setWidgetResizable(True) area.setWidgetResizable(True)
area.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
area.setFrameShape(QScrollArea.Shape.NoFrame) area.setFrameShape(QScrollArea.Shape.NoFrame)
area.setWidget(page) area.setWidget(page)
for box in page.findChildren((QComboBox, QAbstractSpinBox)): for box in page.findChildren((QComboBox, QAbstractSpinBox)):
@@ -1161,6 +1278,13 @@ class SettingsWindow(QDialog):
page = QWidget() page = QWidget()
form = QFormLayout(page) 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() self.indicator_screen = QComboBox()
# The active screen rather than the pointer, for the reason in # The active screen rather than the pointer, for the reason in
# overlay._compositor_screen: it is what a compositor will answer for, # 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.")) "glossary, so it can repair the ones that still come out wrong."))
hint.setWordWrap(True) hint.setWordWrap(True)
layout.addWidget(hint) layout.addWidget(hint)
layout.addWidget(QLabel(t("Dictionary")))
self.transcribe_prompt = QPlainTextEdit() self.transcribe_prompt = QPlainTextEdit()
self.transcribe_prompt.setMaximumHeight(90) self.transcribe_prompt.setMaximumHeight(90)
layout.addWidget(self.transcribe_prompt) layout.addWidget(self.transcribe_prompt)
@@ -1828,7 +1953,7 @@ class SettingsWindow(QDialog):
self.minutes_list = QListWidget() self.minutes_list = QListWidget()
self.minutes_list.setWordWrap(True) self.minutes_list.setWordWrap(True)
self.minutes_list.setMaximumHeight(170) self.minutes_list.setMaximumHeight(110)
self.minutes_list.currentItemChanged.connect(self._show_minutes) self.minutes_list.currentItemChanged.connect(self._show_minutes)
layout.addWidget(self.minutes_list) layout.addWidget(self.minutes_list)
@@ -1839,6 +1964,7 @@ class SettingsWindow(QDialog):
self.minutes_view = QPlainTextEdit() self.minutes_view = QPlainTextEdit()
self.minutes_view.setReadOnly(True) self.minutes_view.setReadOnly(True)
self.minutes_view.setPlaceholderText(t("Pick a meeting to read it.")) self.minutes_view.setPlaceholderText(t("Pick a meeting to read it."))
self.minutes_view.setMinimumHeight(120)
layout.addWidget(self.minutes_view, 1) layout.addWidget(self.minutes_view, 1)
copy = QPushButton(t("Copy")) copy = QPushButton(t("Copy"))
@@ -1859,12 +1985,13 @@ class SettingsWindow(QDialog):
reload_ = QPushButton(t("Reload")) reload_ = QPushButton(t("Reload"))
reload_.clicked.connect(self._load_minutes) reload_.clicked.connect(self._load_minutes)
row = QHBoxLayout() row = QHBoxLayout()
row.addStretch()
row.addWidget(copy) row.addWidget(copy)
row.addWidget(self.minutes_retry) row.addWidget(self.minutes_retry)
row.addStretch(1)
row.addWidget(folder) row.addWidget(folder)
row.addWidget(delete) row.addWidget(delete)
row.addWidget(reload_) row.addWidget(reload_)
row.addStretch()
layout.addLayout(row) layout.addLayout(row)
return page return page
@@ -2053,11 +2180,6 @@ class SettingsWindow(QDialog):
"Once the history passes this many entries, the oldest one is dropped " "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." "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 = QPushButton(t("Copy selected to clipboard"))
copy.clicked.connect(self._copy_history) copy.clicked.connect(self._copy_history)
@@ -2067,12 +2189,12 @@ class SettingsWindow(QDialog):
clear.clicked.connect(self._clear_history) clear.clicked.connect(self._clear_history)
reload_ = QPushButton(t("Reload")) reload_ = QPushButton(t("Reload"))
reload_.clicked.connect(self._load_history) reload_.clicked.connect(self._load_history)
row = QHBoxLayout() self.history_actions = row = QHBoxLayout()
row.addWidget(copy) row.addWidget(copy)
row.addWidget(delete) row.addWidget(delete)
row.addStretch(1)
row.addWidget(clear) row.addWidget(clear)
row.addWidget(reload_) row.addWidget(reload_)
row.addStretch()
layout.addLayout(row) layout.addLayout(row)
return page return page
@@ -2167,6 +2289,8 @@ class SettingsWindow(QDialog):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
for index, widget in enumerate(widgets): for index, widget in enumerate(widgets):
layout.addWidget(widget, 1 if index == 0 else 0) 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 = QWidget()
holder.setLayout(layout) holder.setLayout(layout)
return holder return holder
@@ -2176,7 +2300,9 @@ class SettingsWindow(QDialog):
def _load(self): def _load(self):
conf = self.conf conf = self.conf
self._select_data(self.ui_language, conf["ui_language"]) 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._select_data(self.language, conf["language"])
self.auto_paste.setChecked(conf["auto_paste"]) self.auto_paste.setChecked(conf["auto_paste"])
self.paste_shortcut.setCurrentText(conf["paste_shortcut"]) self.paste_shortcut.setCurrentText(conf["paste_shortcut"])
@@ -2269,8 +2395,8 @@ class SettingsWindow(QDialog):
conf["assistant_prompt"] or self._loaded_defaults["assistant"] conf["assistant_prompt"] or self._loaded_defaults["assistant"]
) )
self._select_data(self.meeting_mic, conf["meeting_mic_target"]) self._select_source(self.meeting_mic, conf["meeting_mic_target"])
self._select_data(self.meeting_system, conf["meeting_system_target"]) self._select_source(self.meeting_system, conf["meeting_system_target"])
self.meeting_self_name.setText(conf["meeting_self_name"]) self.meeting_self_name.setText(conf["meeting_self_name"])
self.meeting_other_name.setText(conf["meeting_other_name"]) self.meeting_other_name.setText(conf["meeting_other_name"])
self.meeting_participants.setPlainText(conf["meeting_participants"]) self.meeting_participants.setPlainText(conf["meeting_participants"])
@@ -2285,8 +2411,10 @@ class SettingsWindow(QDialog):
conf["meeting_prompt"] or self._loaded_defaults["meeting"] conf["meeting_prompt"] or self._loaded_defaults["meeting"]
) )
with QSignalBlocker(self.file_timestamps), QSignalBlocker(self.file_cleanup):
self.file_timestamps.setChecked(conf["file_timestamps"]) self.file_timestamps.setChecked(conf["file_timestamps"])
self.file_cleanup.setChecked(conf["file_cleanup"]) self.file_cleanup.setChecked(conf["file_cleanup"])
if not hasattr(self, "file_path"):
self.file_path = "" self.file_path = ""
for which, (box, _status, _missing) in self._shortcut_rows.items(): for which, (box, _status, _missing) in self._shortcut_rows.items():
@@ -2300,10 +2428,13 @@ class SettingsWindow(QDialog):
self._refresh_assistant_status() self._refresh_assistant_status()
self._load_history() self._load_history()
self._load_minutes() self._load_minutes()
self._loaded_config = dict(conf.data)
def _save(self): def _save(self):
conf = self.conf conf = self.conf
before = dict(conf.data)
conf["ui_language"] = self.ui_language.currentData() or "auto" 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["mic_target"] = self.mic.currentData() or ""
conf["language"] = self.language.currentData() or "auto" conf["language"] = self.language.currentData() or "auto"
conf["auto_paste"] = self.auto_paste.isChecked() conf["auto_paste"] = self.auto_paste.isChecked()
@@ -2450,12 +2581,18 @@ class SettingsWindow(QDialog):
or hotkey.default_combo(which)) or hotkey.default_combo(which))
conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf["history_limit"] = self.history_limit.value() 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: try:
conf.save() conf.save()
except OSError as exc: except OSError as exc:
# An antivirus or a sync tool holding the file for a beat is a # 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 # message, not an exit: an exception out of a Qt slot takes the
# whole application down. # whole application down.
conf.data = before
QMessageBox.warning(self, "Dikte", QMessageBox.warning(self, "Dikte",
t("Could not save the settings: {error}", t("Could not save the settings: {error}",
error=exc)) error=exc))
@@ -2465,7 +2602,9 @@ class SettingsWindow(QDialog):
cfg.trim_history(conf["history_limit"]) cfg.trim_history(conf["history_limit"])
except OSError as exc: except OSError as exc:
print(f"dikte: could not trim the history ({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() self.applied.emit()
# conf.save() has switched the language t() speaks, so the message box # conf.save() has switched the language t() speaks, so the message box
# already answers in the new one; the labels around it were translated # already answers in the new one; the labels around it were translated
@@ -2489,6 +2628,12 @@ class SettingsWindow(QDialog):
or self.local_whisper._downloading or self.local_whisper._downloading
or self.local_llm._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 @staticmethod
def _select_data(combo, value): def _select_data(combo, value):
index = combo.findData(value) index = combo.findData(value)
+136
View File
@@ -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))
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 78 KiB

+1
View File
@@ -49,6 +49,7 @@ UNUSED_QT = [
analysis = Analysis( # noqa: F821 analysis = Analysis( # noqa: F821
[str(ROOT / "packaging" / "entry.py")], [str(ROOT / "packaging" / "entry.py")],
pathex=[str(ROOT)], pathex=[str(ROOT)],
datas=[(str(ROOT / "dikte" / "icons" / "*.svg"), "dikte/icons")],
hiddenimports=["PyQt6.QtNetwork"], hiddenimports=["PyQt6.QtNetwork"],
# tkinter is the other GUI toolkit CPython ships and would be dead weight; # 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. # dikte's own tests have no business in a build at all.
+169
View File
@@ -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'<figure><a href="{entry["file"]}"><img loading="lazy" src="{entry["file"]}"></a><figcaption>{html.escape(entry["file"])}</figcaption></figure>'
for entry in manifest["images"]
)
(output / "index.html").write_text(
'<!doctype html><meta charset="utf-8"><title>' + html.escape(title) + '</title>'
'<style>body{font:16px system-ui;background:#eee;color:#222;margin:24px}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px}figure{margin:0}img{width:100%;height:340px;object-fit:contain;object-position:top}figcaption{padding:8px}</style>'
'<h1>' + html.escape(title) + '</h1><p>' + html.escape(manifest["scope"]) + '</p><main>' + cards + '</main>',
encoding="utf-8",
)
if __name__ == "__main__":
main()
+7 -3
View File
@@ -148,8 +148,8 @@ class Parser(unittest.TestCase):
def parse(self, *argv): def parse(self, *argv):
return cli.build_parser().parse_args(list(argv)) return cli.build_parser().parse_args(list(argv))
def test_no_verb_at_all_is_the_settings_window(self): def test_no_verb_uses_the_plain_gui_command(self):
# argparse leaves the dest as None; run() is what turns it into "". # argparse leaves the dest as None; run() selects home.
opts = self.parse() opts = self.parse()
self.assertIsNone(opts.verb) self.assertIsNone(opts.verb)
self.assertEqual(opts.func, cli.cmd_plain) self.assertEqual(opts.func, cli.cmd_plain)
@@ -700,10 +700,14 @@ class WithoutAnInstance(DikteTest):
launch.assert_called_once_with("toggle") launch.assert_called_once_with("toggle")
def test_every_verb_that_opens_a_window_can_start_it(self): 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): with self.subTest(verb=verb):
self.assertTrue(self.run_verb([verb])[3].called) 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): def test_a_verb_asked_to_wait_starts_nothing(self):
"""There would be no run to wait for; the process would just be replaced.""" """There would be no run to wait for; the process would just be replaced."""
_, _, _, launch = self.run_verb(["toggle", "--wait"]) _, _, _, launch = self.run_verb(["toggle", "--wait"])
+404
View File
@@ -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")
+32 -11
View File
@@ -179,10 +179,11 @@ class Settings(DikteTest):
Qt.KeyboardModifier.NoModifier, Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase, False) 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()) window = self.window(cfg.Config())
tabs = window.findChildren(settings_ui.QTabWidget)[0] 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") self.assertEqual(window.windowTitle(), "Dikte Settings")
def test_no_tab_can_stretch_the_window_past_a_small_screen(self): 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: mock.patch.object(QApplication, "screenAt") as screen_at:
widget._reposition() widget._reposition()
screen_at.assert_not_called() screen_at.assert_not_called()
self.assertEqual(widget.pos(), QPoint(1948, 995)) self.assertEqual(widget.pos(), QPoint(1948, 1003))
def _screen(self, name, area): def _screen(self, name, area):
screen = mock.Mock() screen = mock.Mock()
@@ -1224,7 +1225,7 @@ class Overlay(DikteTest):
mock.patch.object(QApplication, "screenAt") as screen_at: mock.patch.object(QApplication, "screenAt") as screen_at:
widget._reposition() widget._reposition()
screen_at.assert_not_called() 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): def test_the_pointer_decides_when_the_compositor_will_not_say(self):
"""Every desktop but Plasma, and Plasma while KWin is being replaced.""" """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: return_value=screens[0]) as screen_at:
widget._reposition() widget._reposition()
screen_at.assert_called() screen_at.assert_called()
self.assertEqual(widget.pos(), QPoint(28, 995)) self.assertEqual(widget.pos(), QPoint(28, 1003))
def _two_screens(self): def _two_screens(self):
return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)), 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), \ with mock.patch.object(overlay_module, "_kwin", kwin), \
mock.patch.object(QApplication, "screens", return_value=screens): mock.patch.object(QApplication, "screens", return_value=screens):
widget.show_recording() 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"] kwin.call.return_value.arguments.return_value = ["DP-1"]
self._ticks_on(widget, screens, kwin) 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): 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 """Left off, because an indicator that jumps desks mid-sentence is one
@@ -1274,7 +1275,7 @@ class Overlay(DikteTest):
widget.show_recording() widget.show_recording()
kwin.call.return_value.arguments.return_value = ["DP-1"] kwin.call.return_value.arguments.return_value = ["DP-1"]
self._ticks_on(widget, screens, kwin) 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): def test_a_named_screen_is_never_left_for_the_pointer(self):
"""Naming one is the whole answer; following it would undo the naming.""" """Naming one is the whole answer; following it would undo the naming."""
@@ -1285,7 +1286,7 @@ class Overlay(DikteTest):
widget.show_recording() widget.show_recording()
self._ticks_on(widget, screens, kwin) self._ticks_on(widget, screens, kwin)
kwin.call.assert_not_called() 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): 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 """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"] kwin.call.return_value.arguments.return_value = ["DP-1"]
second = self.overlay(below=first) second = self.overlay(below=first)
second.show_busy("Asking Claude…") second.show_busy("Asking Claude…")
self.assertEqual(first.pos(), QPoint(1948, 995)) self.assertEqual(first.pos(), QPoint(1948, 1003))
self.assertEqual(second.pos(), QPoint(1948, 929)) self.assertEqual(second.pos(), QPoint(1948, 945))
def test_the_compositor_is_asked_only_now_and_then(self): def test_the_compositor_is_asked_only_now_and_then(self):
"""Every tick would be thirty conversations a second about a hand """Every tick would be thirty conversations a second about a hand
@@ -1404,6 +1405,7 @@ class LocalModels(DikteTest):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.enterContext(mock.patch.object(settings_ui.LocalModelBox, "_fetch_models"))
# A machine Dikte is actually installed on would otherwise answer the # A machine Dikte is actually installed on would otherwise answer the
# "nothing can transcribe" question from its real binary and model. # "nothing can transcribe" question from its real binary and model.
self.patch_attr(ggml, "BIN_DIR", self.path("bin")) self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
@@ -1687,6 +1689,25 @@ class LocalModels(DikteTest):
time.sleep(0.05) time.sleep(0.05)
_app.processEvents() _app.processEvents()
self.assertEqual(fetch.call_count, 1) 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): 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 # Sorted by size alone, the turbo files land between the two medium
# ones, half a screen from the model they are a copy of. # ones, half a screen from the model they are a copy of.