Merge pull request #9 from yusufipk/cancel-shortcut-and-scripts

A key for discarding a recording, plus uninstall.sh and update.sh
This commit is contained in:
Yusuf İpek
2026-08-01 19:05:28 +03:00
committed by GitHub
14 changed files with 560 additions and 230 deletions
+9 -7
View File
@@ -25,7 +25,7 @@ just the Python standard library and PyQt6.
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
systemctl --user enable --now ydotool # needed for auto-paste systemctl --user enable --now ydotool # needed for auto-paste
./install.sh # or: ./install.sh "Ctrl+Alt+Space" ./install.sh # or: ./install.sh "Meta+Space" "Meta+Shift+Space"
dikte # the settings window opens on first run dikte # the settings window opens on first run
``` ```
@@ -36,8 +36,10 @@ tools instead:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg sudo apt install pulseaudio-utils xclip xdotool ffmpeg
``` ```
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The `install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
settings window installs a GNOME or KDE global shortcut. two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and
puts all of that back, keeping the keys you chose; `./uninstall.sh` takes it away
again and leaves your settings and dictations alone unless you pass `--purge`.
Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**. Three keys go in the settings window: **OpenAI**, **Groq** and **OpenRouter**.
Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup
@@ -53,7 +55,7 @@ set next to it.
| What | How | | What | How |
| --- | --- | | --- | --- |
| Start / stop recording | `Ctrl+Space`, or click the tray icon | | Start / stop recording | `Ctrl+Space`, or click the tray icon |
| Cancel a recording | Tray menu → *Cancel recording*, or `dikte cancel` | | Discard the recording | `Ctrl+Alt+Space`, tray menu, or `dikte cancel` |
| Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` | | Speak a command to an agent | Tray menu → *Ask Claude*, or `dikte ask` |
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` | | Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
| Settings | Tray menu → *Settings*, or `dikte settings` | | Settings | Tray menu → *Settings*, or `dikte settings` |
@@ -127,11 +129,11 @@ running.
right-click to delete. right-click to delete.
- **Turkish and English interface**, following the system locale by default. - **Turkish and English interface**, following the system locale by default.
## The global shortcut needs one logout ## The global shortcuts need one logout
KWin only reads `kglobalshortcutsrc` at startup, so the shortcut `install.sh` KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh`
writes will not fire until you log out and back in. Until then, Settings → writes will not fire until you log out and back in. Until then, Settings →
Shortcut → **built-in listener** reads `/dev/input` and catches the combination Shortcuts → **built-in listener** reads `/dev/input` and catches the combination
itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches
the focused application (some editors will pop up autocomplete). The listener the focused application (some editors will pop up autocomplete). The listener
needs your user in the `input` group: `sudo usermod -aG input $USER`. needs your user in the `input` group: `sudo usermod -aG input $USER`.
+10 -7
View File
@@ -25,7 +25,7 @@ sadece Python standart kütüphanesi ve PyQt6.
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6 sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
systemctl --user enable --now ydotool # otomatik yapıştırma için systemctl --user enable --now ydotool # otomatik yapıştırma için
./install.sh # ya da: ./install.sh "Ctrl+Alt+Space" ./install.sh # ya da: ./install.sh "Meta+Space" "Meta+Shift+Space"
dikte # ilk açılışta ayarlar penceresi gelir dikte # ilk açılışta ayarlar penceresi gelir
``` ```
@@ -36,8 +36,11 @@ araçlarıyla çalışır:
sudo apt install pulseaudio-utils xclip xdotool ffmpeg sudo apt install pulseaudio-utils xclip xdotool ffmpeg
``` ```
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik `install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar. başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh`
son sürümü çeker ve bunları senin seçtiğin tuşlarla yerine koyar;
`./uninstall.sh` hepsini geri alır, `--purge` demedikçe ayarlarına ve
diktelerine dokunmaz.
Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**. Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`), Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`),
@@ -53,7 +56,7 @@ yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebili
| Ne | Nasıl | | Ne | Nasıl |
| --- | --- | | --- | --- |
| Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla | | Kaydı başlat / bitir | `Ctrl+Space`, ya da tepsi simgesine tıkla |
| Kaydı iptal et | Tepsi menüsü → *Kaydı iptal et*, ya da `dikte cancel` | | Kaydı iptal et | `Ctrl+Alt+Space`, tepsi menüsü, ya da `dikte cancel` |
| Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` | | Ajana sesle komut ver | Tepsi menüsü → *Claude'a sor*, ya da `dikte ask` |
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` | | Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` | | Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
@@ -125,11 +128,11 @@ olmasını ister.
silebilirsin. silebilirsin.
- **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler. - **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler.
## Global kısayol için bir kez oturum kapatmak gerekir ## Global kısayollar için bir kez oturum kapatmak gerekir
KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın
yazdığı kısayol oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar → yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
Kısayol → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir
(bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input` (bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input`
grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. grubunda olmasını gerektirir: `sudo usermod -aG input $USER`.
+14 -20
View File
@@ -44,14 +44,6 @@ GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel", IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-cancel",
"ask-reset", "meeting-cancel"} "ask-reset", "meeting-cancel"}
# Which desktop entry, name and setting belong to each of the three shortcuts.
SHORTCUTS = {
"toggle": (hotkey.DESKTOP_ID, "Dikte: start/stop recording", "shortcut"),
"ask": (hotkey.ASK_DESKTOP_ID, "Dikte: ask Claude Code", "assistant_shortcut"),
"meeting": (hotkey.MEETING_DESKTOP_ID, "Dikte: start/end a meeting recording",
"meeting_shortcut"),
}
_app = None _app = None
@@ -705,9 +697,9 @@ def cmd_shortcut(opts):
conf = cfg.Config() conf = cfg.Config()
if opts.shortcut == "status": if opts.shortcut == "status":
rows = {} rows = {}
for name, (desktop_id, _label, key) in SHORTCUTS.items(): for name, spec in hotkey.SHORTCUTS.items():
rows[name] = {"registered": hotkey.shortcut_status(desktop_id), rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id),
"configured": conf[key]} "configured": conf[spec.setting]}
lines = [f"{name:8} {row['registered'] or '(not installed)':16} " lines = [f"{name:8} {row['registered'] or '(not installed)':16} "
f"setting: {row['configured'] or '(none)'}" f"setting: {row['configured'] or '(none)'}"
for name, row in rows.items()] for name, row in rows.items()]
@@ -715,28 +707,29 @@ def cmd_shortcut(opts):
return out(opts, {"ok": True, "shortcuts": rows, return out(opts, {"ok": True, "shortcuts": rows,
"listener": conf["evdev_hotkey"]}, "\n".join(lines)) "listener": conf["evdev_hotkey"]}, "\n".join(lines))
desktop_id, label, key = SHORTCUTS[opts.which] spec = hotkey.SHORTCUTS[opts.which]
if opts.shortcut == "remove": if opts.shortcut == "remove":
hotkey.remove_shortcut(desktop_id) hotkey.remove_shortcut(spec.desktop_id)
return out(opts, {"ok": True, "removed": opts.which}, return out(opts, {"ok": True, "removed": opts.which},
f"Removed the {opts.which} shortcut.") f"Removed the {opts.which} shortcut.")
combo = (opts.combo or conf[key] or ("Ctrl+Space" if opts.which == "toggle" else "")).strip() combo = (opts.combo or conf[spec.setting] or spec.fallback).strip()
if not combo: if not combo:
return fail(opts, "no combination given and none stored; pass --combo", 2) return fail(opts, "no combination given and none stored; pass --combo", 2)
if hotkey.parse_shortcut(combo) == (None, None): if hotkey.parse_shortcut(combo) == (None, None):
return fail(opts, f"cannot parse that combination: {combo}", 2) return fail(opts, f"cannot parse that combination: {combo}", 2)
clashes = hotkey.conflicting_shortcuts(combo, desktop_id) clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
if clashes and not opts.force: if clashes and not opts.force:
return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. " return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. "
"Pass --force to install it anyway.", 1, conflicts=clashes) "Pass --force to install it anyway.", 1, conflicts=clashes)
ok, message = hotkey.install_shortcut( ok, message = hotkey.install_shortcut(
combo, ipc.command_for(opts.which), name=label, desktop_id=desktop_id, combo, ipc.command_for(spec.verb), name=spec.name,
desktop_id=spec.desktop_id,
) )
if not ok: if not ok:
return fail(opts, message) return fail(opts, message)
conf[key] = combo conf[spec.setting] = combo
try: try:
conf.save() conf.save()
except OSError as exc: except OSError as exc:
@@ -1001,19 +994,20 @@ def build_parser():
test.set_defaults(func=cmd_test_key) test.set_defaults(func=cmd_test_key)
leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor) leaf(subs, "doctor", "keys, programs, and what is missing").set_defaults(func=cmd_doctor)
shortcut = leaf(subs, "shortcut", "the KDE global shortcuts") shortcut = leaf(subs, "shortcut", "the desktop's global shortcuts")
inner = shortcut.add_subparsers(dest="shortcut", metavar="") inner = shortcut.add_subparsers(dest="shortcut", metavar="")
shortcut.set_defaults(func=_needs_subcommand(shortcut)) shortcut.set_defaults(func=_needs_subcommand(shortcut))
leaf(inner, "status", "what is registered").set_defaults(func=cmd_shortcut) leaf(inner, "status", "what is registered").set_defaults(func=cmd_shortcut)
install = leaf(inner, "install", "register one") install = leaf(inner, "install", "register one")
install.add_argument("which", nargs="?", default="toggle", install.add_argument("which", nargs="?", default="toggle",
choices=tuple(SHORTCUTS)) choices=tuple(hotkey.SHORTCUTS))
install.add_argument("--combo", help="e.g. Ctrl+Alt+Space") install.add_argument("--combo", help="e.g. Ctrl+Alt+Space")
install.add_argument("--force", action="store_true", install.add_argument("--force", action="store_true",
help="install it even if something else uses it") help="install it even if something else uses it")
install.set_defaults(func=cmd_shortcut) install.set_defaults(func=cmd_shortcut)
remove = leaf(inner, "remove", "unregister one") remove = leaf(inner, "remove", "unregister one")
remove.add_argument("which", nargs="?", default="toggle", choices=tuple(SHORTCUTS)) remove.add_argument("which", nargs="?", default="toggle",
choices=tuple(hotkey.SHORTCUTS))
remove.set_defaults(func=cmd_shortcut) remove.set_defaults(func=cmd_shortcut)
# --- the application -------------------------------------------------- # --- the application --------------------------------------------------
+4
View File
@@ -389,6 +389,10 @@ DEFAULTS = {
"min_voiced_seconds": 0.3, "min_voiced_seconds": 0.3,
"filter_hallucinations": True, "filter_hallucinations": True,
"shortcut": "Ctrl+Space", "shortcut": "Ctrl+Space",
# Ctrl+Alt+Space rather than Escape: the combination the recording started
# with, one modifier along. Escape belongs to whatever window has focus, and
# while you are dictating something else usually has it.
"cancel_shortcut": "Ctrl+Alt+Space",
"evdev_hotkey": False, "evdev_hotkey": False,
"overlay_corner": "bottom-left", "overlay_corner": "bottom-left",
"keep_audio": False, "keep_audio": False,
+14 -25
View File
@@ -152,8 +152,10 @@ class Dikte:
self.ask_cancel_action.setEnabled(False) self.ask_cancel_action.setEnabled(False)
self.menu.addAction(self.ask_cancel_action) self.menu.addAction(self.ask_cancel_action)
self.cancel_action = QAction(t("Cancel recording"), self.menu) self.cancel_action = QAction(t("Discard the recording"), self.menu)
self.cancel_action.triggered.connect(self.cancel) # The inner method, so that a menu click is never mistaken for the KDE
# shortcut echoing the built-in listener's press.
self.cancel_action.triggered.connect(self._cancel)
self.cancel_action.setEnabled(False) self.cancel_action.setEnabled(False)
self.menu.addAction(self.cancel_action) self.menu.addAction(self.cancel_action)
self.menu.addSeparator() self.menu.addSeparator()
@@ -306,6 +308,9 @@ class Dikte:
def toggle_meeting(self): def toggle_meeting(self):
self._external("meeting", self._toggle_meeting) self._external("meeting", self._toggle_meeting)
def cancel(self):
self._external("cancel", self._cancel)
def _external(self, name, handler): def _external(self, name, handler):
# The built-in listener sees the key press the instant it happens, so a # The built-in listener sees the key press the instant it happens, so a
# toggle arriving right behind one is the KDE shortcut catching up on # toggle arriving right behind one is the KDE shortcut catching up on
@@ -323,7 +328,8 @@ class Dikte:
if timer is None: if timer is None:
timer = self.last_evdev[name] = QElapsedTimer() timer = self.last_evdev[name] = QElapsedTimer()
timer.restart() timer.restart()
handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask} handlers = {"meeting": self._toggle_meeting, "ask": self._toggle_ask,
"cancel": self._cancel}
handlers.get(name, self._toggle)() handlers.get(name, self._toggle)()
def _retire_listener(self): def _retire_listener(self):
@@ -516,7 +522,7 @@ class Dikte:
self.ask_overlay.show_busy(t("Transcribing…")) self.ask_overlay.show_busy(t("Transcribing…"))
self.recorder.stop() self.recorder.stop()
def cancel(self): def _cancel(self):
"""Throw away whichever recording is running.""" """Throw away whichever recording is running."""
if not self.recording: if not self.recording:
return return
@@ -540,7 +546,7 @@ class Dikte:
def cancel_ask(self): def cancel_ask(self):
"""Call off the agent, whether it is still recording or already working.""" """Call off the agent, whether it is still recording or already working."""
if self.ask_state == RECORDING: if self.ask_state == RECORDING:
self.cancel() self._cancel()
elif self.ask_state == BUSY: elif self.ask_state == BUSY:
self.ask_overlay.show_busy(t("Stopping…")) self.ask_overlay.show_busy(t("Stopping…"))
self.ask_pipeline.cancel() self.ask_pipeline.cancel()
@@ -788,10 +794,7 @@ class Dikte:
def open_settings(self): def open_settings(self):
if self.settings_window is None: if self.settings_window is None:
self.settings_window = SettingsWindow( self.settings_window = SettingsWindow(self.conf, self.meetings)
self.conf, launch_command(), meeting_command(), self.meetings,
ask_command(),
)
self.settings_window.applied.connect(self._apply_settings) self.settings_window.applied.connect(self._apply_settings)
self.settings_window.finished.connect(self._settings_closed) self.settings_window.finished.connect(self._settings_closed)
self.settings_window.show() self.settings_window.show()
@@ -808,9 +811,8 @@ class Dikte:
self._build_tray() self._build_tray()
self._refresh_tray() self._refresh_tray()
if self.conf["evdev_hotkey"]: if self.conf["evdev_hotkey"]:
self.evdev.start({"toggle": self.conf["shortcut"], self.evdev.start({name: self.conf[spec.setting]
"ask": self.conf["assistant_shortcut"], for name, spec in hotkey.SHORTCUTS.items()})
"meeting": self.conf["meeting_shortcut"]})
else: else:
self.evdev.stop() self.evdev.stop()
@@ -849,19 +851,6 @@ def _clock(seconds):
else f"{minutes}:{secs:02d}") else f"{minutes}:{secs:02d}")
def launch_command():
"""The command the KDE shortcut will run."""
return ipc.command_for("toggle")
def meeting_command():
return ipc.command_for("meeting")
def ask_command():
return ipc.command_for("ask")
def main(): def main():
argv = sys.argv[1:] argv = sys.argv[1:]
# Anything typed at a terminal is the command line's business, including # Anything typed at a terminal is the command line's business, including
+26 -1
View File
@@ -1,6 +1,7 @@
"""GNOME/KDE global-shortcut installation plus a built-in evdev listener.""" """GNOME/KDE global-shortcut installation plus a built-in evdev listener."""
import ast import ast
import collections
import glob import glob
import os import os
import pathlib import pathlib
@@ -16,6 +17,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
from i18n import t from i18n import t
DESKTOP_ID = "dikte-toggle.desktop" DESKTOP_ID = "dikte-toggle.desktop"
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
MEETING_DESKTOP_ID = "dikte-meeting.desktop" MEETING_DESKTOP_ID = "dikte-meeting.desktop"
ASK_DESKTOP_ID = "dikte-ask.desktop" ASK_DESKTOP_ID = "dikte-ask.desktop"
APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications" APPLICATIONS_DIR = pathlib.Path.home() / ".local/share/applications"
@@ -24,6 +26,25 @@ SHORTCUTS_FILE = pathlib.Path.home() / ".config/kglobalshortcutsrc"
GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys" GNOME_MEDIA_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys"
GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding" GNOME_BINDING_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding"
Shortcut = collections.namedtuple("Shortcut", "verb desktop_id name setting fallback")
# Every global shortcut in one place, because there are four of them and the
# command line, the settings window and the installer each used to carry their
# own copy of the list. `fallback` is what to register when the setting is
# empty: only the toggle has one, since it is the key the application is
# unusable without.
SHORTCUTS = {
"toggle": Shortcut("toggle", DESKTOP_ID, "Dikte: start/stop recording",
"shortcut", "Ctrl+Space"),
"cancel": Shortcut("cancel", CANCEL_DESKTOP_ID, "Dikte: discard the recording",
"cancel_shortcut", ""),
"ask": Shortcut("ask", ASK_DESKTOP_ID, "Dikte: ask Claude Code",
"assistant_shortcut", ""),
"meeting": Shortcut("meeting", MEETING_DESKTOP_ID,
"Dikte: start/end a meeting recording",
"meeting_shortcut", ""),
}
# --- evdev key codes (linux/input-event-codes.h) -------------------------- # --- evdev key codes (linux/input-event-codes.h) --------------------------
EV_KEY = 0x01 EV_KEY = 0x01
@@ -371,10 +392,14 @@ def remove_kde_shortcut(desktop_id=DESKTOP_ID):
(APPLICATIONS_DIR / desktop_id).unlink(missing_ok=True) (APPLICATIONS_DIR / desktop_id).unlink(missing_ok=True)
except OSError: except OSError:
pass pass
# kwriteconfig6 deletes keys rather than groups, so both of the ones KDE
# keeps in there go and the empty group is left behind harmlessly.
for key in ("_launch", "_k_friendly_name"):
try: try:
subprocess.run( subprocess.run(
["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc", ["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc",
"--group", "services", "--group", desktop_id, "--key", "_launch", "--delete"], "--group", "services", "--group", desktop_id,
"--key", key, "--delete"],
capture_output=True, timeout=10, capture_output=True, timeout=10,
) )
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError):
+9 -1
View File
@@ -56,7 +56,7 @@ TR = {
"Start recording": "Kaydı başlat", "Start recording": "Kaydı başlat",
"Stop and transcribe": "Kaydı bitir ve yaz", "Stop and transcribe": "Kaydı bitir ve yaz",
"Working…": "İşleniyor…", "Working…": "İşleniyor…",
"Cancel recording": "Kaydı iptal et", "Discard the recording": "Kaydı iptal et",
"Settings…": "Ayarlar…", "Settings…": "Ayarlar…",
"Restart": "Yeniden başlat", "Restart": "Yeniden başlat",
"Quit": "Çık", "Quit": "Çık",
@@ -133,6 +133,7 @@ TR = {
"Cleanup rules": "Temizleme kuralları", "Cleanup rules": "Temizleme kuralları",
"Audio file": "Ses dosyası", "Audio file": "Ses dosyası",
"Shortcut": "Kısayol", "Shortcut": "Kısayol",
"Shortcuts": "Kısayollar",
"History": "Geçmiş", "History": "Geçmiş",
"Save": "Kaydet", "Save": "Kaydet",
"Saved successfully.": "Başarıyla kaydedildi.", "Saved successfully.": "Başarıyla kaydedildi.",
@@ -282,6 +283,13 @@ TR = {
"Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.", "Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.",
"No global shortcut installed. The tray menu asks it too.": "No global shortcut installed. The tray menu asks it too.":
"Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.", "Global kısayol kurulu değil. Tepsi menüsünden de soru sorulabilir.",
"No global shortcut installed. The tray menu discards it too.":
"Global kısayol kurulu değil. Kayıt tepsi menüsünden de iptal edilebilir.",
"Start and stop": "Başlat ve bitir",
"Throws the recording away without transcribing it. Works on a dictation "
"and on a command for the agent alike, whichever is running.":
"Kaydı yazıya dökmeden atar. Hangisi çalışıyorsa ona işler: dikteye de, "
"ajana verilen komuta da.",
"Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}", "Shortcut saved: {shortcut}": "Kısayol kaydedildi: {shortcut}",
"Could not register the GNOME shortcut: {error}": "Could not register the GNOME shortcut: {error}":
"GNOME kısayolu kaydedilemedi: {error}", "GNOME kısayolu kaydedilemedi: {error}",
+39 -23
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Dikte installer: dependency check, launchers, KDE shortcut. # Dikte installer: dependency check, launchers, global shortcuts.
set -euo pipefail set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -8,6 +8,9 @@ BIN_DIR="$HOME/.local/bin"
APP_DIR="$HOME/.local/share/applications" APP_DIR="$HOME/.local/share/applications"
AUTOSTART_DIR="$HOME/.config/autostart" AUTOSTART_DIR="$HOME/.config/autostart"
SHORTCUT="${1:-Ctrl+Space}" SHORTCUT="${1:-Ctrl+Space}"
# Without the colon, so that a second argument given as "" stays empty. That is
# how update.sh says "this one was turned off", as against not saying anything.
CANCEL_SHORTCUT="${2-Ctrl+Alt+Space}"
say() { printf ' %s\n' "$1"; } say() { printf ' %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
@@ -88,30 +91,43 @@ StartupNotify=false
EOF EOF
ok "Will start automatically on login" ok "Will start automatically on login"
# 4. KDE global shortcut --------------------------------------------------- # 4. Global shortcuts ------------------------------------------------------
cat > "$APP_DIR/dikte-toggle.desktop" <<EOF # Two of them: one to start and stop, one to throw the recording away. The
[Desktop Entry] # second is worth a key of its own because stopping is the step there is no
Exec=$PY $DIR/dikte.py toggle # taking back, being what sends the audio off to be transcribed.
Name=Dikte: start/stop recording #
NoDisplay=true # Dikte registers them rather than this script writing the files itself: it
StartupNotify=false # knows which desktop it is on, and it stores the combination in the settings
Type=Application # as well, which is where the built-in listener reads it from. A key written
X-KDE-GlobalAccel-CommandShortcut=true # to only one of the two places is a key that half works.
EOF if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then
warn "Both arguments are $SHORTCUT, so the discard key was left out."
say "Pass two different combinations, or set it in Settings → Shortcuts."
CANCEL_SHORTCUT=""
fi
if [[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* || "${XDG_CURRENT_DESKTOP:-}" == *gnome* ]]; then register() { # which combination label
ok "GNOME detected" if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then
say "Open Dikte Settings > Shortcut to install the global shortcut." ok "$3: $2"
elif command -v kwriteconfig6 >/dev/null; then else
kwriteconfig6 --notify --file kglobalshortcutsrc \ # One line: the rest of what it has to say about KWin is printed below.
--group services --group dikte-toggle.desktop \ warn "${out%%$'\n'*}"
--key _launch "$SHORTCUT" fi
ok "KDE shortcut registered: $SHORTCUT" }
warn "KWin only reads this at startup, so the shortcut goes live after your"
say "next login. Until then open Settings → Shortcut and turn on the" if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
say "built-in listener to use it right away." register toggle "$SHORTCUT" "Start and stop"
if [[ -n "$CANCEL_SHORTCUT" ]]; then
register cancel "$CANCEL_SHORTCUT" "Discard the recording"
fi
if [[ "${XDG_CURRENT_DESKTOP:-}" != *[Gg][Nn][Oo][Mm][Ee]* ]]; then
warn "KWin only reads these at startup, so they go live after your next"
say "login. Until then open Settings → Shortcuts and turn on the"
say "built-in listener to use them right away."
fi
else else
warn "No supported shortcut manager found. Add the shortcut in desktop settings." warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:"
say "dikte shortcut install toggle --combo '$SHORTCUT'"
fi fi
echo echo
+84 -138
View File
@@ -19,6 +19,7 @@ import audio
import config as cfg import config as cfg
import filetranscribe import filetranscribe
import hotkey import hotkey
import ipc
import meeting import meeting
from filetranscribe import FileTranscriber from filetranscribe import FileTranscriber
from i18n import t from i18n import t
@@ -92,9 +93,9 @@ REASONING_LEVELS = [
("Very high", "xhigh"), ("Maximum", "max"), ("Very high", "xhigh"), ("Maximum", "max"),
] ]
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"] PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
# Offered for all three global shortcuts, which keeps them one kind of field # Offered for every global shortcut, which keeps them one kind of field rather
# rather than three. The boxes stay editable: this is a shortlist of # than four. The boxes stay editable: this is a shortlist of combinations that
# combinations that are usually free, not the set of ones that work. # are usually free, not the set of ones that work.
SHORTCUTS = [ SHORTCUTS = [
"Ctrl+Space", "Ctrl+Alt+Space", "Ctrl+Shift+Space", "Meta+Space", "Ctrl+Space", "Ctrl+Alt+Space", "Ctrl+Shift+Space", "Meta+Space",
"Ctrl+Alt+A", "Ctrl+Alt+D", "Ctrl+Alt+M", "Ctrl+Alt+Q", "Ctrl+Alt+A", "Ctrl+Alt+D", "Ctrl+Alt+M", "Ctrl+Alt+Q",
@@ -113,14 +114,15 @@ class SettingsWindow(QDialog):
# Which key was tested, whether it worked, and what to write under it. # Which key was tested, whether it worked, and what to write under it.
_test_done = pyqtSignal(str, bool, str) _test_done = pyqtSignal(str, bool, str)
def __init__(self, conf, launch_command, meeting_command=None, def __init__(self, conf, meetings=None, parent=None):
meetings=None, ask_command=None, parent=None):
super().__init__(parent) super().__init__(parent)
self.conf = conf self.conf = conf
self.launch_command = launch_command
self.meeting_command = meeting_command or launch_command
self.ask_command = ask_command or launch_command
self.meetings = meetings self.meetings = meetings
# Filled in by _shortcut_row as the tabs are built: which combination
# box, status label and "nothing installed" line belong to each of the
# global shortcuts. One dictionary is what lets install, remove and the
# status line be written once instead of once per key.
self._shortcut_rows = {}
# Each provider keeps its own transcription model, so switching the # Each provider keeps its own transcription model, so switching the
# provider back and forth never overwrites the other one's. # provider back and forth never overwrites the other one's.
self._models = dict.fromkeys(cfg.TRANSCRIBERS, "") self._models = dict.fromkeys(cfg.TRANSCRIBERS, "")
@@ -139,7 +141,7 @@ class SettingsWindow(QDialog):
tabs.addTab(self._meeting_tab(), t("Meeting")) tabs.addTab(self._meeting_tab(), t("Meeting"))
tabs.addTab(self._minutes_tab(), t("Minutes")) tabs.addTab(self._minutes_tab(), t("Minutes"))
tabs.addTab(self._file_tab(), t("Audio file")) tabs.addTab(self._file_tab(), t("Audio file"))
tabs.addTab(self._shortcut_tab(), t("Shortcut")) tabs.addTab(self._shortcut_tab(), t("Shortcuts"))
tabs.addTab(self._history_tab(), t("History")) tabs.addTab(self._history_tab(), t("History"))
# Save keeps the window open, so the window is closed with the titlebar # Save keeps the window open, so the window is closed with the titlebar
@@ -360,16 +362,10 @@ class SettingsWindow(QDialog):
how = QGroupBox(t("How it runs")) how = QGroupBox(t("How it runs"))
how_form = QFormLayout(how) how_form = QFormLayout(how)
self.assistant_shortcut = self._shortcut_box(t("none")) self._shortcut_row(
install = QPushButton(t("Install as a KDE shortcut")) how_form, "ask", t("Shortcut"),
install.clicked.connect(self._install_ask_shortcut) t("No global shortcut installed. The tray menu asks it too."),
remove = QPushButton(t("Remove")) )
remove.clicked.connect(self._remove_ask_shortcut)
how_form.addRow(t("Shortcut"),
self._row(self.assistant_shortcut, install, remove))
self.assistant_shortcut_status = QLabel("")
self.assistant_shortcut_status.setWordWrap(True)
how_form.addRow(self.assistant_shortcut_status)
self.assistant_provider = QComboBox() self.assistant_provider = QComboBox()
for label, value in ASSISTANT_PROVIDERS: for label, value in ASSISTANT_PROVIDERS:
@@ -622,16 +618,10 @@ class SettingsWindow(QDialog):
)) ))
recording_form.addRow("", self.meeting_keep_audio) recording_form.addRow("", self.meeting_keep_audio)
self.meeting_shortcut = self._shortcut_box(t("none")) self._shortcut_row(
install = QPushButton(t("Install as a KDE shortcut")) recording_form, "meeting", t("Shortcut"),
install.clicked.connect(self._install_meeting_shortcut) t("No global shortcut installed. The tray menu starts a meeting too."),
remove = QPushButton(t("Remove")) )
remove.clicked.connect(self._remove_meeting_shortcut)
recording_form.addRow(t("Shortcut"),
self._row(self.meeting_shortcut, install, remove))
self.meeting_shortcut_status = QLabel("")
self.meeting_shortcut_status.setWordWrap(True)
recording_form.addRow(self.meeting_shortcut_status)
layout.addWidget(recording) layout.addWidget(recording)
prompt_label = QLabel(t("System instruction given to the minutes model.")) prompt_label = QLabel(t("System instruction given to the minutes model."))
@@ -772,25 +762,27 @@ class SettingsWindow(QDialog):
def _shortcut_tab(self): def _shortcut_tab(self):
page = QWidget() page = QWidget()
layout = QVBoxLayout(page) layout = QVBoxLayout(page)
# Both keys in one form, the way the Meeting and Agent tabs already lay
# theirs out. Two forms would give each row a label column of its own,
# and two combination boxes starting at different places read as two
# unrelated settings rather than the pair they are.
form = QFormLayout() form = QFormLayout()
self.shortcut = self._shortcut_box("Ctrl+Space") self._shortcut_row(
form.addRow(t("Shortcut"), self.shortcut) form, "toggle", t("Start and stop"),
t("No global shortcut installed."), placeholder="Ctrl+Space",
)
# Stopping is what sends the recording off to be transcribed, and that
# is the step there is no taking back. By the time the tray menu is
# open the sentence you did not mean to dictate is already on its way.
self._shortcut_row(
form, "cancel", t("Discard the recording"),
t("No global shortcut installed. The tray menu discards it too."),
tooltip=t("Throws the recording away without transcribing it. Works "
"on a dictation and on a command for the agent alike, "
"whichever is running."),
)
layout.addLayout(form) 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( self.evdev_enabled = QCheckBox(t(
"Use the built-in listener (/dev/input), for when the KDE shortcut is " "Use the built-in listener (/dev/input), for when the KDE shortcut is "
"not active yet" "not active yet"
@@ -904,6 +896,25 @@ class SettingsWindow(QDialog):
self._testers[provider] = (button, answer) self._testers[provider] = (button, answer)
return field return field
def _shortcut_row(self, form, which, label, missing, placeholder="",
tooltip=""):
"""One global shortcut: the combination, Install, Remove, and a line
saying what the desktop has registered. `missing` is what that line
says when nothing is."""
box = self._shortcut_box(placeholder or t("none"))
if tooltip:
box.setToolTip(tooltip)
install = QPushButton(t("Install as a KDE shortcut"))
install.clicked.connect(lambda: self._install_shortcut(which))
remove = QPushButton(t("Remove"))
remove.clicked.connect(lambda: self._remove_shortcut(which))
form.addRow(label, self._row(box, install, remove))
status = QLabel("")
status.setWordWrap(True)
form.addRow(status)
self._shortcut_rows[which] = (box, status, missing)
return box
@staticmethod @staticmethod
def _row(*widgets): def _row(*widgets):
"""Widgets side by side in one form row; the first one takes the space.""" """Widgets side by side in one form row; the first one takes the space."""
@@ -947,7 +958,6 @@ class SettingsWindow(QDialog):
) )
self.transcribe_prompt.setPlainText(conf["transcribe_prompt"]) self.transcribe_prompt.setPlainText(conf["transcribe_prompt"])
self.assistant_shortcut.setCurrentText(conf["assistant_shortcut"])
self._select_data(self.assistant_provider, conf["assistant_provider"]) self._select_data(self.assistant_provider, conf["assistant_provider"])
self.assistant_model.setCurrentText(conf["assistant_model"]) self.assistant_model.setCurrentText(conf["assistant_model"])
self._select_data(self.assistant_permission, conf["assistant_permission_mode"]) self._select_data(self.assistant_permission, conf["assistant_permission_mode"])
@@ -976,7 +986,6 @@ class SettingsWindow(QDialog):
self.meeting_cleanup.setChecked(conf["meeting_cleanup"]) self.meeting_cleanup.setChecked(conf["meeting_cleanup"])
self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60)) self.meeting_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60))
self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"]) self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"])
self.meeting_shortcut.setCurrentText(conf["meeting_shortcut"])
self.meeting_prompt.setPlainText( self.meeting_prompt.setPlainText(
conf["meeting_prompt"] or cfg.default_meeting_prompt() conf["meeting_prompt"] or cfg.default_meeting_prompt()
) )
@@ -985,14 +994,14 @@ class SettingsWindow(QDialog):
self.file_cleanup.setChecked(conf["file_cleanup"]) self.file_cleanup.setChecked(conf["file_cleanup"])
self.file_path = "" self.file_path = ""
self.shortcut.setCurrentText(conf["shortcut"]) for which, (box, _status, _missing) in self._shortcut_rows.items():
box.setCurrentText(conf[hotkey.SHORTCUTS[which].setting])
self.evdev_enabled.setChecked(conf["evdev_hotkey"]) self.evdev_enabled.setChecked(conf["evdev_hotkey"])
self.history_limit.setValue(max(0, int(conf["history_limit"]))) self.history_limit.setValue(max(0, int(conf["history_limit"])))
self._refresh_shortcut_status() for which in self._shortcut_rows:
self._refresh_meeting_shortcut_status() self._refresh_shortcut_status(which)
self._refresh_ask_shortcut_status()
self._refresh_assistant_status() self._refresh_assistant_status()
self._load_history() self._load_history()
self._load_minutes() self._load_minutes()
@@ -1032,7 +1041,6 @@ class SettingsWindow(QDialog):
else file_prompt) else file_prompt)
conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip() conf["transcribe_prompt"] = self.transcribe_prompt.toPlainText().strip()
conf["assistant_shortcut"] = self.assistant_shortcut.currentText().strip()
conf["assistant_provider"] = self.assistant_provider.currentData() or "claude" conf["assistant_provider"] = self.assistant_provider.currentData() or "claude"
conf["assistant_model"] = (self.assistant_model.currentText().strip() conf["assistant_model"] = (self.assistant_model.currentText().strip()
or cfg.DEFAULTS["assistant_model"]) or cfg.DEFAULTS["assistant_model"])
@@ -1072,7 +1080,6 @@ class SettingsWindow(QDialog):
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked() conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60 conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked() conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
conf["meeting_shortcut"] = self.meeting_shortcut.currentText().strip()
meeting_prompt = self.meeting_prompt.toPlainText().strip() meeting_prompt = self.meeting_prompt.toPlainText().strip()
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt() conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt()
else meeting_prompt) else meeting_prompt)
@@ -1080,7 +1087,12 @@ class SettingsWindow(QDialog):
conf["file_timestamps"] = self.file_timestamps.isChecked() conf["file_timestamps"] = self.file_timestamps.isChecked()
conf["file_cleanup"] = self.file_cleanup.isChecked() conf["file_cleanup"] = self.file_cleanup.isChecked()
conf["shortcut"] = self.shortcut.currentText().strip() or "Ctrl+Space" # Left empty, only the toggle falls back to a default: the application
# is unusable without it. The other three stay empty, which is what
# turns them off.
for which, (box, _status, _missing) in self._shortcut_rows.items():
spec = hotkey.SHORTCUTS[which]
conf[spec.setting] = box.currentText().strip() or spec.fallback
conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["evdev_hotkey"] = self.evdev_enabled.isChecked()
conf["history_limit"] = self.history_limit.value() conf["history_limit"] = self.history_limit.value()
conf.save() conf.save()
@@ -1293,45 +1305,17 @@ class SettingsWindow(QDialog):
except OSError as exc: except OSError as exc:
self.file_status.setText(t("Failed: {error}", error=exc)) self.file_status.setText(t("Failed: {error}", error=exc))
# ---- shortcut -------------------------------------------------------- # ---- shortcuts -------------------------------------------------------
def _install_shortcut(self): def _install_shortcut(self, which):
combo = self.shortcut.currentText().strip() or "Ctrl+Space" spec = hotkey.SHORTCUTS[which]
clashes = hotkey.conflicting_shortcuts(combo) box, _status, _missing = self._shortcut_rows[which]
if clashes: combo = box.currentText().strip() or spec.fallback
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_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_shortcut()
self._refresh_shortcut_status()
def _refresh_shortcut_status(self):
current = hotkey.shortcut_status()
self.shortcut_status.setText(
t("Registered in {desktop}: {shortcut}",
desktop=hotkey.desktop_name(), shortcut=current) if current
else t("No global shortcut installed.")
)
def _install_meeting_shortcut(self):
combo = self.meeting_shortcut.currentText().strip()
if not combo: if not combo:
QMessageBox.information(self, t("Shortcut"), QMessageBox.information(self, t("Shortcut"),
t("Type a key combination first.")) t("Type a key combination first."))
return return
clashes = hotkey.conflicting_shortcuts(combo, hotkey.MEETING_DESKTOP_ID) clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
if clashes: if clashes:
answer = QMessageBox.question( answer = QMessageBox.question(
self, t("Shortcut conflict"), self, t("Shortcut conflict"),
@@ -1341,64 +1325,26 @@ class SettingsWindow(QDialog):
if answer != QMessageBox.StandardButton.Yes: if answer != QMessageBox.StandardButton.Yes:
return return
ok, message = hotkey.install_shortcut( ok, message = hotkey.install_shortcut(
combo, self.meeting_command, name="Dikte: start/end a meeting recording", combo, ipc.command_for(spec.verb), name=spec.name,
desktop_id=hotkey.MEETING_DESKTOP_ID, desktop_id=spec.desktop_id,
) )
QMessageBox.information(self, t("Shortcut"), message) QMessageBox.information(self, t("Shortcut"), message)
if ok: if ok:
self.conf["meeting_shortcut"] = combo self.conf[spec.setting] = combo
self.conf.save() self.conf.save()
self._refresh_meeting_shortcut_status() self._refresh_shortcut_status(which)
def _remove_meeting_shortcut(self): def _remove_shortcut(self, which):
hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID) hotkey.remove_shortcut(hotkey.SHORTCUTS[which].desktop_id)
self._refresh_meeting_shortcut_status() self._refresh_shortcut_status(which)
def _refresh_meeting_shortcut_status(self): def _refresh_shortcut_status(self, which):
current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID) _box, status, missing = self._shortcut_rows[which]
self.meeting_shortcut_status.setText( current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id)
status.setText(
t("Registered in {desktop}: {shortcut}", t("Registered in {desktop}: {shortcut}",
desktop=hotkey.desktop_name(), shortcut=current) if current desktop=hotkey.desktop_name(), shortcut=current) if current
else t("No global shortcut installed. The tray menu starts a meeting too.") else missing
)
# ---- Claude ----------------------------------------------------------
def _install_ask_shortcut(self):
combo = self.assistant_shortcut.currentText().strip()
if not combo:
QMessageBox.information(self, t("Shortcut"),
t("Type a key combination first."))
return
clashes = hotkey.conflicting_shortcuts(combo, hotkey.ASK_DESKTOP_ID)
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_shortcut(
combo, self.ask_command, name="Dikte: ask Claude Code",
desktop_id=hotkey.ASK_DESKTOP_ID,
)
QMessageBox.information(self, t("Shortcut"), message)
if ok:
self.conf["assistant_shortcut"] = combo
self.conf.save()
self._refresh_ask_shortcut_status()
def _remove_ask_shortcut(self):
hotkey.remove_shortcut(hotkey.ASK_DESKTOP_ID)
self._refresh_ask_shortcut_status()
def _refresh_ask_shortcut_status(self):
current = hotkey.shortcut_status(hotkey.ASK_DESKTOP_ID)
self.assistant_shortcut_status.setText(
t("Registered in {desktop}: {shortcut}",
desktop=hotkey.desktop_name(), shortcut=current) if current
else t("No global shortcut installed. The tray menu asks it too.")
) )
def _assistant_provider_changed(self): def _assistant_provider_changed(self):
+17
View File
@@ -14,6 +14,7 @@ from unittest import mock
import cli import cli
import config as cfg import config as cfg
import hotkey
import ipc import ipc
from tests.support import DikteTest, fake_urlopen from tests.support import DikteTest, fake_urlopen
@@ -144,6 +145,22 @@ class Parser(unittest.TestCase):
self.assertIsNone(opts.verb) self.assertIsNone(opts.verb)
self.assertEqual(opts.func, cli.cmd_plain) self.assertEqual(opts.func, cli.cmd_plain)
def test_every_global_shortcut_runs_a_verb_that_exists(self):
"""A shortcut registers a command line; a verb the parser never heard of
is a key that does nothing at all when it is pressed."""
for name, spec in hotkey.SHORTCUTS.items():
with self.subTest(name=name):
opts = self.parse(spec.verb)
self.assertTrue(callable(opts.func))
def test_every_shortcut_can_be_installed_and_removed_by_name(self):
for name in hotkey.SHORTCUTS:
with self.subTest(name=name):
self.assertEqual(self.parse("shortcut", "install", name).which,
name)
self.assertEqual(self.parse("shortcut", "remove", name).which,
name)
def test_every_verb_is_wired_to_something(self): def test_every_verb_is_wired_to_something(self):
for verb in ("record", "toggle", "start", "stop", "cancel", "ask", for verb in ("record", "toggle", "start", "stop", "cancel", "ask",
"session", "transcribe", "meeting", "meetings", "history", "session", "transcribe", "meeting", "meetings", "history",
+40
View File
@@ -6,6 +6,7 @@ import subprocess
import unittest import unittest
from unittest import mock from unittest import mock
import config as cfg
import hotkey import hotkey
from tests.support import DikteTest, FakeCompleted, linux_only from tests.support import DikteTest, FakeCompleted, linux_only
@@ -56,6 +57,28 @@ class ParseShortcut(unittest.TestCase):
self.assertEqual(hotkey.parse_shortcut(None), (None, None)) self.assertEqual(hotkey.parse_shortcut(None), (None, None))
class Table(unittest.TestCase):
"""The one list of global shortcuts. The command line, the settings window
and install.sh read it instead of keeping a copy each, so what it has to
hold together is checked here rather than in three places."""
def test_every_shortcut_remembers_itself_in_a_real_setting(self):
for name, spec in hotkey.SHORTCUTS.items():
with self.subTest(name=name):
self.assertIn(spec.setting, cfg.DEFAULTS)
def test_no_two_share_a_desktop_entry(self):
ids = [spec.desktop_id for spec in hotkey.SHORTCUTS.values()]
self.assertEqual(len(ids), len(set(ids)))
def test_only_the_toggle_falls_back_to_a_key_of_its_own(self):
"""The rest are off until you pick one, and emptying the box is how you
turn them off again."""
self.assertEqual(hotkey.SHORTCUTS["toggle"].fallback, "Ctrl+Space")
self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items()
if spec.fallback], ["toggle"])
class ModsMatch(unittest.TestCase): class ModsMatch(unittest.TestCase):
"""The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space.""" """The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space."""
@@ -122,6 +145,23 @@ class Bindings(DikteTest):
thread.assert_called_once() thread.assert_called_once()
self.assertEqual(len(listener._bindings[57]), 2) self.assertEqual(len(listener._bindings[57]), 2)
def test_starting_and_discarding_do_not_fire_on_each_other(self):
"""The two defaults are one modifier apart on the same key code, so the
modifier set is the only thing keeping them apart."""
listener = hotkey.EvdevHotkey()
self.addCleanup(listener.stop)
with mock.patch.object(listener, "_open_devices", return_value=[99]), \
mock.patch.object(hotkey.threading, "Thread"):
listener.start({"toggle": "Ctrl+Space", "cancel": "Ctrl+Alt+Space"})
def fired(held):
return [name for mods, name in listener._bindings[57]
if hotkey.EvdevHotkey._mods_match(held, mods)]
self.assertEqual(fired({29}), ["toggle"]) # ctrl
self.assertEqual(fired({29, 56}), ["cancel"]) # ctrl + alt
self.assertEqual(fired({29, 42}), []) # ctrl + shift
@linux_only @linux_only
class Chooser(DikteTest): class Chooser(DikteTest):
+34 -1
View File
@@ -12,6 +12,7 @@ from unittest import mock
from PyQt6.QtWidgets import QApplication, QMessageBox from PyQt6.QtWidgets import QApplication, QMessageBox
import config as cfg import config as cfg
import hotkey
import overlay as overlay_module import overlay as overlay_module
import settings_ui import settings_ui
from tests.support import DikteTest, only_these_tools from tests.support import DikteTest, only_these_tools
@@ -76,6 +77,7 @@ CHANGED = {
"file_timestamps": True, "file_timestamps": True,
"file_cleanup": False, "file_cleanup": False,
"shortcut": "Ctrl+Alt+Space", "shortcut": "Ctrl+Alt+Space",
"cancel_shortcut": "Meta+Shift+Space",
"evdev_hotkey": True, "evdev_hotkey": True,
"history_limit": 50, "history_limit": 50,
} }
@@ -98,7 +100,7 @@ class Settings(DikteTest):
self.path("kglobalshortcutsrc"))) self.path("kglobalshortcutsrc")))
def window(self, conf): def window(self, conf):
window = settings_ui.SettingsWindow(conf, "dikte toggle") window = settings_ui.SettingsWindow(conf)
self.addCleanup(window.deleteLater) self.addCleanup(window.deleteLater)
self.addCleanup(window.close) self.addCleanup(window.close)
return window return window
@@ -136,6 +138,37 @@ class Settings(DikteTest):
self.assertEqual(stored["speech_margin_db"], 15.0) self.assertEqual(stored["speech_margin_db"], 15.0)
self.assertEqual(stored["openrouter_base_url"], "http://localhost:1234/v1") self.assertEqual(stored["openrouter_base_url"], "http://localhost:1234/v1")
def test_every_global_shortcut_has_a_row_of_its_own(self):
window = self.window(cfg.Config())
self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS))
def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
"""The application is unusable without the toggle, so that one box
falls back. The rest stay empty, which is how they are switched off."""
conf = cfg.Config()
window = self.window(conf)
for box, _status, _missing in window._shortcut_rows.values():
box.setCurrentText("")
window._save()
self.assertEqual(conf["shortcut"], "Ctrl+Space")
self.assertEqual(conf["cancel_shortcut"], "")
self.assertEqual(conf["assistant_shortcut"], "")
self.assertEqual(conf["meeting_shortcut"], "")
def test_installing_the_discard_key_writes_its_own_entry(self):
conf = cfg.Config()
window = self.window(conf)
window._shortcut_rows["cancel"][0].setCurrentText("Meta+Shift+Space")
with mock.patch.object(settings_ui.hotkey, "install_shortcut",
return_value=(True, "saved")) as install:
window._install_shortcut("cancel")
combo, command = install.call_args.args
self.assertEqual(combo, "Meta+Shift+Space")
self.assertTrue(command.endswith(" cancel"))
self.assertEqual(install.call_args.kwargs["desktop_id"],
hotkey.CANCEL_DESKTOP_ID)
self.assertEqual(conf["cancel_shortcut"], "Meta+Shift+Space")
def test_a_prompt_left_at_its_default_is_stored_as_empty(self): def test_a_prompt_left_at_its_default_is_stored_as_empty(self):
"""So that switching the interface language switches the prompt too.""" """So that switching the interface language switches the prompt too."""
conf = cfg.Config() conf = cfg.Config()
Executable
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# Dikte uninstaller: takes back what install.sh put down, and nothing else
# unless asked. Your settings and your dictations survive a plain run; --purge
# is the word that deletes them.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY="$(command -v python3 || true)"
USER_NAME="$(id -un)"
BIN_DIR="$HOME/.local/bin"
APP_DIR="$HOME/.local/share/applications"
AUTOSTART_DIR="$HOME/.config/autostart"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte"
PURGE=0
ASSUME_YES=0
say() { printf ' %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
gone() { printf ' \033[90m·\033[0m %s\n' "$1"; }
# "1 dictation", "3 dictations": how many is the point of printing it at all.
count() {
if (( $1 == 1 )); then printf '%s %s' "$1" "$2"; else printf '%s %s' "$1" "$3"; fi
}
usage() {
cat <<EOF
Usage: ./uninstall.sh [--purge] [--yes]
--purge also delete the settings ($CONFIG_DIR)
and the dictations, meetings and recordings ($DATA_DIR)
--yes do not ask before deleting those
Without --purge nothing you have written is touched, and the source directory
is left alone either way.
EOF
}
for arg in "$@"; do
case "$arg" in
--purge) PURGE=1 ;;
--yes|-y) ASSUME_YES=1 ;;
-h|--help) usage; exit 0 ;;
*) printf 'uninstall.sh: unknown option: %s\n' "$arg" >&2; usage >&2; exit 2 ;;
esac
done
# A symlink whose target is gone is still a file to remove, hence -L.
remove() {
if [[ -e "$1" || -L "$1" ]]; then
rm -f "$1"
ok "Removed $1"
else
gone "Was not there: $1"
fi
}
echo
echo "Uninstalling Dikte"
echo "──────────────────"
# 1. Global shortcuts ------------------------------------------------------
# Handed to Dikte while it can still run, because it is the half that knows
# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings.
if [[ -n "$PY" ]] && python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
for which in toggle cancel ask meeting; do
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
done
ok "Global shortcuts unregistered"
say "KWin reads that file at startup, so the keys are free after your next login."
else
warn "PyQt6 is missing, so the shortcuts were left registered."
say "Remove them in your desktop's shortcut settings."
fi
# 2. The running instance --------------------------------------------------
# It holds a tray icon and a socket; asking it to quit is tidier than pulling
# its launchers out from under it.
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
[[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" quit >/dev/null 2>&1 || true
sleep 0.5
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
warn "Dikte is still running; close it from the tray icon"
else
ok "Stopped the running instance"
fi
fi
# 3. Launchers -------------------------------------------------------------
# Only our own symlink goes: a file of the same name that somebody else put
# there is not ours to delete.
if [[ -L "$BIN_DIR/dikte" ]]; then
remove "$BIN_DIR/dikte"
elif [[ -e "$BIN_DIR/dikte" ]]; then
warn "$BIN_DIR/dikte is not our symlink, leaving it alone"
else
gone "Was not there: $BIN_DIR/dikte"
fi
remove "$APP_DIR/dikte.desktop"
remove "$AUTOSTART_DIR/dikte.desktop"
# Removing the shortcut takes its desktop file with it, but an install from
# before this script existed may have left one behind on a desktop that never
# used them.
for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do
if [[ -e "$APP_DIR/$id.desktop" ]]; then
remove "$APP_DIR/$id.desktop"
fi
done
# 4. Settings and dictations -----------------------------------------------
echo
if ((PURGE)); then
warn "--purge also deletes:"
if [[ -f "$CONFIG_DIR/config.json" ]]; then
say "$CONFIG_DIR/config.json (your API keys and every setting)"
fi
if [[ -f "$DATA_DIR/history.jsonl" ]]; then
# grep -c rather than wc -l: a last line with no newline is still a dictation.
say "$DATA_DIR/history.jsonl ($(count "$(grep -c '' "$DATA_DIR/history.jsonl" 2>/dev/null || echo 0)" dictation dictations))"
fi
if [[ -d "$DATA_DIR/meetings" ]]; then
say "$DATA_DIR/meetings ($(count "$(find "$DATA_DIR/meetings" -name '*.md' | wc -l)" meeting meetings))"
fi
if [[ -d "$DATA_DIR/recordings" ]]; then
say "$DATA_DIR/recordings ($(du -sh "$DATA_DIR/recordings" | cut -f1) of audio)"
fi
if ((!ASSUME_YES)); then
if [[ -t 0 ]]; then
printf ' Type yes to delete them: '
read -r reply
[[ "$reply" == "yes" ]] || { PURGE=0; say "Kept."; }
else
PURGE=0
warn "Not a terminal, so nothing was deleted. Pass --yes if you meant it."
fi
fi
fi
if ((PURGE)); then
rm -rf "$CONFIG_DIR" "$DATA_DIR"
ok "Settings and dictations deleted"
else
say "Settings kept: $CONFIG_DIR"
say "Dictations kept: $DATA_DIR"
say "Delete them too with: ./uninstall.sh --purge"
fi
echo
ok "Done."
say "The source directory is untouched: $DIR"
echo
Executable
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Dikte updater: pull, put the launchers back, restart what was running.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY="$(command -v python3 || true)"
USER_NAME="$(id -un)"
say() { printf ' %s\n' "$1"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$1"; }
die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; }
# The combination stored in the settings, which is where Dikte itself reads it
# from and the one place that is the same on KDE and on GNOME.
setting() {
[[ -n "$PY" ]] || return 0
"$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true
}
echo
echo "Updating Dikte"
echo "──────────────"
cd "$DIR"
# 1. Somewhere there is something to pull ----------------------------------
command -v git >/dev/null || die "git not found; update by downloading the source again"
git rev-parse --git-dir >/dev/null 2>&1 \
|| die "$DIR is not a git checkout; update by downloading the source again"
before="$(git rev-parse HEAD)"
# 2. Is there anything to come? ---------------------------------------------
# Asked before anything else is complained about: an unfinished afternoon in
# the working tree is nobody's problem on a day when nothing has been
# published. Fetching leaves the working tree alone.
git fetch --quiet || die "Could not reach the remote."
upstream="$(git rev-parse '@{u}' 2>/dev/null)" \
|| die "This branch is not tracking a remote one; pull by hand."
if [[ "$before" == "$upstream" ]]; then
echo
ok "Already up to date ($(git log -1 --format=%s))"
echo
exit 0
fi
# 3. Only now, your own edits -----------------------------------------------
# They would be overwritten by a fast-forward or would block it, and either way
# that is your call to make, not this script's. Untracked files are counted
# too: a fast-forward that adds a file of that name stops on them.
if [[ -n "$(git status --porcelain)" ]]; then
warn "There is an update waiting, but you have changes of your own here:"
git --no-pager status --short | sed 's/^/ /'
say "Commit them, or put them aside with: git stash --include-untracked"
die "Nothing was updated."
fi
# --ff-only: an update should be somebody else's commits arriving, never a
# merge this script decided to make on your behalf. The fetch above already
# brought them, so this touches no network.
# advice off: git's suggestion is a merge or a rebase, and which of those you
# want is the sentence below, not a wall of hints.
if ! merge_log="$(git -c advice.diverging=false merge --ff-only '@{u}' 2>&1)"; then
printf '%s\n' "$merge_log" | sed 's/^/ /'
say "Your branch has commits the remote does not. To put them on top of the"
say "update instead: git pull --rebase"
die "Could not fast-forward."
fi
after="$(git rev-parse HEAD)"
echo
say "What arrived:"
git --no-pager log --oneline "$before..$after" | sed 's/^/ /'
echo
# 4. Launchers --------------------------------------------------------------
# An update can add a dependency or move a file, so the installer runs again.
# It would otherwise register its own defaults over the keys you chose, so it
# is told what those are. Read before the installer runs, since it is the one
# writing them.
shortcut="$(setting shortcut)"
cancel_shortcut="$(setting cancel_shortcut)"
# Positional, so a chosen discard key cannot be passed without the other one.
"$DIR/install.sh" "${shortcut:-Ctrl+Space}" "${cancel_shortcut:-}"
# 5. The running instance ---------------------------------------------------
# It is still running the code from before the pull.
if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then
if [[ -n "$PY" ]] && "$PY" "$DIR/dikte.py" restart >/dev/null 2>&1; then
ok "Restarted, so the new version is the one running"
else
warn "Could not restart it; use the tray menu → Restart"
fi
else
say "Dikte was not running. Start it with: dikte"
fi
echo