Files
dikte/settings_ui.py
T
yusufipk 011493a9bc Ship full-resolution WebP screenshots, drop em dashes everywhere
The screenshots were downscaled to 430 px wide, which made the UI text
blurry. Restore them at native 1292 px as lossless WebP, which is also
half the size of the original PNGs (72 KB against 155 KB for the largest).

Rewrite every em dash in prose, comments, docstrings and interface strings
as ordinary punctuation.
2026-07-25 19:31:10 +07:00

592 lines
23 KiB
Python

"""Settings window."""
import os
import threading
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QGuiApplication
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog,
QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSpinBox,
QTabWidget, QVBoxLayout, QWidget,
)
import api
import audio
import config as cfg
import hotkey
from filetranscribe import FileTranscriber
from i18n import t
UI_LANGUAGES = [("Automatic (system)", "auto"), ("Turkish", "tr"), ("English", "en")]
LANGUAGES = [
("Detect automatically", "auto"), ("Turkish", "tr"), ("English", "en"),
("German", "de"), ("French", "fr"), ("Spanish", "es"), ("Arabic", "ar"),
]
CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"]
TRANSCRIBE_MODELS = ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"]
CLEANUP_MODELS = [
"google/gemini-3.5-flash-lite", "google/gemini-3.1-flash-lite",
"google/gemini-2.5-flash-lite", "anthropic/claude-haiku-4.5",
"openai/gpt-5-mini", "meta-llama/llama-3.3-70b-instruct",
]
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
AUDIO_FILTER = ("*.mp3 *.wav *.m4a *.ogg *.opus *.flac *.aac *.wma "
"*.mp4 *.mkv *.webm *.mov *.avi")
class SettingsWindow(QDialog):
applied = pyqtSignal()
_models_loaded = pyqtSignal(list, str)
_test_done = pyqtSignal(bool, str)
def __init__(self, conf, launch_command, parent=None):
super().__init__(parent)
self.conf = conf
self.launch_command = launch_command
self.transcriber = FileTranscriber(conf, self)
self.setWindowTitle(t("Dikte Settings"))
self.resize(680, 640)
tabs = QTabWidget(self)
tabs.addTab(self._general_tab(), t("General"))
tabs.addTab(self._api_tab(), t("API and models"))
tabs.addTab(self._prompt_tab(), t("Cleanup rules"))
tabs.addTab(self._file_tab(), t("Audio file"))
tabs.addTab(self._shortcut_tab(), t("Shortcut"))
tabs.addTab(self._history_tab(), t("History"))
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
)
buttons.button(QDialogButtonBox.StandardButton.Save).setText(t("Save"))
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("Cancel"))
buttons.accepted.connect(self._save)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(tabs)
layout.addWidget(buttons)
self._models_loaded.connect(self._on_models_loaded)
self._test_done.connect(self._on_test_done)
self.transcriber.progress.connect(self._on_file_progress)
self.transcriber.finished.connect(self._on_file_finished)
self.transcriber.failed.connect(self._on_file_failed)
self._load()
# ---- tabs ----------------------------------------------------------
def _general_tab(self):
page = QWidget()
form = QFormLayout(page)
self.ui_language = QComboBox()
for label, code in UI_LANGUAGES:
self.ui_language.addItem(t(label), code)
self.ui_language.setToolTip(
t("Restart Dikte for the language change to reach every window.")
)
form.addRow(t("Interface language"), self.ui_language)
self.mic = QComboBox()
self.mic.addItem(t("Default microphone"), "")
for name, desc in audio.list_sources():
self.mic.addItem(desc, name)
form.addRow(t("Microphone"), self.mic)
self.language = QComboBox()
for label, code in LANGUAGES:
self.language.addItem(t(label), code)
form.addRow(t("Speech language"), self.language)
self.auto_paste = QCheckBox(t("Paste the text into the focused window"))
form.addRow("", self.auto_paste)
self.paste_shortcut = QComboBox()
self.paste_shortcut.addItems(PASTE_SHORTCUTS)
self.paste_shortcut.setToolTip(
t("Terminals usually want ctrl+shift+v. Change this if pasting does nothing.")
)
form.addRow(t("Paste key"), self.paste_shortcut)
self.restore_clipboard = QCheckBox(t("Restore the previous clipboard after pasting"))
form.addRow("", self.restore_clipboard)
self.corner = QComboBox()
for value in CORNERS:
self.corner.addItem(t(value), value)
form.addRow(t("Indicator corner"), self.corner)
self.max_seconds = QSpinBox()
self.max_seconds.setRange(10, 3600)
self.max_seconds.setSuffix(t(" s"))
form.addRow(t("Longest recording"), self.max_seconds)
self.skip_silent = QCheckBox(t("Skip silent recordings (don't call the API)"))
form.addRow("", self.skip_silent)
self.silence_db = QSpinBox()
self.silence_db.setRange(-80, -20)
self.silence_db.setSuffix(" dB")
self.silence_db.setToolTip(t(
"Speech also has to rise {margin} dB above the recording's own noise "
"floor, so this absolute floor rarely needs touching. Lower it if quiet "
"speech gets dropped; raise it if noise still gets through.",
margin=10,
))
form.addRow(t("Silence threshold"), self.silence_db)
self.filter_hallucinations = QCheckBox(
t("Discard stock phrases models invent for near-silent audio")
)
self.filter_hallucinations.setToolTip(
t("Whisper answers silence with things like “Thanks for watching”.")
)
form.addRow("", self.filter_hallucinations)
self.keep_audio = QCheckBox(t("Keep audio files (~/.local/share/dikte/recordings)"))
form.addRow("", self.keep_audio)
return page
def _api_tab(self):
page = QWidget()
outer = QVBoxLayout(page)
oai = QGroupBox(t("OpenAI: speech to text"))
oai_form = QFormLayout(oai)
self.openai_key = QLineEdit()
self.openai_key.setEchoMode(QLineEdit.EchoMode.Password)
self.openai_key.setPlaceholderText(t("sk-… (falls back to OPENAI_API_KEY)"))
oai_form.addRow(t("API key"), self.openai_key)
self.transcribe_model = QComboBox()
self.transcribe_model.setEditable(True)
self.transcribe_model.addItems(TRANSCRIBE_MODELS)
oai_form.addRow(t("Model"), self.transcribe_model)
self.test_button = QPushButton(t("Test key"))
self.test_button.clicked.connect(self._test_openai)
self.test_label = QLabel("")
self.test_label.setWordWrap(True)
row = QHBoxLayout()
row.addWidget(self.test_button)
row.addWidget(self.test_label, 1)
oai_form.addRow("", self._wrap(row))
outer.addWidget(oai)
orr = QGroupBox(t("OpenRouter: transcript cleanup"))
orr_form = QFormLayout(orr)
self.cleanup_enabled = QCheckBox(t("Clean the transcript with a model"))
orr_form.addRow("", self.cleanup_enabled)
self.openrouter_key = QLineEdit()
self.openrouter_key.setEchoMode(QLineEdit.EchoMode.Password)
self.openrouter_key.setPlaceholderText(t("sk-or-… (falls back to OPENROUTER_API_KEY)"))
orr_form.addRow(t("API key"), self.openrouter_key)
self.cleanup_model = QComboBox()
self.cleanup_model.setEditable(True)
self.cleanup_model.addItems(CLEANUP_MODELS)
self.refresh_models = QPushButton(t("Fetch model list"))
self.refresh_models.clicked.connect(self._load_models)
model_row = QHBoxLayout()
model_row.addWidget(self.cleanup_model, 1)
model_row.addWidget(self.refresh_models)
orr_form.addRow(t("Model"), self._wrap(model_row))
self.models_label = QLabel("")
self.models_label.setWordWrap(True)
orr_form.addRow("", self.models_label)
outer.addWidget(orr)
outer.addStretch(1)
return page
def _prompt_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
intro = QLabel(t("System instruction given to the cleanup model. This is where "
"you decide how much it may touch your words."))
intro.setWordWrap(True)
layout.addWidget(intro)
self.cleanup_prompt = QPlainTextEdit()
layout.addWidget(self.cleanup_prompt, 1)
reset = QPushButton(t("Reset to default"))
reset.clicked.connect(
lambda: self.cleanup_prompt.setPlainText(cfg.default_cleanup_prompt())
)
layout.addWidget(reset, 0, Qt.AlignmentFlag.AlignRight)
hint = QLabel(t("Transcription hint (optional): names and terms you say often. "
"Helps Whisper spell them correctly."))
hint.setWordWrap(True)
layout.addWidget(hint)
self.transcribe_prompt = QPlainTextEdit()
self.transcribe_prompt.setMaximumHeight(90)
layout.addWidget(self.transcribe_prompt)
return page
def _file_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
intro = QLabel(t("Transcribe an existing audio or video file with the same models."))
intro.setWordWrap(True)
layout.addWidget(intro)
pick = QPushButton(t("Choose file…"))
pick.clicked.connect(self._choose_file)
self.file_label = QLabel(t("No file selected"))
self.file_label.setWordWrap(True)
row = QHBoxLayout()
row.addWidget(pick)
row.addWidget(self.file_label, 1)
layout.addLayout(row)
self.file_timestamps = QCheckBox(t("Add timestamps"))
self.file_timestamps.setToolTip(
t("Prefixes every segment with [mm:ss]. Uses whisper-1, the only model "
"that returns segment times.")
)
layout.addWidget(self.file_timestamps)
self.file_cleanup = QCheckBox(t("Run the cleanup model afterwards"))
layout.addWidget(self.file_cleanup)
self.file_run = QPushButton(t("Transcribe"))
self.file_run.clicked.connect(self._run_file)
self.file_stop = QPushButton(t("Stop"))
self.file_stop.clicked.connect(self.transcriber.stop)
self.file_stop.setEnabled(False)
run_row = QHBoxLayout()
run_row.addWidget(self.file_run)
run_row.addWidget(self.file_stop)
run_row.addStretch(1)
layout.addLayout(run_row)
self.file_status = QLabel("")
self.file_status.setWordWrap(True)
layout.addWidget(self.file_status)
self.file_output = QPlainTextEdit()
self.file_output.setPlaceholderText("…")
layout.addWidget(self.file_output, 1)
copy = QPushButton(t("Copy"))
copy.clicked.connect(
lambda: QGuiApplication.clipboard().setText(self.file_output.toPlainText())
)
save = QPushButton(t("Save as .txt"))
save.clicked.connect(self._save_transcript)
out_row = QHBoxLayout()
out_row.addWidget(copy)
out_row.addWidget(save)
out_row.addStretch(1)
layout.addLayout(out_row)
return page
def _shortcut_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
form = QFormLayout()
self.shortcut = QLineEdit()
self.shortcut.setPlaceholderText("Ctrl+Space")
form.addRow(t("Shortcut"), self.shortcut)
layout.addLayout(form)
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(self._install_shortcut)
remove = QPushButton(t("Remove"))
remove.clicked.connect(self._remove_shortcut)
row = QHBoxLayout()
row.addWidget(install)
row.addWidget(remove)
row.addStretch(1)
layout.addLayout(row)
self.shortcut_status = QLabel("")
self.shortcut_status.setWordWrap(True)
layout.addWidget(self.shortcut_status)
self.evdev_enabled = QCheckBox(t(
"Use the built-in listener (/dev/input), for when the KDE shortcut is "
"not active yet"
))
self.evdev_enabled.setToolTip(t(
"Works immediately, no session restart. The only difference: the key "
"combination also reaches the focused application."
))
layout.addWidget(self.evdev_enabled)
note = QLabel(t(
"KWin only reads shortcut settings at startup. After 'Install' the "
"shortcut shows up under System Settings → Shortcuts, but it will not "
"fire until you log out and back in. Until then, use the built-in listener."
))
note.setWordWrap(True)
layout.addWidget(note)
layout.addStretch(1)
return page
def _history_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
self.history = QListWidget()
self.history.setWordWrap(True)
layout.addWidget(self.history, 1)
copy = QPushButton(t("Copy selected to clipboard"))
copy.clicked.connect(self._copy_history)
reload_ = QPushButton(t("Reload"))
reload_.clicked.connect(self._load_history)
row = QHBoxLayout()
row.addWidget(copy)
row.addWidget(reload_)
row.addStretch(1)
layout.addLayout(row)
return page
@staticmethod
def _wrap(layout):
widget = QWidget()
widget.setLayout(layout)
return widget
# ---- load / save ----------------------------------------------------
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.language, conf["language"])
self.auto_paste.setChecked(conf["auto_paste"])
self.paste_shortcut.setCurrentText(conf["paste_shortcut"])
self.restore_clipboard.setChecked(conf["restore_clipboard"])
self._select_data(self.corner, conf["overlay_corner"])
self.max_seconds.setValue(conf["max_seconds"])
self.skip_silent.setChecked(conf["skip_silent"])
self.silence_db.setValue(int(conf["silence_db"]))
self.filter_hallucinations.setChecked(conf["filter_hallucinations"])
self.keep_audio.setChecked(conf["keep_audio"])
self.openai_key.setText(conf["openai_api_key"])
self.transcribe_model.setCurrentText(conf["transcribe_model"])
self.cleanup_enabled.setChecked(conf["cleanup_enabled"])
self.openrouter_key.setText(conf["openrouter_api_key"])
self.cleanup_model.setCurrentText(conf["cleanup_model"])
self.cleanup_prompt.setPlainText(conf["cleanup_prompt"] or cfg.default_cleanup_prompt())
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
self.file_timestamps.setChecked(conf["file_timestamps"])
self.file_cleanup.setChecked(conf["file_cleanup"])
self.file_path = ""
self.shortcut.setText(conf["shortcut"])
self.evdev_enabled.setChecked(conf["evdev_hotkey"])
self._refresh_shortcut_status()
self._load_history()
def _save(self):
conf = self.conf
conf["ui_language"] = self.ui_language.currentData() or "auto"
conf["mic_target"] = self.mic.currentData() or ""
conf["language"] = self.language.currentData() or "auto"
conf["auto_paste"] = self.auto_paste.isChecked()
conf["paste_shortcut"] = self.paste_shortcut.currentText().strip()
conf["restore_clipboard"] = self.restore_clipboard.isChecked()
conf["overlay_corner"] = self.corner.currentData() or "bottom-left"
conf["max_seconds"] = self.max_seconds.value()
conf["skip_silent"] = self.skip_silent.isChecked()
conf["silence_db"] = float(self.silence_db.value())
conf["filter_hallucinations"] = self.filter_hallucinations.isChecked()
conf["keep_audio"] = self.keep_audio.isChecked()
conf["openai_api_key"] = self.openai_key.text().strip()
conf["transcribe_model"] = self.transcribe_model.currentText().strip()
conf["cleanup_enabled"] = self.cleanup_enabled.isChecked()
conf["openrouter_api_key"] = self.openrouter_key.text().strip()
conf["cleanup_model"] = self.cleanup_model.currentText().strip()
# Store an empty prompt when it matches the default, so switching the
# interface language also switches the prompt language.
prompt = self.cleanup_prompt.toPlainText().strip()
conf["cleanup_prompt"] = "" if prompt == cfg.default_cleanup_prompt() else prompt
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
conf["file_timestamps"] = self.file_timestamps.isChecked()
conf["file_cleanup"] = self.file_cleanup.isChecked()
conf["shortcut"] = self.shortcut.text().strip() or "Ctrl+Space"
conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf.save()
self.applied.emit()
self.accept()
@staticmethod
def _select_data(combo, value):
index = combo.findData(value)
combo.setCurrentIndex(index if index >= 0 else 0)
# ---- api helpers -----------------------------------------------------
def _load_models(self):
self.refresh_models.setEnabled(False)
self.models_label.setText(t("Fetching model list…"))
key = self.openrouter_key.text().strip() or self.conf.openrouter_key()
def work():
try:
self._models_loaded.emit(api.openrouter_models(key), "")
except api.ApiError as exc:
self._models_loaded.emit([], str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_models_loaded(self, models, error):
self.refresh_models.setEnabled(True)
if error:
self.models_label.setText(t("Could not fetch the list: {error}", error=error))
return
current = self.cleanup_model.currentText()
self.cleanup_model.clear()
self.cleanup_model.addItems(models)
self.cleanup_model.setCurrentText(current)
self.models_label.setText(t("{count} models loaded.", count=len(models)))
def _test_openai(self):
self.test_button.setEnabled(False)
self.test_label.setText(t("Trying…"))
key = self.openai_key.text().strip() or self.conf.openai_key()
base = self.conf["openai_base_url"]
def work():
try:
models = api.openai_models(key, base)
self._test_done.emit(
True, t("Connection works. {count} audio models visible.", count=len(models))
)
except api.ApiError as exc:
self._test_done.emit(False, str(exc))
threading.Thread(target=work, daemon=True).start()
def _on_test_done(self, ok, message):
self.test_button.setEnabled(True)
self.test_label.setText(("✓ " if ok else "✗ ") + message)
# ---- audio file ------------------------------------------------------
def _choose_file(self):
start = self.conf["file_last_dir"] or os.path.expanduser("~")
path, _ = QFileDialog.getOpenFileName(
self, t("Select an audio file"), start,
f"{t('Audio and video files')} ({AUDIO_FILTER});;{t('All files')} (*)",
)
if not path:
return
self.file_path = path
self.file_label.setText(os.path.basename(path))
self.conf["file_last_dir"] = os.path.dirname(path)
def _run_file(self):
if not getattr(self, "file_path", "") or self.transcriber.busy:
return
self.file_output.clear()
self.file_run.setEnabled(False)
self.file_stop.setEnabled(True)
self.transcriber.start(
self.file_path,
self.file_timestamps.isChecked(),
self.file_cleanup.isChecked(),
)
def _on_file_progress(self, message):
self.file_status.setText(message)
if message == t("Stopped."):
self._file_idle()
def _on_file_finished(self, text):
self.file_output.setPlainText(text)
self.file_status.setText(t("Done: {chars} characters.", chars=len(text)))
self._file_idle()
def _on_file_failed(self, error):
self.file_status.setText(t("Failed: {error}", error=error))
self._file_idle()
def _file_idle(self):
self.file_run.setEnabled(True)
self.file_stop.setEnabled(False)
def _save_transcript(self):
text = self.file_output.toPlainText()
if not text:
return
base = os.path.splitext(os.path.basename(getattr(self, "file_path", "")))[0]
start = os.path.join(self.conf["file_last_dir"] or os.path.expanduser("~"),
f"{base or 'transcript'}.txt")
path, _ = QFileDialog.getSaveFileName(
self, t("Save transcript"), start, f"{t('Text files')} (*.txt)"
)
if not path:
return
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
self.file_status.setText(t("Saved: {path}", path=path))
except OSError as exc:
self.file_status.setText(t("Failed: {error}", error=exc))
# ---- shortcut --------------------------------------------------------
def _install_shortcut(self):
combo = self.shortcut.text().strip() or "Ctrl+Space"
clashes = hotkey.conflicting_shortcuts(combo)
if clashes:
answer = QMessageBox.question(
self, t("Shortcut conflict"),
t("{shortcut} is also used by:\n\n{list}\n\nInstall anyway?",
shortcut=combo, list="\n".join(clashes[:6])),
)
if answer != QMessageBox.StandardButton.Yes:
return
ok, message = hotkey.install_kde_shortcut(combo, self.launch_command)
QMessageBox.information(self, t("Shortcut"), message)
if ok:
self.conf["shortcut"] = combo
self.conf.save()
self._refresh_shortcut_status()
def _remove_shortcut(self):
hotkey.remove_kde_shortcut()
self._refresh_shortcut_status()
def _refresh_shortcut_status(self):
current = hotkey.kde_shortcut_status()
self.shortcut_status.setText(
t("Registered in KDE: {shortcut}", shortcut=current) if current
else t("No KDE shortcut installed.")
)
# ---- history ---------------------------------------------------------
def _load_history(self):
self.history.clear()
for row in reversed(cfg.read_history(200)):
text = (row.get("text") or "").replace("\n", " ")
preview = text[:110] + ("…" if len(text) > 110 else "")
header = t("{ts} ({duration} s)",
ts=row.get("ts", ""), duration=row.get("duration", 0))
item = QListWidgetItem(f"{header}\n{preview}")
item.setData(Qt.ItemDataRole.UserRole, row.get("text", ""))
self.history.addItem(item)
def _copy_history(self):
item = self.history.currentItem()
if item:
QGuiApplication.clipboard().setText(item.data(Qt.ItemDataRole.UserRole))