Redesign desktop workspace with compact task views and themes

This commit is contained in:
2026-09-09 14:12:23 +03:00
parent 1fa5343baa
commit e5e9ae66e8
27 changed files with 1466 additions and 109 deletions
+2 -1
View File
@@ -13,11 +13,12 @@ library, 3.11 or newer, and PyQt6.
*[Türkçe README](README.tr.md)*
<p align="center">
<img src="docs/settings-general.webp" width="820" alt="Dikte settings, General tab">
<img src="docs/home.webp" width="620" alt="Dikte, sample transcript">
</p>
| | |
|---|---|
| <img src="docs/settings-general.webp" width="410" alt="General and themes"> | <img src="docs/settings-display.webp" width="410" alt="Nord, Dracula, dark, light"> |
| <img src="docs/settings-api.webp" width="410" alt="API and models"> | <img src="docs/settings-cleanup.webp" width="410" alt="Cleanup rules"> |
| <img src="docs/settings-agent.webp" width="410" alt="Agent"> | <img src="docs/settings-meeting.webp" width="410" alt="Meeting"> |
| <img src="docs/settings-audio-file.webp" width="410" alt="Audio file"> | <img src="docs/settings-shortcuts.webp" width="410" alt="Shortcuts"> |
+2 -1
View File
@@ -12,11 +12,12 @@ Python standart kütüphanesi (3.11 veya üstü) ve PyQt6.
*[English README](README.md)*
<p align="center">
<img src="docs/settings-general.webp" width="820" alt="Dikte ayarları, Genel sekmesi">
<img src="docs/home.webp" width="620" alt="Dikte, örnek metin">
</p>
| | |
|---|---|
| <img src="docs/settings-general.webp" width="410" alt="Genel ve temalar"> | <img src="docs/settings-display.webp" width="410" alt="Nord, Dracula, dark, light"> |
| <img src="docs/settings-api.webp" width="410" alt="API ve modeller"> | <img src="docs/settings-cleanup.webp" width="410" alt="Temizleme kuralları"> |
| <img src="docs/settings-agent.webp" width="410" alt="Ajan"> | <img src="docs/settings-meeting.webp" width="410" alt="Toplantı"> |
| <img src="docs/settings-audio-file.webp" width="410" alt="Ses dosyası"> | <img src="docs/settings-shortcuts.webp" width="410" alt="Kısayollar"> |
+105 -21
View File
@@ -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":
+4 -4
View File
@@ -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])
+1
View File
@@ -418,6 +418,7 @@ da senin soracağın soruya verilecek bir yanıt yok.
ve varsayımını bir yan cümlede söyle"""
DEFAULTS = {
"theme": "nord",
"ui_language": "auto", # auto | tr | en
"openai_api_key": "",
"openai_base_url": "https://api.openai.com/v1",
+539
View File
@@ -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()
+69
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path d="m3 4.5 3 3 3-3" fill="none" stroke="#526174" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 211 B

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

After

Width:  |  Height:  |  Size: 211 B

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

After

Width:  |  Height:  |  Size: 211 B

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

After

Width:  |  Height:  |  Size: 211 B

+28 -33
View File
@@ -9,33 +9,18 @@ from PyQt6.QtGui import QColor, QCursor, QFont, QPainter, QPainterPath, QPen, QF
from PyQt6.QtWidgets import QWidget, QApplication
from . 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())
+181 -32
View File
@@ -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,8 +2415,10 @@ class SettingsWindow(QDialog):
conf["meeting_prompt"] or self._loaded_defaults["meeting"]
)
with QSignalBlocker(self.file_timestamps), QSignalBlocker(self.file_cleanup):
self.file_timestamps.setChecked(conf["file_timestamps"])
self.file_cleanup.setChecked(conf["file_cleanup"])
if not hasattr(self, "file_path"):
self.file_path = ""
for which, (box, _status, _missing) in self._shortcut_rows.items():
@@ -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)
+119
View File
@@ -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))
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 78 KiB

+1
View File
@@ -49,6 +49,7 @@ UNUSED_QT = [
analysis = Analysis( # noqa: F821
[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.
+7 -3
View File
@@ -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"])
+369
View File
@@ -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")
+32 -11
View File
@@ -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.