diff --git a/README.md b/README.md index 3a2b14e..6769f65 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ library, 3.11 or newer, and PyQt6. *[Türkçe README](README.tr.md)*
-
+
|
|
|
|
|
|
|
|
|
|
|
diff --git a/README.tr.md b/README.tr.md
index 86a4b82..5767336 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -12,11 +12,12 @@ Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
*[English README](README.md)*
-
+
|
|
|
|
|
|
|
|
|
|
|
diff --git a/dikte/app.py b/dikte/app.py
index f024ed4..ef91c4d 100644
--- a/dikte/app.py
+++ b/dikte/app.py
@@ -58,6 +58,7 @@ from .i18n import t # noqa: E402
from .meeting import MeetingPipeline # noqa: E402
from .overlay import Overlay # noqa: E402
from .settings_ui import SettingsWindow # noqa: E402
+from .home_ui import HomeWindow # noqa: E402
from .worker import Pipeline # noqa: E402
SERVER_NAME = ipc.SERVER_NAME
@@ -138,6 +139,8 @@ class Dikte:
self.meeting_base = ""
self.meeting_message = ""
self.settings_window = None
+ self.home_window = None
+ self.home_messages = {}
# The single-instance server, handed over once run_app has opened it, so
# that a restart can stop answering before the replacement starts.
self.server = None
@@ -165,13 +168,15 @@ class Dikte:
self.overlay = Overlay(self.conf["overlay_corner"],
screen_name=self.conf["overlay_screen"],
- follow_pointer=self.conf["overlay_follows_pointer"])
+ follow_pointer=self.conf["overlay_follows_pointer"],
+ theme_name=self.conf["theme"])
# The agent's indicator sits on top of the dictation one when both are
# up, and drops into the corner when it is alone there.
self.ask_overlay = Overlay(self.conf["overlay_corner"], below=self.overlay,
dismissable=True,
screen_name=self.conf["overlay_screen"],
- follow_pointer=self.conf["overlay_follows_pointer"])
+ follow_pointer=self.conf["overlay_follows_pointer"],
+ theme_name=self.conf["theme"])
self.recorder = audio.Recorder()
self.pipeline = Pipeline(self.conf)
self.ask_pipeline = Pipeline(self.conf)
@@ -195,7 +200,7 @@ class Dikte:
self.pipeline.stage.connect(self._on_stage)
self.pipeline.finished.connect(self._on_finished)
self.pipeline.failed.connect(self._on_pipeline_failed)
- self.ask_pipeline.stage.connect(self.ask_overlay.show_busy)
+ self.ask_pipeline.stage.connect(self._on_ask_stage)
self.ask_pipeline.finished.connect(self._on_ask_finished)
self.ask_pipeline.failed.connect(self._on_ask_error)
self.ask_pipeline.cancelled.connect(self._on_ask_cancelled)
@@ -294,6 +299,10 @@ class Dikte:
self.unload_action.triggered.connect(self.unload_models)
self.menu.addAction(self.unload_action)
+ self.home_action = QAction(t("Open Dikte"), self.menu)
+ self.home_action.triggered.connect(self.open_home)
+ self.menu.addAction(self.home_action)
+
self.settings_action = QAction(t("Settings…"), self.menu)
self.settings_action.triggered.connect(self.open_settings)
self.menu.addAction(self.settings_action)
@@ -524,7 +533,7 @@ class Dikte:
# than only able to press its buttons.
def handle(self, request, reply):
- cmd = str(request.get("cmd") or "settings").strip()
+ cmd = str(request.get("cmd") or "home").strip()
if cmd in ("toggle", "start", "stop", "record"):
self._dictation_request(cmd, request, reply)
elif cmd == "ask":
@@ -541,6 +550,7 @@ class Dikte:
"ask-reset": self.reset_conversation,
"meeting-cancel": self.cancel_meeting,
"settings": self.open_settings,
+ "home": self.open_home,
"reload": self.reload_settings,
"restart": self.restart,
"quit": self.app.quit,
@@ -609,9 +619,28 @@ class Dikte:
def _settle(self, kind, payload):
"""Tell whoever was waiting on this run how it ended."""
+ self._home_settled(kind, payload)
for reply in self._waiters.pop(kind, []):
reply(payload)
+ def _home_settled(self, kind, payload):
+ if not hasattr(self, "home_messages"):
+ self.home_messages = {}
+ self.home_messages.pop(kind + "_stage", None)
+ if payload.get("cancelled"):
+ message = t("Stopped.")
+ elif payload.get("error"):
+ message = t("Failed: {error}", error=payload["error"])
+ elif payload.get("warning"):
+ message = t("Completed with a warning: {error}", error=payload["warning"])
+ else:
+ message = t("Transcript ready") if kind == DICTATION else ""
+ self.home_messages[kind] = message
+ window = getattr(self, "home_window", None)
+ if window is not None:
+ window.refresh_results()
+ window.refresh()
+
def _auto_stop(self, run):
"""The end of a `record --seconds`, if that recording is still the one."""
if self._run_id == run and self.state == RECORDING:
@@ -656,6 +685,8 @@ class Dikte:
def reload_settings(self):
"""Read the config file back after something outside changed it."""
self.conf.load()
+ if self.settings_window is not None:
+ self.settings_window.refresh_configuration()
self._apply_settings()
def _toggle(self):
@@ -706,6 +737,9 @@ class Dikte:
# recorder's.
if self.state == RECORDING or self.recording:
return
+ if isinstance(getattr(self, "home_messages", None), dict):
+ self.home_messages[DICTATION] = ""
+ self.home_messages.pop("dictation_stage", None)
self.front_before = self._the_front()
self.overlay.show_recording()
self._begin_recording(DICTATION)
@@ -720,6 +754,9 @@ class Dikte:
def start_ask(self):
if self.ask_state != IDLE or self.recording:
return
+ if isinstance(getattr(self, "home_messages", None), dict):
+ self.home_messages[ASK] = ""
+ self.home_messages.pop("ask_stage", None)
self.front_before = self._the_front()
self.ask_overlay.show_recording(asking=True)
self._begin_recording(ASK)
@@ -1038,7 +1075,7 @@ class Dikte:
self.overlay.show_error(t("Meeting failed: {error}", error=first_line))
self.tray.showMessage(
t("Dikte: the meeting could not be written up"),
- t("{error}\n\nThe recording has been kept. Settings → Minutes can "
+ t("{error}\n\nThe recording has been kept. Meeting → Minutes can "
"try again.", error=error),
QSystemTrayIcon.MessageIcon.Warning, 12000,
)
@@ -1080,7 +1117,14 @@ class Dikte:
self.pipeline.run(wav_path, duration, rms_values,
paste=wants_paste, focus=focus)
+ def _on_ask_stage(self, message):
+ if isinstance(getattr(self, "home_messages", None), dict):
+ self.home_messages["ask_stage"] = message
+ self.ask_overlay.show_busy(message)
+
def _on_stage(self, message):
+ if isinstance(getattr(self, "home_messages", None), dict):
+ self.home_messages["dictation_stage"] = message
# The corner belongs to the recording when one is on: the previous
# run's progress must not wipe the waveform mid-sentence.
if self.state != RECORDING:
@@ -1105,19 +1149,18 @@ class Dikte:
# a rejected key otherwise looks exactly like working dictation.
if self.state != RECORDING:
self.overlay.show_warning(
- t("Pasted raw, cleanup failed: {error}",
+ t("Completed with a warning: {error}",
error=warning.splitlines()[0])
)
self.tray.showMessage(
- t("Dikte: cleanup failed"), warning,
+ t("Dikte: completed with a warning"), warning,
QSystemTrayIcon.MessageIcon.Warning, 10000,
)
elif self.state != RECORDING:
# While a new recording is on, the flash is skipped: the text
# arriving where the cursor is says everything it would have.
- action = t("Pasted") if self.conf["auto_paste"] else t("Copied")
self.overlay.show_done(
- t("{action}: {preview}", action=action, preview=_preview(text))
+ t("Transcript ready: {preview}", preview=_preview(text))
)
self._transcript_settled({"ok": True, "text": text, "raw": _raw,
"warning": warning,
@@ -1264,9 +1307,21 @@ class Dikte:
# ---- settings ---------------------------------------------------------
+ def open_home(self):
+ if self.settings_window is None:
+ self._make_settings()
+ if getattr(self, "home_window", None) is None:
+ self.home_window = HomeWindow(self, self.settings_window)
+ self.home_window.show()
+ self.home_window.raise_()
+ self.home_window.activateWindow()
+
def open_settings(self):
if self.settings_window is None:
self._make_settings()
+ else:
+ self.settings_window.refresh_configuration()
+ self.settings_window.refresh_sources()
self.settings_window.show()
self.settings_window.raise_()
self.settings_window.activateWindow()
@@ -1281,8 +1336,9 @@ class Dikte:
self.settings_window.finished.connect(self._settings_closed)
def _settings_closed(self, *_):
- # Don't drop the object while its own signal is still being delivered.
- QTimer.singleShot(0, lambda: setattr(self, "settings_window", None))
+ # Task pages share this controller and may still be processing a file.
+ # Closing the configuration dialog keeps both its edits and jobs alive.
+ pass
def _reopen_settings(self):
"""Replace the settings window, so a language change reaches it too.
@@ -1295,8 +1351,15 @@ class Dikte:
old one stood, on the same tab.
"""
old = self.settings_window
- if old is None:
+ if old is None or old._work_in_flight():
return
+ home = getattr(self, "home_window", None)
+ home_visible = home is not None and home.isVisible()
+ home_mode = home.mode if home is not None else "dictation"
+ home_geometry = home.geometry() if home is not None else None
+ if home is not None:
+ home.close()
+ self.home_window = None
tab = old.tabs.currentIndex()
geometry = old.geometry()
# Replaced rather than merely closed: left connected, _settings_closed
@@ -1313,9 +1376,28 @@ class Dikte:
# window does not come up at the default size and jump.
self.settings_window.setGeometry(geometry)
self.settings_window.tabs.setCurrentIndex(tab)
+ self.settings_window.file_path = old.file_path
+ self.settings_window.file_label.setText(old.file_label.text())
+ self.settings_window.file_output.setPlainText(old.file_output.toPlainText())
+ self.settings_window.file_segments = getattr(old, "file_segments", [])
+ self.settings_window.file_save_srt.setEnabled(bool(self.settings_window.file_segments))
+ self.settings_window.file_status.setText(old.file_status.text())
self.settings_window.show()
self.settings_window.raise_()
self.settings_window.activateWindow()
+ for signal, callback in (
+ (self.meetings.progress, old._on_minutes_progress),
+ (self.meetings.finished, old._on_minutes_finished),
+ (self.meetings.failed, old._on_minutes_failed),
+ ):
+ signal.disconnect(callback)
+ if home is not None:
+ self.home_window = HomeWindow(self, self.settings_window)
+ self.home_window.setGeometry(home_geometry)
+ self.home_window.show_mode(home_mode)
+ if home_visible:
+ self.home_window.show()
+ home.deleteLater()
def _apply_local(self):
"""Pass the local settings on, and hold the models ready if asked to.
@@ -1353,10 +1435,13 @@ class Dikte:
def _apply_settings(self):
for indicator in (self.overlay, self.ask_overlay):
+ indicator.set_theme(self.conf["theme"])
indicator.corner = self.conf["overlay_corner"]
indicator.screen_name = self.conf["overlay_screen"]
indicator.follow_pointer = self.conf["overlay_follows_pointer"]
self._apply_local()
+ if getattr(self, "home_window", None) is not None:
+ self.home_window.refresh()
self._build_tray()
self._refresh_tray()
# Taken once here for _external: the answer cannot change under a
@@ -1532,11 +1617,11 @@ def _hand_over(command):
"""Give the running instance the attention this start was asking for.
A start carrying a verb forwards only that verb; a bare double start asks
- for the Settings window as the sign of life the click was looking for.
+ for the daily workspace as the sign of life the click was looking for.
Retried for a moment, because the copy that won the lock may not be
listening yet.
"""
- verb = command or "settings"
+ verb = command or "home"
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if ipc.send(verb) is not None:
@@ -1563,7 +1648,7 @@ def run_app(args):
if command:
ipc.send(command)
else:
- ipc.send("settings")
+ ipc.send("home")
return 0
app = QApplication(sys.argv)
@@ -1638,13 +1723,12 @@ def run_app(args):
server.newConnection.connect(on_connection)
app.aboutToQuit.connect(dikte.shutdown)
- # No key for the chosen transcription provider means nothing can work yet,
- # so the settings window is the only useful thing to open.
- # A transcription provider that cannot run yet, whether that is a missing
- # API key or a model nobody has downloaded, means nothing can work, so the
- # settings window is the only useful thing to open.
- if command == "settings" or not dikte.conf.transcribe_ready():
+ # Explicit home requests and first setup open the daily workspace.
+ # A configured --gui background start stays quiet for login and restart.
+ if command == "settings":
dikte.open_settings()
+ elif command == "home" or not dikte.conf.transcribe_ready():
+ dikte.open_home()
elif command == "toggle":
QTimer.singleShot(0, dikte.toggle)
elif command == "ask":
diff --git a/dikte/cli.py b/dikte/cli.py
index 7a8957c..940425f 100644
--- a/dikte/cli.py
+++ b/dikte/cli.py
@@ -45,7 +45,7 @@ NOT_RUNNING = 3
# Verbs that start the application when none is running, which is what a
# shortcut registered with the desktop has always relied on: press the key on a
# fresh login and Dikte comes up recording.
-GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
+GUI_VERBS = {"", "home", "settings", "toggle", "ask", "meeting"}
# Asking a process that is not there to stop, cancel or quit is not a failure;
# it is already in the state that was asked for.
@@ -1275,7 +1275,7 @@ def build_parser():
updates.set_defaults(func=cmd_update)
leaf(subs, "status", "what it is doing right now").set_defaults(func=cmd_status)
- for name, help_text in (("settings", "open the settings window"),
+ for name, help_text in (("home", "open Dikte"), ("settings", "open the settings window"),
("restart", "reload the running instance"),
("quit", "shut it down")):
leaf(subs, name, help_text).set_defaults(func=cmd_plain)
@@ -1306,8 +1306,8 @@ def run(argv):
pass
parser = build_parser()
opts = parser.parse_args(argv)
- # No verb at all is the plain `dikte`, which means the settings window.
- opts.verb = opts.verb or ""
+ # No verb opens the daily workspace; settings remains an explicit verb.
+ opts.verb = opts.verb or "home"
# Every path here either talks over the socket or drives one of the workers,
# and both want an event loop under them; a window is what none of them want.
_app = QCoreApplication.instance() or QCoreApplication(sys.argv[:1])
diff --git a/dikte/config.py b/dikte/config.py
index c06377a..5ec5285 100644
--- a/dikte/config.py
+++ b/dikte/config.py
@@ -418,6 +418,7 @@ da senin soracağın soruya verilecek bir yanıt yok.
ve varsayımını bir yan cümlede söyle"""
DEFAULTS = {
+ "theme": "nord",
"ui_language": "auto", # auto | tr | en
"openai_api_key": "",
"openai_base_url": "https://api.openai.com/v1",
diff --git a/dikte/home_ui.py b/dikte/home_ui.py
new file mode 100644
index 0000000..df97e6c
--- /dev/null
+++ b/dikte/home_ui.py
@@ -0,0 +1,539 @@
+"""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("Audio: {sound} / Text: {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._history_page()
+ for page in self.mode_pages.values():
+ self.pages.addWidget(page)
+ self.footer = _label("", "footer")
+ 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.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)
+ self.ask_button = _button("", self._ask, "primary")
+ layout.addWidget(self.ask_button)
+ 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)
+ layout.addWidget(_button(t("Start a new conversation"), self.controller.reset_conversation))
+ self.ask_error = _label()
+ layout.addWidget(self.ask_error)
+ self.ask_output = QPlainTextEdit()
+ self.ask_output.setReadOnly(True)
+ self.ask_output.setAccessibleName(t("Assistant reply"))
+ self.ask_output.setPlaceholderText(t("The assistant's reply will appear here."))
+ self.ask_output.setMinimumHeight(160)
+ layout.addWidget(self.ask_output, 1)
+ layout.addWidget(_button(t("Copy"), lambda: QApplication.clipboard().setText(self.ask_output.toPlainText())))
+ return page
+
+ def _history_page(self):
+ page = QWidget()
+ layout = QVBoxLayout(page)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(_button(t("Back to dictation"), lambda: self.show_mode("dictation")))
+ layout.addWidget(self.settings.task_pages["history"], 1)
+ self.settings.task_pages["history"].show()
+ layout.addWidget(_button(t("Open selected text"), self._open_selected))
+ self.settings.history.itemDoubleClicked.connect(self._open_selected)
+ return page
+
+ def _open_selected(self, *_):
+ rows = self.settings._selected_rows()
+ if rows:
+ self._open_text("\n\n".join(row.get("text", "") for row in rows))
+
+ def _open_text(self, text):
+ if not text:
+ return
+ document = QDialog(self)
+ document.setWindowTitle(t("Transcript"))
+ document.resize(600, 500)
+ layout = QVBoxLayout(document)
+ editor = QPlainTextEdit()
+ editor.setReadOnly(True)
+ editor.setPlainText(text)
+ layout.addWidget(editor)
+ layout.addWidget(_button(t("Copy"), lambda: QApplication.clipboard().setText(text)))
+ document.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
+ document.show()
+
+ def show_mode(self, mode):
+ if mode not in self.mode_pages:
+ return
+ self.mode = mode
+ self.pages.setCurrentWidget(self.mode_pages[mode])
+ self.mode_buttons["dictation" if mode == "history" else mode].setChecked(True)
+ if mode == "history":
+ self.settings._load_history()
+ elif mode == "meeting":
+ self.settings._load_minutes()
+ self.refresh()
+
+ def showEvent(self, event):
+ super().showEvent(event)
+ self._timer.start()
+ self.refresh_results()
+ self.refresh()
+
+ def hideEvent(self, event):
+ self._timer.stop()
+ super().hideEvent(event)
+
+ def refresh_results(self, *_):
+ rows = cfg.read_history(self.conf["history_limit"])
+ result = next((row for row in reversed(rows) if row.get("mode") != "ask"), {})
+ answer = next((row for row in reversed(rows) if row.get("mode") == "ask"), {})
+ self._set_result(result, answer)
+
+ def _set_result(self, result, answer):
+ if result != self._last_result:
+ self._last_result = dict(result)
+ self.latest_text.setPlainText(result.get("text", ""))
+ self.latest_time.setText(result.get("ts", ""))
+ self.latest_warning.setText(result.get("cleanup_error", ""))
+ self.latest_warning.setVisible(bool(self.latest_warning.text()))
+ self.copy_button.setEnabled(bool(result.get("text")))
+ self.open_button.setEnabled(bool(result.get("text")))
+ if answer != self._last_answer:
+ self._last_answer = dict(answer)
+ self.ask_output.setPlainText(answer.get("text", ""))
+
+ def refresh(self, *_):
+ app, conf = self.controller, self.conf
+ if self._theme_name != conf["theme"]:
+ self._theme_name = conf["theme"]
+ theme.apply(self, self._theme_name)
+ colors = theme.palette(self._theme_name)
+ self._mic_icon = microphone_icon(color=colors["accent_text"])
+ self._stop_icon = microphone_icon(recording=True, color=colors["accent_text"])
+ self.settings_button.setIcon(settings_icon(colors["muted"]))
+ messages = getattr(app, "home_messages", {})
+ ready = conf.transcribe_ready()
+ recording = app.state == "recording"
+ busy = app.state == "busy"
+ title = t("Ready to speak") if ready else t("Set up transcription")
+ if recording:
+ seconds = int(app._recorded_seconds())
+ title = t("Paused") if app.paused else t("Recording")
+ title += f" {seconds // 60:02d}:{seconds % 60:02d}"
+ elif busy:
+ title = messages.get("dictation_stage") or t("Transcribing…")
+ self.capture_status.setText(title)
+ action = t("Stop and transcribe") if recording else t("Start recording") if ready else t("Set up transcription")
+ self.capture_button.setAccessibleName(action)
+ self.capture_button.setIcon(self._stop_icon if recording else self._mic_icon)
+ self.capture_button.setToolTip(action)
+ self.capture_button.setEnabled(recording or not app.recording)
+ self.capture_shortcut.setText(" + ".join(part.strip() for part in conf["shortcut"].split("+")))
+ self.pause_button.setVisible(recording)
+ self.cancel_button.setVisible(recording)
+ self.pause_button.setText(t("Resume the recording") if app.paused else t("Pause the recording"))
+ warning = ""
+ if conf["cleanup_enabled"] and conf["cleanup_provider"] == "local" and not conf.local_llm_ready():
+ warning = t("The local editing model is missing. Set it up in Settings; the original transcript is kept if editing fails.")
+ self.capture_error.setText(messages.get("dictation", "") or warning)
+ self.capture_error.setVisible(bool(self.capture_error.text()))
+ details = processing_locations(conf, self.mode, self.settings.file_cleanup.isChecked(),
+ self.settings.file_timestamps.isChecked())
+ self.capture_models.setText(details if self.mode == "dictation" else "")
+ self.footer.setText(details)
+ self.footer.setVisible(self.mode != "dictation")
+ self._refresh_meeting(messages, ready)
+ self._refresh_ask(messages, ready)
+
+ def _refresh_meeting(self, messages, ready):
+ app = self.controller
+ state = app.meeting_state
+ supported = audio.sound().meetings
+ hint = t("Record your microphone and the other participants. Use headphones.")
+ if not supported:
+ hint = t("Meeting recording is not supported on this system. You can still transcribe a file.")
+ elif audio.sound() is audio.COREAUDIO:
+ hint = t("On macOS, set up BlackHole or Loopback and select the system audio source in Settings first.")
+ title = t("Meeting")
+ if state == "recording":
+ seconds = int(app.meeting_elapsed.elapsed() / 1000)
+ title = t("Recording") + f" {seconds // 60:02d}:{seconds % 60:02d}"
+ elif state == "working":
+ title = app.meeting_message or t("Writing the meeting up…")
+ if supported and not self.conf.openrouter_key():
+ hint += "\n" + t("Connect OpenRouter in Settings to write minutes. The recording is kept if writing fails.")
+ self.meeting_status.setText(title)
+ self.meeting_hint.setText(hint)
+ self.meeting_button.setText(t("End the meeting and write it up") if state == "recording" else t("Record a meeting") if ready else t("Set up transcription"))
+ self.meeting_button.setEnabled(supported and state != "working")
+ self.meeting_cancel.setVisible(state == "recording")
+ self.meeting_error.setText(messages.get("meeting", ""))
+ self.meeting_error.setVisible(bool(self.meeting_error.text()))
+
+ def _refresh_ask(self, messages, ready):
+ app, conf = self.controller, self.conf
+ name = assistant.display_name(conf)
+ provider = conf["assistant_provider"]
+ directory = assistant.working_dir(conf)
+ if provider == "claude":
+ permission = {"auto": t("Automatic permission decisions"),
+ "manual": t("Only actions that need no permission"),
+ "bypassPermissions": t("All permissions allowed")}.get(conf["assistant_permission_mode"], conf["assistant_permission_mode"])
+ elif provider == "codex":
+ permission = {"workspace-write": t("Read files; write in the working directory"),
+ "read-only": t("Read only"),
+ "danger-full-access": t("No sandbox at all")}.get(conf["assistant_codex_sandbox"], conf["assistant_codex_sandbox"])
+ elif provider == "agy":
+ permission = t("Uses the CLI's configured permissions")
+ else:
+ permission = t("Chat provider; no local command execution")
+ self.ask_scope.setText(t("{name}\nPermissions: {permission}\nWorking directory: {directory}\nShortcut: {shortcut}\nThis button sends a spoken command to the assistant. Its reply is copied without automatic pasting.", name=name, permission=permission, directory=directory if provider in ("claude", "codex", "agy") else t("Not used"), shortcut=conf["assistant_shortcut"] or t("Not assigned")))
+ state = app.ask_state
+ self.ask_status.setText(t("Paused") if state == "recording" and app.paused else t("Recording") if state == "recording" else messages.get("ask_stage", t("Working…")) if state == "busy" else t("Assistant"))
+ available = self._assistant_available()
+ self.ask_button.setText(t("Stop and send command") if state == "recording" else t("Set up transcription") if not ready else t("Record a command") if available else t("Set up assistant"))
+ self.ask_button.setEnabled(state == "recording" or (state == "idle" and not app.recording))
+ self.ask_pause.setVisible(state == "recording")
+ self.ask_pause.setText(t("Resume the recording") if app.paused else t("Pause the recording"))
+ self.ask_cancel.setVisible(state != "idle")
+ self.ask_error.setText(messages.get("ask", "") or ("" if available else t("Install the selected assistant CLI or configure its connection in Settings.")))
+ self.ask_error.setVisible(bool(self.ask_error.text()))
+
+ def _assistant_available(self):
+ provider = self.conf["assistant_provider"]
+ binary = assistant.executable(provider)
+ if binary:
+ return bool(shutil.which(binary))
+ return bool(self.conf.opencode_key() if provider == "opencode" else self.conf.openrouter_key())
+
+ def _capture(self):
+ app = self.controller
+ if app.state == "recording":
+ # Clicking Stop puts this window in front of the original target.
+ app.paste_override["dictation"] = False
+ app.stop()
+ elif not app.recording:
+ if not self.conf.transcribe_ready():
+ self.settings.tabs.setCurrentIndex(self.settings.api_tab_index)
+ app.open_settings()
+ return
+ app.paste_override["dictation"] = False
+ app.start()
+ if app.state != "recording":
+ app.paste_override.pop("dictation", None)
+ self.refresh()
+
+ def _ask(self):
+ app = self.controller
+ if app.ask_state == "recording":
+ app.paste_override["ask"] = False
+ app.stop_ask()
+ elif app.ask_state == "idle" and not app.recording:
+ if not self.conf.transcribe_ready():
+ app.open_settings()
+ return
+ if not self._assistant_available():
+ self.settings.tabs.setCurrentIndex(4)
+ app.open_settings()
+ return
+ app.paste_override["ask"] = False
+ app.start_ask()
+ if app.ask_state != "recording":
+ app.paste_override.pop("ask", None)
+ self.refresh()
+
+ def _pause(self):
+ self.controller._toggle_pause()
+ self.refresh()
+
+ def _cancel_capture(self):
+ if self.controller.state == "recording":
+ self.controller._cancel()
+ self.refresh()
+
+ def _cancel_ask(self):
+ self.controller.cancel_ask()
+ self.refresh()
+
+ def _meeting(self):
+ if not audio.sound().meetings:
+ return
+ if not self.conf.transcribe_ready() and self.controller.meeting_state == "idle":
+ self.controller.open_settings()
+ else:
+ self.controller._toggle_meeting()
+ self.refresh()
+
+ def _cancel_meeting(self):
+ self.controller.cancel_meeting()
+ self.refresh()
diff --git a/dikte/i18n.py b/dikte/i18n.py
index b0b9ebf..c8badc9 100644
--- a/dikte/i18n.py
+++ b/dikte/i18n.py
@@ -60,6 +60,75 @@ def name(text, /, case=""):
TR = {
+ "Theme": "Tema",
+ "Classic dark": "Klasik karanlık",
+ "Classic light": "Klasik beyaz",
+ "Local": "Yerel",
+ "Local GPU": "Yerel GPU",
+ "Local CPU": "Yerel CPU",
+ "Model not selected": "Model seçilmedi",
+ "{name} default model": "{name} varsayılan modeli",
+ "Audio: {sound} / Text: {text}": "Ses: {sound} / Metin: {text}",
+ "Minutes: {model}": "Tutanak: {model}",
+ "Assistant: {model}": "Asistan: {model}",
+ "Completed with a warning: {error}": "Uyarıyla tamamlandı: {error}",
+ "Dikte: completed with a warning": "Dikte: uyarıyla tamamlandı",
+ "Connect OpenRouter in Settings to write minutes. The recording is kept if writing fails.": "Tutanak yazmak için ayarlardan OpenRouter bağlantısını kurun. Tutanak yazılamazsa kayıt korunur.",
+ "Set up assistant": "Asistanı ayarla",
+ "Install the selected assistant CLI or configure its connection in Settings.": "Seçili asistanın CLI uygulamasını kurun veya ayarlardan bağlantısını yapılandırın.",
+ "The local editing model is missing. Set it up in Settings; the original transcript is kept if editing fails.": "Yerel düzenleme modeli eksik. Ayarlardan yapılandırın; düzenleme başarısız olursa ham metin korunur.",
+ # Compact desktop workspace.
+ 'Audio: {sound}\nText: {text}': 'Ses: {sound}\nMetin: {text}',
+ 'This computer': 'Bu bilgisayar',
+ 'Editing off': 'Düzenleme kapalı',
+ 'Record your microphone and the other participants. Use headphones.': 'Mikrofonunuzu ve diğer katılımcıları kaydedin. Kulaklık kullanın.',
+ 'Minutes: OpenRouter': 'Tutanak: OpenRouter',
+ 'Use the shortcut in the app where you want to write.': 'Yazmak istediğiniz uygulamada kısayola basın.',
+ 'Button recordings stay here and are copied to the clipboard.': 'Düğmeyle başlattığınız kayıtların metni burada kalır ve panoya kopyalanır.',
+ 'Latest text': 'Son metin',
+ 'Your first transcript will appear here.': 'İlk kaydınızın metni burada görünecek.',
+ 'Open text': 'Metni aç',
+ 'Assistant reply': 'Asistan yanıtı',
+ "The assistant's reply will appear here.": 'Asistanın yanıtı burada görünecek.',
+ 'Ready to speak': 'Konuşmaya hazır',
+ 'Set up transcription': 'Yazıya çevirmeyi ayarla',
+ 'Automatic language': 'Dil otomatik',
+ '{mic} / {language}': '{mic} / {language}',
+ 'Meeting recording is not supported on this system. You can still transcribe a file.': 'Bu sistemde toplantı kaydı desteklenmiyor. Ses dosyalarını yazıya çevirebilirsiniz.',
+ '{name}\nPermissions: {permission}\nWorking directory: {directory}\nShortcut: {shortcut}\nThis button sends a spoken command to the assistant. Its reply is copied without automatic pasting.': '{name}\nYetkiler: {permission}\nÇalışma dizini: {directory}\nKısayol: {shortcut}\nBu düğme asistana sesli komut gönderir. Yanıtı otomatik yapıştırılmadan kopyalanır.',
+ '{name} CLI (provider connection)': '{name} CLI (sağlayıcı bağlantısı)',
+ 'Assistant: {name}': 'Asistan: {name}',
+ 'Settings': 'Ayarlar',
+ 'Back to dictation': 'Dikteye dön',
+ 'Open selected text': 'Seçili metni aç',
+ 'Paused': 'Duraklatıldı',
+ 'Choose a connection or download a local model in Settings.': 'Ayarlardan bir bağlantı seçin veya yerel model indirin.',
+ 'On macOS, set up BlackHole or Loopback and select the system audio source in Settings first.': 'macOS üzerinde önce BlackHole veya Loopback kurun ve ayarlardan sistem sesi kaynağını seçin.',
+ 'Stop and send command': 'Bitir ve komutu gönder',
+ "Uses the CLI's configured permissions": 'CLI için yapılandırılmış yetkileri kullanır',
+ 'Chat provider; no local command execution': 'Sohbet sağlayıcısı; yerel komut çalıştırmaz',
+ 'Record a command': 'Sesli komut kaydet',
+ 'Automatic permission decisions': 'İzin kararları otomatik',
+ 'Only actions that need no permission': 'Yalnız izin gerektirmeyen işlemler',
+ 'All permissions allowed': 'Tüm izinler açık',
+ 'Not used': 'Kullanılmıyor',
+ 'Not assigned': 'Atanmamış',
+ 'Assistant': 'Asistan',
+ 'Read files; write in the working directory': 'Dosyaları oku; çalışma dizinine yaz',
+ 'Open Dikte': 'Dikteyi aç',
+ '{error}\n\nThe recording has been kept. Meeting → Minutes can try again.': '{error}\n\nKayıt korundu. Toplantı → Tutanaklar bölümünden yeniden deneyebilirsiniz.',
+ 'Original text kept, cleanup failed: {error}': 'Ham metin korundu, düzenleme başarısız: {error}',
+ 'Transcript ready: {preview}': 'Metin hazır: {preview}',
+ 'Editing did not finish. The original text was kept. {error}': 'Düzenleme tamamlanamadı. Ham metin korundu. {error}',
+ 'Transcript ready': 'Metin hazır',
+ 'Text editing and dictionary': 'Metin düzenleme ve sözlük',
+ 'Settings category': 'Ayar kategorisi',
+ 'Apply changes': 'Değişiklikleri uygula',
+ 'Discard changes': 'Değişikliklerden vazgeç',
+ 'Unsaved changes': 'Kaydedilmemiş değişiklikler',
+ 'Dictionary': 'Sözlük',
+ 'File': 'Dosya',
+
# --- tray ---------------------------------------------------------
"Start recording": "Kaydı başlat",
"Stop and transcribe": "Kaydı bitir ve yaz",
diff --git a/dikte/icons/chevron-down-light.svg b/dikte/icons/chevron-down-light.svg
new file mode 100644
index 0000000..b69b232
--- /dev/null
+++ b/dikte/icons/chevron-down-light.svg
@@ -0,0 +1 @@
+
diff --git a/dikte/icons/chevron-down.svg b/dikte/icons/chevron-down.svg
new file mode 100644
index 0000000..c6b4f49
--- /dev/null
+++ b/dikte/icons/chevron-down.svg
@@ -0,0 +1 @@
+
diff --git a/dikte/icons/chevron-up-light.svg b/dikte/icons/chevron-up-light.svg
new file mode 100644
index 0000000..9fb4fa4
--- /dev/null
+++ b/dikte/icons/chevron-up-light.svg
@@ -0,0 +1 @@
+
diff --git a/dikte/icons/chevron-up.svg b/dikte/icons/chevron-up.svg
new file mode 100644
index 0000000..1a066d4
--- /dev/null
+++ b/dikte/icons/chevron-up.svg
@@ -0,0 +1 @@
+
diff --git a/dikte/overlay.py b/dikte/overlay.py
index 79601c7..2e4969e 100644
--- a/dikte/overlay.py
+++ b/dikte/overlay.py
@@ -9,33 +9,18 @@ from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QF
from PyQt6.QtWidgets import QWidget, QApplication
from . import mac_window
+from . import theme
BARS = 22
-HEIGHT = 56
+HEIGHT = 48
MIN_WIDTH = 210
MAX_WIDTH = 460
MARGIN = 28
GAP = 10 # between two indicators sharing a corner
FOLLOW_EVERY = 8 # ticks between two looks for the pointer: about four a second
-BG = QColor(22, 24, 29, 238)
-BORDER = QColor(255, 255, 255, 28)
-TEXT = QColor(235, 237, 242)
-MUTED = QColor(150, 156, 168)
-REC = QColor(240, 78, 82)
-BUSY = QColor(120, 170, 255)
-OK = QColor(80, 205, 140)
-ERR = QColor(240, 100, 90)
-WARN = QColor(240, 180, 80)
-THEM = QColor(110, 190, 255) # the other side of a meeting
-
-ASK = QColor(150, 140, 255) # recording a command rather than a dictation
-# Recording, but nothing is going in. The same amber a warning gets, and for
-# the same reason: it is the colour that stops you walking away from it.
-HELD = WARN
-
-STATE_COLORS = {"recording": REC, "asking": ASK, "meeting": REC, "busy": BUSY,
- "done": OK, "warning": WARN, "error": ERR}
+STATE_ROLES = {"recording": "rec", "asking": "ask", "meeting": "rec", "busy": "accent",
+ "done": "ok", "warning": "warn", "error": "err"}
LIVE = ("recording", "asking", "meeting")
@@ -94,8 +79,9 @@ class Overlay(QWidget):
under way at the same time and still both be visible."""
def __init__(self, corner="bottom-left", below=None, dismissable=False,
- screen_name="", follow_pointer=False):
+ screen_name="", follow_pointer=False, theme_name=theme.DEFAULT):
super().__init__(None)
+ self.set_theme(theme_name)
self.corner = corner
self.screen_name = screen_name
# Whether it goes on following the pointer once it is up, rather than
@@ -388,6 +374,15 @@ class Overlay(QWidget):
# ---- painting --------------------------------------------------
+ def set_theme(self, name):
+ self.colors = {key: QColor(value) for key, value in theme.palette(name).items()}
+ states = (dict(rec="#D52E3F", ok="#187B4B", err="#BD2735", warn="#946000",
+ them="#2460A0", ask="#7048B4") if name == "light" else
+ dict(rec="#F04E52", ok="#50CD8C", err="#F0645A", warn="#F0B450",
+ them="#6EBEFF", ask="#968CFF"))
+ self.colors.update({key: QColor(value) for key, value in states.items()})
+ self.update()
+
def paintEvent(self, _event):
if self.state == "hidden":
return # translucent window, nothing drawn means nothing shown
@@ -397,14 +392,14 @@ class Overlay(QWidget):
rect = QRectF(0.5, 0.5, self.width() - 1, self.height() - 1)
path = QPainterPath()
- path.addRoundedRect(rect, 15, 15)
- painter.fillPath(path, BG)
- painter.setPen(QPen(BORDER, 1))
+ path.addRoundedRect(rect, 12, 12)
+ painter.fillPath(path, self.colors["base"])
+ painter.setPen(QPen(self.colors["border"], 1))
painter.drawPath(path)
- accent = STATE_COLORS.get(self.state, MUTED)
+ accent = self.colors[STATE_ROLES.get(self.state, "muted")]
if self._held:
- accent = HELD
+ accent = self.colors["warn"]
self._draw_indicator(painter, accent)
if self.state in LIVE:
@@ -480,13 +475,13 @@ class Overlay(QWidget):
gap = (right - left - BARS * bar_w) / max(1, BARS - 1)
return left, bar_w, bar_w + gap
- @staticmethod
- def _bar_colour(shaped, accent):
- color = QColor(accent if shaped > 0.04 else MUTED)
+ def _bar_colour(self, shaped, accent):
+ color = QColor(accent if shaped > 0.04 else self.colors["muted"])
color.setAlphaF(0.35 + 0.65 * min(1.0, shaped * 2.2))
return color
- def _draw_waveform(self, painter, accent=REC):
+ def _draw_waveform(self, painter, accent=None):
+ accent = accent if accent is not None else self.colors["rec"]
if self.state == "meeting":
self._draw_dual_waveform(painter)
return
@@ -512,7 +507,7 @@ class Overlay(QWidget):
painter.setPen(Qt.PenStyle.NoPen)
for i, (mine, theirs) in enumerate(zip(self.levels, self.levels2)):
x = left + i * step
- for level, accent, up in ((mine, REC, True), (theirs, THEM, False)):
+ for level, accent, up in ((mine, self.colors["rec"], True), (theirs, self.colors["them"], False)):
shaped = min(1.0, level ** 0.55)
h = 2.0 + shaped * 12.0
y = mid - 1.5 - h if up else mid + 1.5
@@ -524,7 +519,7 @@ class Overlay(QWidget):
font.setPointSizeF(10.0)
font.setFamilies(["monospace"])
painter.setFont(font)
- painter.setPen(MUTED)
+ painter.setPen(self.colors["muted"])
mins, secs = divmod(int(self.seconds), 60)
hours, mins = divmod(mins, 60)
text = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
@@ -543,7 +538,7 @@ class Overlay(QWidget):
"""A faint cross on the right: without it there is nothing to say the
box can be clicked away, and a feature nobody can see is not one."""
cx, cy = self.width() - 18.0, self.height() / 2
- pen = QPen(QColor(MUTED), 1.6)
+ pen = QPen(QColor(self.colors["muted"]), 1.6)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
@@ -552,7 +547,7 @@ class Overlay(QWidget):
def _draw_message(self, painter):
painter.setFont(self._label_font())
- painter.setPen({"error": ERR, "warning": WARN}.get(self.state, TEXT))
+ painter.setPen({"error": self.colors["err"], "warning": self.colors["warn"]}.get(self.state, self.colors["text"]))
# Leave the cross its corner rather than running the text under it.
box = QRectF(46, 0, self.width() - 60 - (18 if self._can_dismiss else 0),
self.height())
diff --git a/dikte/settings_ui.py b/dikte/settings_ui.py
index 0603aa5..6620117 100644
--- a/dikte/settings_ui.py
+++ b/dikte/settings_ui.py
@@ -6,13 +6,13 @@ import shutil
import sys
import threading
-from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, pyqtSignal
+from PyQt6.QtCore import QEvent, QObject, QRect, Qt, QTimer, QUrl, QSignalBlocker, pyqtSignal
from PyQt6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QShortcut
from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractSpinBox, QCheckBox, QComboBox, QDialog,
QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit,
- QPushButton, QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
+ QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
from . import __version__
@@ -32,6 +32,7 @@ from . import paste
from . import update
from .filetranscribe import FileTranscriber
from .i18n import t
+from . import theme
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
LANGUAGES = [
@@ -382,6 +383,8 @@ class LocalModelBox(QGroupBox):
layout.setContentsMargins(0, 0, 0, 0)
for index, widget in enumerate(widgets):
layout.addWidget(widget, 1 if index == 0 else 0)
+ if widgets and all(isinstance(widget, QPushButton) for widget in widgets):
+ layout.addStretch()
holder = QWidget()
holder.setLayout(layout)
return holder
@@ -397,13 +400,17 @@ class LocalModelBox(QGroupBox):
def load(self, model, repo=""):
"""Show what is stored. What else is on offer is asked for on the way up.
- Nothing is fetched here: building the settings window is not the same as
- opening it, and a list nobody is looking at is not worth a request. What
- is already on this disk is shown straight away either way.
+ Hidden boxes defer fetching until shown. A catalog already fetched for
+ this repository survives Apply; a visible box on a new repository
+ refreshes immediately. Installed files are available in either case.
"""
+ target_repo = repo or (ggml.suggested_llm()[0] if self._repos is not None else "")
+ reuse = self._answered and self._chosen_in == target_repo and self.repository() == target_repo
+ items = self._current_items() if reuse else []
+ self._later.stop()
self._wanted = model
- self._pending = True
- self._answered = False
+ self._pending = not reuse
+ self._answered = reuse
self._show_program()
self._chosen_in = ""
if self._repos is not None:
@@ -413,7 +420,10 @@ class LocalModelBox(QGroupBox):
self.repo.setCurrentText(self._chosen_in)
self.repo.blockSignals(False)
self._fill_repos_box(suggested)
- self._fill_models([])
+ self._fill_models(items)
+ if self._pending and self.isVisible():
+ self._pending = False
+ self._fetch_models(self.repository())
def showEvent(self, event):
super().showEvent(event)
@@ -793,6 +803,10 @@ class LocalModelBox(QGroupBox):
def _fill_models_from_current(self):
"""Redraw the rows without asking anybody anything again."""
+ self._wanted = self.selected()
+ self._fill_models(self._current_items())
+
+ def _current_items(self):
# By name, because the recommended model has a row of its own at the
# top as well as one in its group, and reading the rows back twice
# would double it in the list every time a download finished.
@@ -802,8 +816,7 @@ class LocalModelBox(QGroupBox):
if item is not None and item.name not in seen:
seen.add(item.name)
items.append(item)
- self._wanted = self.selected()
- self._fill_models(items)
+ return items
def _delete(self):
name = self.selected()
@@ -920,6 +933,7 @@ class SettingsWindow(QDialog):
self._release_url = update.RELEASES_PAGE
self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings"))
+ theme.apply(self, conf["theme"])
# One for the whole window, parented to it so it outlives the boxes it
# watches and goes when they do.
@@ -930,23 +944,46 @@ class SettingsWindow(QDialog):
tabs.addTab(self._scrolled(self._display_tab()), t("Display"))
self.api_tab_index = tabs.addTab(
self._scrolled(self._api_tab()), t("API and models"))
- tabs.addTab(self._scrolled(self._prompt_tab()), t("Cleanup rules"))
+ tabs.addTab(self._scrolled(self._prompt_tab()), t("Text editing and dictionary"))
tabs.addTab(self._scrolled(self._assistant_tab()), t("Agent"))
tabs.addTab(self._scrolled(self._meeting_tab()), t("Meeting"))
- tabs.addTab(self._scrolled(self._minutes_tab()), t("Minutes"))
- tabs.addTab(self._scrolled(self._file_tab()), t("Audio file"))
tabs.addTab(self._scrolled(self._shortcut_tab()), t("Shortcuts"))
- tabs.addTab(self._scrolled(self._history_tab()), t("History"))
+ self.task_pages = {
+ "file": self._scrolled(self._file_tab()),
+ "minutes": self._scrolled(self._minutes_tab()),
+ "history": self._scrolled(self._history_tab()),
+ }
+ for page in self.task_pages.values():
+ page.setParent(self)
+ page.hide()
+ retention = QGroupBox(t("History"))
+ retention_form = QFormLayout(retention)
+ retention_form.addRow(t("Keep at most"), self.history_limit)
+ general_layout = tabs.widget(0).widget().layout()
+ general_layout.addRow(retention)
+ tabs.tabBar().hide()
+ self.categories = QComboBox()
+ self.categories.setAccessibleName(t("Settings category"))
+ for index in range(tabs.count()):
+ self.categories.addItem(tabs.tabText(index))
+ self.categories.currentIndexChanged.connect(tabs.setCurrentIndex)
+ tabs.currentChanged.connect(self.categories.setCurrentIndex)
# Save keeps the window open, so the window is closed with the titlebar
# cross (or Escape) instead. A "Cancel" next to it would be a lie: the
# settings are already on disk by then.
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save)
- buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Save"))
+ buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Apply changes"))
+ discard = buttons.addButton(t("Discard changes"), QDialogButtonBox.ButtonRole.ResetRole)
+ discard.clicked.connect(self._discard_changes)
+ self.dirty_label = QLabel("")
+ self.dirty_label.setObjectName("muted")
buttons.accepted.connect(self._save)
layout = QVBoxLayout(self)
+ layout.addWidget(self.categories)
layout.addWidget(tabs)
+ layout.addWidget(self.dirty_label)
layout.addWidget(buttons)
self._size_to_screen(680, 640)
@@ -967,6 +1004,26 @@ class SettingsWindow(QDialog):
self.meetings.finished.connect(self._on_minutes_finished)
self.meetings.failed.connect(self._on_minutes_failed)
self._load()
+ for button in self.findChildren(QPushButton):
+ button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+ compact = self.tabs.findChildren(QSpinBox) + [
+ self.theme_choice,
+ self.ui_language, self.language, self.paste_shortcut, self.corner,
+ self.cleanup_reasoning, self.local_llm_reasoning,
+ self.assistant_reasoning, self.meeting_reasoning,
+ ]
+ for box in compact:
+ box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+ self._saved_form = self._form_values()
+ for box in self.tabs.findChildren((QLineEdit, QComboBox, QCheckBox, QSpinBox, QPlainTextEdit)):
+ if isinstance(box, (QLineEdit, QPlainTextEdit)):
+ box.textChanged.connect(self._show_dirty)
+ elif isinstance(box, QComboBox):
+ box.currentTextChanged.connect(self._show_dirty)
+ elif isinstance(box, QCheckBox):
+ box.toggled.connect(self._show_dirty)
+ else:
+ box.valueChanged.connect(self._show_dirty)
self._load_codex_models()
self._load_agy_models()
self._load_hosted_models()
@@ -987,6 +1044,64 @@ class SettingsWindow(QDialog):
self._local_state_timer.timeout.connect(self._show_local_state)
self._show_local_state()
+ def _form_values(self):
+ values = []
+ for box in self.tabs.findChildren((QLineEdit, QComboBox, QCheckBox, QSpinBox, QPlainTextEdit)):
+ if isinstance(box, QPlainTextEdit):
+ if not box.isReadOnly():
+ values.append(box.toPlainText())
+ elif isinstance(box, QLineEdit):
+ values.append(box.text())
+ elif isinstance(box, QComboBox):
+ value = box.currentData()
+ values.append(box.currentText() if value is None else value)
+ elif isinstance(box, QCheckBox):
+ values.append(box.isChecked())
+ else:
+ values.append(box.value())
+ models = dict(self._models)
+ provider = self.transcribe_provider.currentData()
+ if provider in models:
+ models[provider] = self.transcribe_model.currentText().strip()
+ values.append(models)
+ return values
+
+ def refresh_configuration(self):
+ """Reload clean forms while keeping unsaved edits for a later merge."""
+ if self._form_values() == self._saved_form:
+ self._load()
+ self._saved_form = self._form_values()
+ self._show_dirty()
+
+ def refresh_sources(self):
+ """Discover new devices without replacing an in-progress selection."""
+ self._sources = audio.list_sources()
+ monitors = audio.list_monitors()
+ for box, title, sources in (
+ (self.mic, "Default microphone", self._sources),
+ (self.meeting_mic, "Same as dictation", self._sources),
+ (self.meeting_system, "Current output", monitors),
+ ):
+ selected = box.currentData() or ""
+ with QSignalBlocker(box):
+ box.clear()
+ box.addItem(t(title), "")
+ for name, description in sources:
+ box.addItem(description, name)
+ if selected and box.findData(selected) < 0:
+ box.addItem(t("{name} (not connected)", name=selected), selected)
+ self._select_data(box, selected)
+ self._show_dirty()
+
+ def _show_dirty(self, *_):
+ dirty = self._form_values() != getattr(self, "_saved_form", [])
+ self.dirty_label.setText(t("Unsaved changes") if dirty else "")
+
+ def _discard_changes(self):
+ self._load()
+ self._saved_form = self._form_values()
+ self._show_dirty()
+
def showEvent(self, event):
super().showEvent(event)
self._show_local_state()
@@ -1036,6 +1151,7 @@ class SettingsWindow(QDialog):
# that height on as the window's minimum, and a tall one (the API tab
# is the tallest, and taller still under a large interface font) then
# pushes Save off the bottom of the screen with no way to shrink back.
+ page.setMaximumWidth(680)
area = QScrollArea()
area.setWidgetResizable(True)
area.setFrameShape(QScrollArea.Shape.NoFrame)
@@ -1161,6 +1277,13 @@ class SettingsWindow(QDialog):
page = QWidget()
form = QFormLayout(page)
+ self.theme_choice = QComboBox()
+ for name, label in theme.NAMES.items():
+ self.theme_choice.addItem(t(label), name)
+ self.theme_choice.currentIndexChanged.connect(
+ lambda: theme.apply(self, self.theme_choice.currentData()))
+ form.addRow(t("Theme"), self.theme_choice)
+
self.indicator_screen = QComboBox()
# The active screen rather than the pointer, for the reason in
# overlay._compositor_screen: it is what a compositor will answer for,
@@ -1477,6 +1600,7 @@ class SettingsWindow(QDialog):
"glossary, so it can repair the ones that still come out wrong."))
hint.setWordWrap(True)
layout.addWidget(hint)
+ layout.addWidget(QLabel(t("Dictionary")))
self.transcribe_prompt = QPlainTextEdit()
self.transcribe_prompt.setMaximumHeight(90)
layout.addWidget(self.transcribe_prompt)
@@ -1828,7 +1952,7 @@ class SettingsWindow(QDialog):
self.minutes_list = QListWidget()
self.minutes_list.setWordWrap(True)
- self.minutes_list.setMaximumHeight(170)
+ self.minutes_list.setMaximumHeight(110)
self.minutes_list.currentItemChanged.connect(self._show_minutes)
layout.addWidget(self.minutes_list)
@@ -1839,6 +1963,7 @@ class SettingsWindow(QDialog):
self.minutes_view = QPlainTextEdit()
self.minutes_view.setReadOnly(True)
self.minutes_view.setPlaceholderText(t("Pick a meeting to read it."))
+ self.minutes_view.setMinimumHeight(120)
layout.addWidget(self.minutes_view, 1)
copy = QPushButton(t("Copy"))
@@ -1861,11 +1986,14 @@ class SettingsWindow(QDialog):
row = QHBoxLayout()
row.addWidget(copy)
row.addWidget(self.minutes_retry)
- row.addStretch(1)
- row.addWidget(folder)
- row.addWidget(delete)
- row.addWidget(reload_)
+ row.addStretch()
layout.addLayout(row)
+ more = QHBoxLayout()
+ more.addWidget(folder)
+ more.addWidget(delete)
+ more.addWidget(reload_)
+ more.addStretch()
+ layout.addLayout(more)
return page
def _file_tab(self):
@@ -2053,11 +2181,6 @@ class SettingsWindow(QDialog):
"Once the history passes this many entries, the oldest one is dropped "
"every time a new one arrives. Set it to 0 to keep everything."
))
- limit_row = QHBoxLayout()
- limit_row.addWidget(QLabel(t("Keep at most")))
- limit_row.addWidget(self.history_limit)
- limit_row.addStretch(1)
- layout.addLayout(limit_row)
copy = QPushButton(t("Copy selected to clipboard"))
copy.clicked.connect(self._copy_history)
@@ -2070,10 +2193,13 @@ class SettingsWindow(QDialog):
row = QHBoxLayout()
row.addWidget(copy)
row.addWidget(delete)
- row.addStretch(1)
- row.addWidget(clear)
- row.addWidget(reload_)
+ row.addStretch()
layout.addLayout(row)
+ more = QHBoxLayout()
+ more.addWidget(clear)
+ more.addWidget(reload_)
+ more.addStretch()
+ layout.addLayout(more)
return page
@staticmethod
@@ -2167,6 +2293,8 @@ class SettingsWindow(QDialog):
layout.setContentsMargins(0, 0, 0, 0)
for index, widget in enumerate(widgets):
layout.addWidget(widget, 1 if index == 0 else 0)
+ if widgets and all(isinstance(widget, QPushButton) for widget in widgets):
+ layout.addStretch()
holder = QWidget()
holder.setLayout(layout)
return holder
@@ -2176,7 +2304,9 @@ class SettingsWindow(QDialog):
def _load(self):
conf = self.conf
self._select_data(self.ui_language, conf["ui_language"])
- self._select_data(self.mic, conf["mic_target"])
+ self._select_data(self.theme_choice, conf["theme"])
+ theme.apply(self, self.theme_choice.currentData())
+ self._select_source(self.mic, conf["mic_target"])
self._select_data(self.language, conf["language"])
self.auto_paste.setChecked(conf["auto_paste"])
self.paste_shortcut.setCurrentText(conf["paste_shortcut"])
@@ -2269,8 +2399,8 @@ class SettingsWindow(QDialog):
conf["assistant_prompt"] or self._loaded_defaults["assistant"]
)
- self._select_data(self.meeting_mic, conf["meeting_mic_target"])
- self._select_data(self.meeting_system, conf["meeting_system_target"])
+ self._select_source(self.meeting_mic, conf["meeting_mic_target"])
+ self._select_source(self.meeting_system, conf["meeting_system_target"])
self.meeting_self_name.setText(conf["meeting_self_name"])
self.meeting_other_name.setText(conf["meeting_other_name"])
self.meeting_participants.setPlainText(conf["meeting_participants"])
@@ -2285,9 +2415,11 @@ class SettingsWindow(QDialog):
conf["meeting_prompt"] or self._loaded_defaults["meeting"]
)
- self.file_timestamps.setChecked(conf["file_timestamps"])
- self.file_cleanup.setChecked(conf["file_cleanup"])
- self.file_path = ""
+ with QSignalBlocker(self.file_timestamps), QSignalBlocker(self.file_cleanup):
+ self.file_timestamps.setChecked(conf["file_timestamps"])
+ self.file_cleanup.setChecked(conf["file_cleanup"])
+ if not hasattr(self, "file_path"):
+ self.file_path = ""
for which, (box, _status, _missing) in self._shortcut_rows.items():
box.setCurrentText(conf[hotkey.SHORTCUTS[which].setting])
@@ -2300,10 +2432,13 @@ class SettingsWindow(QDialog):
self._refresh_assistant_status()
self._load_history()
self._load_minutes()
+ self._loaded_config = dict(conf.data)
def _save(self):
conf = self.conf
+ before = dict(conf.data)
conf["ui_language"] = self.ui_language.currentData() or "auto"
+ conf["theme"] = self.theme_choice.currentData() or theme.DEFAULT
conf["mic_target"] = self.mic.currentData() or ""
conf["language"] = self.language.currentData() or "auto"
conf["auto_paste"] = self.auto_paste.isChecked()
@@ -2450,12 +2585,18 @@ class SettingsWindow(QDialog):
or hotkey.default_combo(which))
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf["history_limit"] = self.history_limit.value()
+ # A retained form may predate a CLI reload. Only its edits take priority;
+ # unchanged fields keep the current runtime value.
+ for key, value in before.items():
+ if conf.data.get(key) == self._loaded_config.get(key):
+ conf.data[key] = value
try:
conf.save()
except OSError as exc:
# An antivirus or a sync tool holding the file for a beat is a
# message, not an exit: an exception out of a Qt slot takes the
# whole application down.
+ conf.data = before
QMessageBox.warning(self, "Dikte",
t("Could not save the settings: {error}",
error=exc))
@@ -2465,7 +2606,9 @@ class SettingsWindow(QDialog):
cfg.trim_history(conf["history_limit"])
except OSError as exc:
print(f"dikte: could not trim the history ({exc})")
- self._load_history() # the trim may just have dropped rows from the list
+ self._load()
+ self._saved_form = self._form_values()
+ self._show_dirty()
self.applied.emit()
# conf.save() has switched the language t() speaks, so the message box
# already answers in the new one; the labels around it were translated
@@ -2489,6 +2632,12 @@ class SettingsWindow(QDialog):
or self.local_whisper._downloading
or self.local_llm._downloading)
+ @staticmethod
+ def _select_source(combo, value):
+ if value and combo.findData(value) < 0:
+ combo.addItem(t("{name} (not connected)", name=value), value)
+ SettingsWindow._select_data(combo, value)
+
@staticmethod
def _select_data(combo, value):
index = combo.findData(value)
diff --git a/dikte/theme.py b/dikte/theme.py
new file mode 100644
index 0000000..1939158
--- /dev/null
+++ b/dikte/theme.py
@@ -0,0 +1,119 @@
+"""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#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-right: 28px; min-height: 18px; }
+QSpinBox::up-button, QSpinBox::down-button {
+ subcontrol-origin: border; width: 26px; 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; }
+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; }
+"""
+
+
+def stylesheet(name=DEFAULT):
+ colors = palette(name)
+ result = _STYLE.replace("@arrow_suffix", "-light" if name == "light" else "")
+ for role, color in sorted(colors.items(), key=lambda item: -len(item[0])):
+ result = result.replace("@" + role, color)
+ return result.replace("__ICONS__", (Path(__file__).parent / "icons").as_posix())
+
+
+def apply(widget, name=DEFAULT):
+ colors = palette(name)
+ native = QPalette(widget.palette())
+ for role, color in (
+ (QPalette.ColorRole.Window, "base"), (QPalette.ColorRole.Base, "surface"),
+ (QPalette.ColorRole.AlternateBase, "hover"), (QPalette.ColorRole.Button, "surface"),
+ (QPalette.ColorRole.WindowText, "text"), (QPalette.ColorRole.Text, "text"),
+ (QPalette.ColorRole.ButtonText, "text"), (QPalette.ColorRole.PlaceholderText, "muted"),
+ (QPalette.ColorRole.Highlight, "accent"), (QPalette.ColorRole.HighlightedText, "accent_text"),
+ ):
+ native.setColor(role, QColor(colors[color]))
+ widget.setPalette(native)
+ widget.setStyleSheet(stylesheet(name))
diff --git a/docs/home.webp b/docs/home.webp
new file mode 100644
index 0000000..a7b1051
Binary files /dev/null and b/docs/home.webp differ
diff --git a/docs/settings-agent.webp b/docs/settings-agent.webp
index d693658..abfa761 100644
Binary files a/docs/settings-agent.webp and b/docs/settings-agent.webp differ
diff --git a/docs/settings-api.webp b/docs/settings-api.webp
index 142a435..3495ad2 100644
Binary files a/docs/settings-api.webp and b/docs/settings-api.webp differ
diff --git a/docs/settings-audio-file.webp b/docs/settings-audio-file.webp
index f5d77f0..6a03165 100644
Binary files a/docs/settings-audio-file.webp and b/docs/settings-audio-file.webp differ
diff --git a/docs/settings-cleanup.webp b/docs/settings-cleanup.webp
index 725b0b0..2ea2554 100644
Binary files a/docs/settings-cleanup.webp and b/docs/settings-cleanup.webp differ
diff --git a/docs/settings-display.webp b/docs/settings-display.webp
new file mode 100644
index 0000000..e22ce8a
Binary files /dev/null and b/docs/settings-display.webp differ
diff --git a/docs/settings-general.webp b/docs/settings-general.webp
index 18bcef7..cbd4787 100644
Binary files a/docs/settings-general.webp and b/docs/settings-general.webp differ
diff --git a/docs/settings-meeting.webp b/docs/settings-meeting.webp
index 13b59e4..844cf66 100644
Binary files a/docs/settings-meeting.webp and b/docs/settings-meeting.webp differ
diff --git a/docs/settings-shortcuts.webp b/docs/settings-shortcuts.webp
index 73ba99a..89d98b2 100644
Binary files a/docs/settings-shortcuts.webp and b/docs/settings-shortcuts.webp differ
diff --git a/packaging/dikte.spec b/packaging/dikte.spec
index f977777..62d8466 100644
--- a/packaging/dikte.spec
+++ b/packaging/dikte.spec
@@ -49,6 +49,7 @@ UNUSED_QT = [
analysis = Analysis( # noqa: F821
[str(ROOT / "packaging" / "entry.py")],
pathex=[str(ROOT)],
+ datas=[(str(ROOT / "dikte" / "icons" / "*.svg"), "dikte/icons")],
hiddenimports=["PyQt6.QtNetwork"],
# tkinter is the other GUI toolkit CPython ships and would be dead weight;
# dikte's own tests have no business in a build at all.
diff --git a/tests/test_cli.py b/tests/test_cli.py
index cd954f9..34257fe 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -148,8 +148,8 @@ class Parser(unittest.TestCase):
def parse(self, *argv):
return cli.build_parser().parse_args(list(argv))
- def test_no_verb_at_all_is_the_settings_window(self):
- # argparse leaves the dest as None; run() is what turns it into "".
+ def test_no_verb_uses_the_plain_gui_command(self):
+ # argparse leaves the dest as None; run() selects home.
opts = self.parse()
self.assertIsNone(opts.verb)
self.assertEqual(opts.func, cli.cmd_plain)
@@ -700,10 +700,14 @@ class WithoutAnInstance(DikteTest):
launch.assert_called_once_with("toggle")
def test_every_verb_that_opens_a_window_can_start_it(self):
- for verb in ("settings", "toggle", "ask", "meeting"):
+ for verb in ("home", "settings", "toggle", "ask", "meeting"):
with self.subTest(verb=verb):
self.assertTrue(self.run_verb([verb])[3].called)
+ def test_bare_command_opens_the_daily_workspace(self):
+ _, _, _, launch = self.run_verb([])
+ launch.assert_called_once_with("home")
+
def test_a_verb_asked_to_wait_starts_nothing(self):
"""There would be no run to wait for; the process would just be replaced."""
_, _, _, launch = self.run_verb(["toggle", "--wait"])
diff --git a/tests/test_home_ui.py b/tests/test_home_ui.py
new file mode 100644
index 0000000..6165aea
--- /dev/null
+++ b/tests/test_home_ui.py
@@ -0,0 +1,369 @@
+"""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
+
+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_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("Ses:", self.window.capture_models.text())
+
+ def test_completed_run_refreshes_workspace_without_changing_controller_state(self):
+ controller = Dikte.__new__(Dikte)
+ controller.home_messages = {}
+ controller.home_window = self.window
+ controller._waiters = {}
+ cfg.append_history({"text": "Finished"})
+ controller._settle("dictation", {"ok": True, "text": "Finished"})
+ self.assertEqual(self.window.latest_text.toPlainText(), "Finished")
+ self.assertEqual(controller.home_messages["dictation"], "Transcript ready")
diff --git a/tests/test_ui.py b/tests/test_ui.py
index 2f0cf01..95644cf 100644
--- a/tests/test_ui.py
+++ b/tests/test_ui.py
@@ -179,10 +179,11 @@ class Settings(DikteTest):
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase, False)
- def test_the_window_opens_with_every_tab_on_it(self):
+ def test_settings_keeps_configuration_and_exposes_separate_task_pages(self):
window = self.window(cfg.Config())
tabs = window.findChildren(settings_ui.QTabWidget)[0]
- self.assertEqual(tabs.count(), 10)
+ self.assertEqual(tabs.count(), 7)
+ self.assertEqual(set(window.task_pages), {"file", "minutes", "history"})
self.assertEqual(window.windowTitle(), "Dikte Settings")
def test_no_tab_can_stretch_the_window_past_a_small_screen(self):
@@ -1198,7 +1199,7 @@ class Overlay(DikteTest):
mock.patch.object(QApplication, "screenAt") as screen_at:
widget._reposition()
screen_at.assert_not_called()
- self.assertEqual(widget.pos(), QPoint(1948, 995))
+ self.assertEqual(widget.pos(), QPoint(1948, 1003))
def _screen(self, name, area):
screen = mock.Mock()
@@ -1224,7 +1225,7 @@ class Overlay(DikteTest):
mock.patch.object(QApplication, "screenAt") as screen_at:
widget._reposition()
screen_at.assert_not_called()
- self.assertEqual(widget.pos(), QPoint(1948, 995))
+ self.assertEqual(widget.pos(), QPoint(1948, 1003))
def test_the_pointer_decides_when_the_compositor_will_not_say(self):
"""Every desktop but Plasma, and Plasma while KWin is being replaced."""
@@ -1236,7 +1237,7 @@ class Overlay(DikteTest):
return_value=screens[0]) as screen_at:
widget._reposition()
screen_at.assert_called()
- self.assertEqual(widget.pos(), QPoint(28, 995))
+ self.assertEqual(widget.pos(), QPoint(28, 1003))
def _two_screens(self):
return [self._screen("DP-1", settings_ui.QRect(0, 0, 1920, 1080)),
@@ -1258,10 +1259,10 @@ class Overlay(DikteTest):
with mock.patch.object(overlay_module, "_kwin", kwin), \
mock.patch.object(QApplication, "screens", return_value=screens):
widget.show_recording()
- self.assertEqual(widget.pos(), QPoint(1948, 995))
+ self.assertEqual(widget.pos(), QPoint(1948, 1003))
kwin.call.return_value.arguments.return_value = ["DP-1"]
self._ticks_on(widget, screens, kwin)
- self.assertEqual(widget.pos(), QPoint(28, 995))
+ self.assertEqual(widget.pos(), QPoint(28, 1003))
def test_it_stays_where_it_appeared_unless_it_was_told_otherwise(self):
"""Left off, because an indicator that jumps desks mid-sentence is one
@@ -1274,7 +1275,7 @@ class Overlay(DikteTest):
widget.show_recording()
kwin.call.return_value.arguments.return_value = ["DP-1"]
self._ticks_on(widget, screens, kwin)
- self.assertEqual(widget.pos(), QPoint(1948, 995))
+ self.assertEqual(widget.pos(), QPoint(1948, 1003))
def test_a_named_screen_is_never_left_for_the_pointer(self):
"""Naming one is the whole answer; following it would undo the naming."""
@@ -1285,7 +1286,7 @@ class Overlay(DikteTest):
widget.show_recording()
self._ticks_on(widget, screens, kwin)
kwin.call.assert_not_called()
- self.assertEqual(widget.pos(), QPoint(28, 995))
+ self.assertEqual(widget.pos(), QPoint(28, 1003))
def test_the_one_on_top_goes_where_the_one_underneath_is(self):
"""Asking for itself would put the pair on two monitors, with this one
@@ -1299,8 +1300,8 @@ class Overlay(DikteTest):
kwin.call.return_value.arguments.return_value = ["DP-1"]
second = self.overlay(below=first)
second.show_busy("Asking Claude…")
- self.assertEqual(first.pos(), QPoint(1948, 995))
- self.assertEqual(second.pos(), QPoint(1948, 929))
+ self.assertEqual(first.pos(), QPoint(1948, 1003))
+ self.assertEqual(second.pos(), QPoint(1948, 945))
def test_the_compositor_is_asked_only_now_and_then(self):
"""Every tick would be thirty conversations a second about a hand
@@ -1404,6 +1405,7 @@ class LocalModels(DikteTest):
def setUp(self):
super().setUp()
+ self.enterContext(mock.patch.object(settings_ui.LocalModelBox, "_fetch_models"))
# A machine Dikte is actually installed on would otherwise answer the
# "nothing can transcribe" question from its real binary and model.
self.patch_attr(ggml, "BIN_DIR", self.path("bin"))
@@ -1687,6 +1689,25 @@ class LocalModels(DikteTest):
time.sleep(0.05)
_app.processEvents()
self.assertEqual(fetch.call_count, 1)
+
+ def test_reloading_settings_keeps_the_fetched_model_choices(self):
+ repo = "ggml-org/SmolLM3-3B-GGUF"
+ box = self.window(self.config(local_llm_repo=repo)).local_llm
+ box._on_listed([("models", [self._item("first.gguf"), self._item("second.gguf")], repo)], "")
+ box.load("first.gguf", repo)
+ self.assertGreaterEqual(box.model.findData("second.gguf"), 0)
+ self.assertEqual(box.selected(), "first.gguf")
+
+ def test_reload_before_repository_debounce_does_not_reuse_previous_catalog(self):
+ first_repo = "ggml-org/SmolLM3-3B-GGUF"
+ second_repo = "ggml-org/gemma-3-4b-it-GGUF"
+ box = self.window(self.config(local_llm_repo=first_repo)).local_llm
+ box._on_listed([("models", [self._item("first.gguf"), self._item("second.gguf")], first_repo)], "")
+ box.repo.setCurrentText(second_repo)
+ box.load("first.gguf", second_repo)
+ self.assertLess(box.model.findData("second.gguf"), 0)
+ self.assertTrue(box._pending)
+ self.assertFalse(box._answered)
def test_the_models_are_grouped_by_the_model_rather_than_by_size(self):
# Sorted by size alone, the turbo files land between the two medium
# ones, half a screen from the model they are a copy of.