mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Give throwing a recording away a key of its own
Stopping a recording is the step there is no taking back. It is what sends the audio off, and a moment later the sentence you did not mean to dictate is in the clipboard and pasted into whatever window you were typing in. The tray menu was the only way out, and by the time it is open the recording has already gone. Discarding needed to be as quick as starting, which means a key. Ctrl+Alt+Space rather than Escape: the combination the recording started with, one modifier along, so the two are one gesture with a modifier between them. Escape is the obvious choice and the wrong one, because it belongs to whichever window has focus, and while you are talking something else usually has it. It works on a dictation and on a command for the agent alike, since the one you want to take back is the one that is running. That made four global shortcuts, and four is the number at which three copies of the same forty lines stop being a coincidence. The command line already had a table of them; hotkey.py now holds it, and the settings window reads it too, so a shortcut is a row rather than a combination box, two buttons, a status label and three methods written out again. The window no longer takes the commands to run as arguments either, because ipc.command_for already knows them from the verb. Adding the fourth key is what this buys: one line in the table and one call per row. Both places a key can live are told about it. The listener catching the press and the KDE shortcut arriving behind it go through the same echo guard the toggle already had, so the two are one discard rather than two, and the tray calls the inner method as it already did for the toggle. Ctrl+Space and Ctrl+Alt+Space land on the same evdev key code, so there is a test for the modifier matching that keeps them apart. install.sh takes the second key as a second argument and refuses to register two of the same. It hands both to `dikte shortcut install` rather than writing kglobalshortcutsrc itself, which is what makes it work on GNOME, and what finally puts the chosen key in the settings as well: the built-in listener reads it from there, so a key written to only one of the two places was a key that half worked. Left empty in the settings window the discard key stays empty, unlike the dictation shortcut which falls back to Ctrl+Space: a recording can always be thrown away from the tray menu, so there is nothing to guarantee here.
This commit is contained in:
@@ -25,7 +25,7 @@ just the Python standard library and PyQt6.
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
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
|
||||
```
|
||||
|
||||
@@ -36,8 +36,8 @@ tools instead:
|
||||
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||
```
|
||||
|
||||
`install.sh` adds the `dikte` command, a menu entry and an autostart entry. The
|
||||
settings window installs a GNOME or KDE global shortcut.
|
||||
`install.sh` adds the `dikte` command, a menu entry, an autostart entry and the
|
||||
two global shortcuts, whose keys are its two arguments.
|
||||
|
||||
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
|
||||
@@ -53,7 +53,7 @@ set next to it.
|
||||
| What | How |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| Start / end a meeting | Tray menu → *Record a meeting*, or `dikte meeting` |
|
||||
| Settings | Tray menu → *Settings*, or `dikte settings` |
|
||||
@@ -127,11 +127,11 @@ running.
|
||||
right-click to delete.
|
||||
- **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 →
|
||||
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
|
||||
the focused application (some editors will pop up autocomplete). The listener
|
||||
needs your user in the `input` group: `sudo usermod -aG input $USER`.
|
||||
|
||||
+7
-7
@@ -25,7 +25,7 @@ sadece Python standart kütüphanesi ve PyQt6.
|
||||
sudo pacman -S --needed pipewire-audio wl-clipboard ydotool ffmpeg python-pyqt6
|
||||
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
|
||||
```
|
||||
|
||||
@@ -36,8 +36,8 @@ araçlarıyla çalışır:
|
||||
sudo apt install pulseaudio-utils xclip xdotool ffmpeg
|
||||
```
|
||||
|
||||
`install.sh` `dikte` komutunu, menü girdisini ve oturum açılışında otomatik
|
||||
başlatmayı kurar. Ayarlar penceresi GNOME veya KDE global kısayolunu kurar.
|
||||
`install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik
|
||||
başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı.
|
||||
|
||||
Ayarlar penceresinde üç anahtar istenir: **OpenAI**, **Groq** ve **OpenRouter**.
|
||||
Sesi yazıya çevirme üçünden birinde çalışır (varsayılan `gpt-4o-transcribe`),
|
||||
@@ -53,7 +53,7 @@ yapıştırılır; modelin yanındaki kutudan düşünme seviyesini de seçebili
|
||||
| Ne | Nasıl |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| Toplantıyı başlat / bitir | Tepsi menüsü → *Toplantı kaydet*, ya da `dikte meeting` |
|
||||
| Ayarlar | Tepsi menüsü → *Ayarlar*, ya da `dikte settings` |
|
||||
@@ -125,11 +125,11 @@ olmasını ister.
|
||||
silebilirsin.
|
||||
- **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
|
||||
yazdığı kısayol oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
|
||||
Kısayol → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
|
||||
yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
|
||||
Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
|
||||
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`
|
||||
grubunda olmasını gerektirir: `sudo usermod -aG input $USER`.
|
||||
|
||||
@@ -44,14 +44,6 @@ GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
|
||||
IDEMPOTENT_VERBS = {"cancel", "stop", "quit", "restart", "ask-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
|
||||
|
||||
|
||||
@@ -705,9 +697,9 @@ def cmd_shortcut(opts):
|
||||
conf = cfg.Config()
|
||||
if opts.shortcut == "status":
|
||||
rows = {}
|
||||
for name, (desktop_id, _label, key) in SHORTCUTS.items():
|
||||
rows[name] = {"registered": hotkey.shortcut_status(desktop_id),
|
||||
"configured": conf[key]}
|
||||
for name, spec in hotkey.SHORTCUTS.items():
|
||||
rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id),
|
||||
"configured": conf[spec.setting]}
|
||||
lines = [f"{name:8} {row['registered'] or '(not installed)':16} "
|
||||
f"setting: {row['configured'] or '(none)'}"
|
||||
for name, row in rows.items()]
|
||||
@@ -715,28 +707,29 @@ def cmd_shortcut(opts):
|
||||
return out(opts, {"ok": True, "shortcuts": rows,
|
||||
"listener": conf["evdev_hotkey"]}, "\n".join(lines))
|
||||
|
||||
desktop_id, label, key = SHORTCUTS[opts.which]
|
||||
spec = hotkey.SHORTCUTS[opts.which]
|
||||
if opts.shortcut == "remove":
|
||||
hotkey.remove_shortcut(desktop_id)
|
||||
hotkey.remove_shortcut(spec.desktop_id)
|
||||
return out(opts, {"ok": True, "removed": opts.which},
|
||||
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:
|
||||
return fail(opts, "no combination given and none stored; pass --combo", 2)
|
||||
if hotkey.parse_shortcut(combo) == (None, None):
|
||||
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:
|
||||
return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. "
|
||||
"Pass --force to install it anyway.", 1, conflicts=clashes)
|
||||
|
||||
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:
|
||||
return fail(opts, message)
|
||||
conf[key] = combo
|
||||
conf[spec.setting] = combo
|
||||
try:
|
||||
conf.save()
|
||||
except OSError as exc:
|
||||
@@ -1001,19 +994,20 @@ def build_parser():
|
||||
test.set_defaults(func=cmd_test_key)
|
||||
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="")
|
||||
shortcut.set_defaults(func=_needs_subcommand(shortcut))
|
||||
leaf(inner, "status", "what is registered").set_defaults(func=cmd_shortcut)
|
||||
install = leaf(inner, "install", "register one")
|
||||
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("--force", action="store_true",
|
||||
help="install it even if something else uses it")
|
||||
install.set_defaults(func=cmd_shortcut)
|
||||
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)
|
||||
|
||||
# --- the application --------------------------------------------------
|
||||
|
||||
@@ -389,6 +389,10 @@ DEFAULTS = {
|
||||
"min_voiced_seconds": 0.3,
|
||||
"filter_hallucinations": True,
|
||||
"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,
|
||||
"overlay_corner": "bottom-left",
|
||||
"keep_audio": False,
|
||||
|
||||
@@ -152,8 +152,10 @@ class Dikte:
|
||||
self.ask_cancel_action.setEnabled(False)
|
||||
self.menu.addAction(self.ask_cancel_action)
|
||||
|
||||
self.cancel_action = QAction(t("Cancel recording"), self.menu)
|
||||
self.cancel_action.triggered.connect(self.cancel)
|
||||
self.cancel_action = QAction(t("Discard the recording"), self.menu)
|
||||
# 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.menu.addAction(self.cancel_action)
|
||||
self.menu.addSeparator()
|
||||
@@ -306,6 +308,9 @@ class Dikte:
|
||||
def toggle_meeting(self):
|
||||
self._external("meeting", self._toggle_meeting)
|
||||
|
||||
def cancel(self):
|
||||
self._external("cancel", self._cancel)
|
||||
|
||||
def _external(self, name, handler):
|
||||
# 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
|
||||
@@ -323,7 +328,8 @@ class Dikte:
|
||||
if timer is None:
|
||||
timer = self.last_evdev[name] = QElapsedTimer()
|
||||
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)()
|
||||
|
||||
def _retire_listener(self):
|
||||
@@ -516,7 +522,7 @@ class Dikte:
|
||||
self.ask_overlay.show_busy(t("Transcribing…"))
|
||||
self.recorder.stop()
|
||||
|
||||
def cancel(self):
|
||||
def _cancel(self):
|
||||
"""Throw away whichever recording is running."""
|
||||
if not self.recording:
|
||||
return
|
||||
@@ -540,7 +546,7 @@ class Dikte:
|
||||
def cancel_ask(self):
|
||||
"""Call off the agent, whether it is still recording or already working."""
|
||||
if self.ask_state == RECORDING:
|
||||
self.cancel()
|
||||
self._cancel()
|
||||
elif self.ask_state == BUSY:
|
||||
self.ask_overlay.show_busy(t("Stopping…"))
|
||||
self.ask_pipeline.cancel()
|
||||
@@ -788,10 +794,7 @@ class Dikte:
|
||||
|
||||
def open_settings(self):
|
||||
if self.settings_window is None:
|
||||
self.settings_window = SettingsWindow(
|
||||
self.conf, launch_command(), meeting_command(), self.meetings,
|
||||
ask_command(),
|
||||
)
|
||||
self.settings_window = SettingsWindow(self.conf, self.meetings)
|
||||
self.settings_window.applied.connect(self._apply_settings)
|
||||
self.settings_window.finished.connect(self._settings_closed)
|
||||
self.settings_window.show()
|
||||
@@ -808,9 +811,8 @@ class Dikte:
|
||||
self._build_tray()
|
||||
self._refresh_tray()
|
||||
if self.conf["evdev_hotkey"]:
|
||||
self.evdev.start({"toggle": self.conf["shortcut"],
|
||||
"ask": self.conf["assistant_shortcut"],
|
||||
"meeting": self.conf["meeting_shortcut"]})
|
||||
self.evdev.start({name: self.conf[spec.setting]
|
||||
for name, spec in hotkey.SHORTCUTS.items()})
|
||||
else:
|
||||
self.evdev.stop()
|
||||
|
||||
@@ -849,19 +851,6 @@ def _clock(seconds):
|
||||
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():
|
||||
argv = sys.argv[1:]
|
||||
# Anything typed at a terminal is the command line's business, including
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""GNOME/KDE global-shortcut installation plus a built-in evdev listener."""
|
||||
|
||||
import ast
|
||||
import collections
|
||||
import glob
|
||||
import os
|
||||
import pathlib
|
||||
@@ -16,6 +17,7 @@ from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from i18n import t
|
||||
|
||||
DESKTOP_ID = "dikte-toggle.desktop"
|
||||
CANCEL_DESKTOP_ID = "dikte-cancel.desktop"
|
||||
MEETING_DESKTOP_ID = "dikte-meeting.desktop"
|
||||
ASK_DESKTOP_ID = "dikte-ask.desktop"
|
||||
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_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) --------------------------
|
||||
|
||||
EV_KEY = 0x01
|
||||
@@ -371,10 +392,14 @@ def remove_kde_shortcut(desktop_id=DESKTOP_ID):
|
||||
(APPLICATIONS_DIR / desktop_id).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
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:
|
||||
subprocess.run(
|
||||
["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,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
|
||||
@@ -56,7 +56,7 @@ TR = {
|
||||
"Start recording": "Kaydı başlat",
|
||||
"Stop and transcribe": "Kaydı bitir ve yaz",
|
||||
"Working…": "İşleniyor…",
|
||||
"Cancel recording": "Kaydı iptal et",
|
||||
"Discard the recording": "Kaydı iptal et",
|
||||
"Settings…": "Ayarlar…",
|
||||
"Restart": "Yeniden başlat",
|
||||
"Quit": "Çık",
|
||||
@@ -133,6 +133,7 @@ TR = {
|
||||
"Cleanup rules": "Temizleme kuralları",
|
||||
"Audio file": "Ses dosyası",
|
||||
"Shortcut": "Kısayol",
|
||||
"Shortcuts": "Kısayollar",
|
||||
"History": "Geçmiş",
|
||||
"Save": "Kaydet",
|
||||
"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.",
|
||||
"No global shortcut installed. The tray menu asks it too.":
|
||||
"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}",
|
||||
"Could not register the GNOME shortcut: {error}":
|
||||
"GNOME kısayolu kaydedilemedi: {error}",
|
||||
|
||||
+39
-23
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dikte installer: dependency check, launchers, KDE shortcut.
|
||||
# Dikte installer: dependency check, launchers, global shortcuts.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -8,6 +8,9 @@ BIN_DIR="$HOME/.local/bin"
|
||||
APP_DIR="$HOME/.local/share/applications"
|
||||
AUTOSTART_DIR="$HOME/.config/autostart"
|
||||
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"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; }
|
||||
@@ -88,30 +91,43 @@ StartupNotify=false
|
||||
EOF
|
||||
ok "Will start automatically on login"
|
||||
|
||||
# 4. KDE global shortcut ---------------------------------------------------
|
||||
cat > "$APP_DIR/dikte-toggle.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Exec=$PY $DIR/dikte.py toggle
|
||||
Name=Dikte: start/stop recording
|
||||
NoDisplay=true
|
||||
StartupNotify=false
|
||||
Type=Application
|
||||
X-KDE-GlobalAccel-CommandShortcut=true
|
||||
EOF
|
||||
# 4. Global shortcuts ------------------------------------------------------
|
||||
# Two of them: one to start and stop, one to throw the recording away. The
|
||||
# second is worth a key of its own because stopping is the step there is no
|
||||
# taking back, being what sends the audio off to be transcribed.
|
||||
#
|
||||
# Dikte registers them rather than this script writing the files itself: it
|
||||
# knows which desktop it is on, and it stores the combination in the settings
|
||||
# as well, which is where the built-in listener reads it from. A key written
|
||||
# to only one of the two places is a key that half works.
|
||||
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
|
||||
ok "GNOME detected"
|
||||
say "Open Dikte Settings > Shortcut to install the global shortcut."
|
||||
elif command -v kwriteconfig6 >/dev/null; then
|
||||
kwriteconfig6 --notify --file kglobalshortcutsrc \
|
||||
--group services --group dikte-toggle.desktop \
|
||||
--key _launch "$SHORTCUT"
|
||||
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"
|
||||
say "built-in listener to use it right away."
|
||||
register() { # which combination label
|
||||
if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then
|
||||
ok "$3: $2"
|
||||
else
|
||||
warn "No supported shortcut manager found. Add the shortcut in desktop settings."
|
||||
# One line: the rest of what it has to say about KWin is printed below.
|
||||
warn "${out%%$'\n'*}"
|
||||
fi
|
||||
}
|
||||
|
||||
if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
|
||||
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
|
||||
warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:"
|
||||
say "dikte shortcut install toggle --combo '$SHORTCUT'"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
+84
-138
@@ -19,6 +19,7 @@ import audio
|
||||
import config as cfg
|
||||
import filetranscribe
|
||||
import hotkey
|
||||
import ipc
|
||||
import meeting
|
||||
from filetranscribe import FileTranscriber
|
||||
from i18n import t
|
||||
@@ -92,9 +93,9 @@ REASONING_LEVELS = [
|
||||
("Very high", "xhigh"), ("Maximum", "max"),
|
||||
]
|
||||
PASTE_SHORTCUTS = ["ctrl+v", "ctrl+shift+v", "shift+insert"]
|
||||
# Offered for all three global shortcuts, which keeps them one kind of field
|
||||
# rather than three. The boxes stay editable: this is a shortlist of
|
||||
# combinations that are usually free, not the set of ones that work.
|
||||
# Offered for every global shortcut, which keeps them one kind of field rather
|
||||
# than four. The boxes stay editable: this is a shortlist of combinations that
|
||||
# are usually free, not the set of ones that work.
|
||||
SHORTCUTS = [
|
||||
"Ctrl+Space", "Ctrl+Alt+Space", "Ctrl+Shift+Space", "Meta+Space",
|
||||
"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.
|
||||
_test_done = pyqtSignal(str, bool, str)
|
||||
|
||||
def __init__(self, conf, launch_command, meeting_command=None,
|
||||
meetings=None, ask_command=None, parent=None):
|
||||
def __init__(self, conf, meetings=None, parent=None):
|
||||
super().__init__(parent)
|
||||
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
|
||||
# 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
|
||||
# provider back and forth never overwrites the other one's.
|
||||
self._models = dict.fromkeys(cfg.TRANSCRIBERS, "")
|
||||
@@ -139,7 +141,7 @@ class SettingsWindow(QDialog):
|
||||
tabs.addTab(self._meeting_tab(), t("Meeting"))
|
||||
tabs.addTab(self._minutes_tab(), t("Minutes"))
|
||||
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"))
|
||||
|
||||
# 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_form = QFormLayout(how)
|
||||
self.assistant_shortcut = self._shortcut_box(t("none"))
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_ask_shortcut)
|
||||
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._shortcut_row(
|
||||
how_form, "ask", t("Shortcut"),
|
||||
t("No global shortcut installed. The tray menu asks it too."),
|
||||
)
|
||||
|
||||
self.assistant_provider = QComboBox()
|
||||
for label, value in ASSISTANT_PROVIDERS:
|
||||
@@ -622,16 +618,10 @@ class SettingsWindow(QDialog):
|
||||
))
|
||||
recording_form.addRow("", self.meeting_keep_audio)
|
||||
|
||||
self.meeting_shortcut = self._shortcut_box(t("none"))
|
||||
install = QPushButton(t("Install as a KDE shortcut"))
|
||||
install.clicked.connect(self._install_meeting_shortcut)
|
||||
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)
|
||||
self._shortcut_row(
|
||||
recording_form, "meeting", t("Shortcut"),
|
||||
t("No global shortcut installed. The tray menu starts a meeting too."),
|
||||
)
|
||||
layout.addWidget(recording)
|
||||
|
||||
prompt_label = QLabel(t("System instruction given to the minutes model."))
|
||||
@@ -772,25 +762,27 @@ class SettingsWindow(QDialog):
|
||||
def _shortcut_tab(self):
|
||||
page = QWidget()
|
||||
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()
|
||||
self.shortcut = self._shortcut_box("Ctrl+Space")
|
||||
form.addRow(t("Shortcut"), self.shortcut)
|
||||
self._shortcut_row(
|
||||
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)
|
||||
|
||||
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"
|
||||
@@ -904,6 +896,25 @@ class SettingsWindow(QDialog):
|
||||
self._testers[provider] = (button, answer)
|
||||
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
|
||||
def _row(*widgets):
|
||||
"""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.assistant_shortcut.setCurrentText(conf["assistant_shortcut"])
|
||||
self._select_data(self.assistant_provider, conf["assistant_provider"])
|
||||
self.assistant_model.setCurrentText(conf["assistant_model"])
|
||||
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_max_minutes.setValue(max(5, int(conf["meeting_max_seconds"]) // 60))
|
||||
self.meeting_keep_audio.setChecked(conf["meeting_keep_audio"])
|
||||
self.meeting_shortcut.setCurrentText(conf["meeting_shortcut"])
|
||||
self.meeting_prompt.setPlainText(
|
||||
conf["meeting_prompt"] or cfg.default_meeting_prompt()
|
||||
)
|
||||
@@ -985,14 +994,14 @@ class SettingsWindow(QDialog):
|
||||
self.file_cleanup.setChecked(conf["file_cleanup"])
|
||||
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.history_limit.setValue(max(0, int(conf["history_limit"])))
|
||||
|
||||
self._refresh_shortcut_status()
|
||||
self._refresh_meeting_shortcut_status()
|
||||
self._refresh_ask_shortcut_status()
|
||||
for which in self._shortcut_rows:
|
||||
self._refresh_shortcut_status(which)
|
||||
self._refresh_assistant_status()
|
||||
self._load_history()
|
||||
self._load_minutes()
|
||||
@@ -1032,7 +1041,6 @@ class SettingsWindow(QDialog):
|
||||
else file_prompt)
|
||||
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_model"] = (self.assistant_model.currentText().strip()
|
||||
or cfg.DEFAULTS["assistant_model"])
|
||||
@@ -1072,7 +1080,6 @@ class SettingsWindow(QDialog):
|
||||
conf["meeting_cleanup"] = self.meeting_cleanup.isChecked()
|
||||
conf["meeting_max_seconds"] = self.meeting_max_minutes.value() * 60
|
||||
conf["meeting_keep_audio"] = self.meeting_keep_audio.isChecked()
|
||||
conf["meeting_shortcut"] = self.meeting_shortcut.currentText().strip()
|
||||
meeting_prompt = self.meeting_prompt.toPlainText().strip()
|
||||
conf["meeting_prompt"] = ("" if meeting_prompt == cfg.default_meeting_prompt()
|
||||
else meeting_prompt)
|
||||
@@ -1080,7 +1087,12 @@ class SettingsWindow(QDialog):
|
||||
conf["file_timestamps"] = self.file_timestamps.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["history_limit"] = self.history_limit.value()
|
||||
conf.save()
|
||||
@@ -1293,45 +1305,17 @@ class SettingsWindow(QDialog):
|
||||
except OSError as exc:
|
||||
self.file_status.setText(t("Failed: {error}", error=exc))
|
||||
|
||||
# ---- shortcut --------------------------------------------------------
|
||||
# ---- shortcuts -------------------------------------------------------
|
||||
|
||||
def _install_shortcut(self):
|
||||
combo = self.shortcut.currentText().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_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()
|
||||
def _install_shortcut(self, which):
|
||||
spec = hotkey.SHORTCUTS[which]
|
||||
box, _status, _missing = self._shortcut_rows[which]
|
||||
combo = box.currentText().strip() or spec.fallback
|
||||
if not combo:
|
||||
QMessageBox.information(self, t("Shortcut"),
|
||||
t("Type a key combination first."))
|
||||
return
|
||||
clashes = hotkey.conflicting_shortcuts(combo, hotkey.MEETING_DESKTOP_ID)
|
||||
clashes = hotkey.conflicting_shortcuts(combo, spec.desktop_id)
|
||||
if clashes:
|
||||
answer = QMessageBox.question(
|
||||
self, t("Shortcut conflict"),
|
||||
@@ -1341,64 +1325,26 @@ class SettingsWindow(QDialog):
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
ok, message = hotkey.install_shortcut(
|
||||
combo, self.meeting_command, name="Dikte: start/end a meeting recording",
|
||||
desktop_id=hotkey.MEETING_DESKTOP_ID,
|
||||
combo, ipc.command_for(spec.verb), name=spec.name,
|
||||
desktop_id=spec.desktop_id,
|
||||
)
|
||||
QMessageBox.information(self, t("Shortcut"), message)
|
||||
if ok:
|
||||
self.conf["meeting_shortcut"] = combo
|
||||
self.conf[spec.setting] = combo
|
||||
self.conf.save()
|
||||
self._refresh_meeting_shortcut_status()
|
||||
self._refresh_shortcut_status(which)
|
||||
|
||||
def _remove_meeting_shortcut(self):
|
||||
hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID)
|
||||
self._refresh_meeting_shortcut_status()
|
||||
def _remove_shortcut(self, which):
|
||||
hotkey.remove_shortcut(hotkey.SHORTCUTS[which].desktop_id)
|
||||
self._refresh_shortcut_status(which)
|
||||
|
||||
def _refresh_meeting_shortcut_status(self):
|
||||
current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID)
|
||||
self.meeting_shortcut_status.setText(
|
||||
def _refresh_shortcut_status(self, which):
|
||||
_box, status, missing = self._shortcut_rows[which]
|
||||
current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id)
|
||||
status.setText(
|
||||
t("Registered in {desktop}: {shortcut}",
|
||||
desktop=hotkey.desktop_name(), shortcut=current) if current
|
||||
else t("No global shortcut installed. The tray menu starts a meeting too.")
|
||||
)
|
||||
|
||||
# ---- 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.")
|
||||
else missing
|
||||
)
|
||||
|
||||
def _assistant_provider_changed(self):
|
||||
|
||||
@@ -14,6 +14,7 @@ from unittest import mock
|
||||
|
||||
import cli
|
||||
import config as cfg
|
||||
import hotkey
|
||||
import ipc
|
||||
from tests.support import DikteTest, fake_urlopen
|
||||
|
||||
@@ -144,6 +145,22 @@ class Parser(unittest.TestCase):
|
||||
self.assertIsNone(opts.verb)
|
||||
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):
|
||||
for verb in ("record", "toggle", "start", "stop", "cancel", "ask",
|
||||
"session", "transcribe", "meeting", "meetings", "history",
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import config as cfg
|
||||
import hotkey
|
||||
from tests.support import DikteTest, FakeCompleted, linux_only
|
||||
|
||||
@@ -56,6 +57,28 @@ class ParseShortcut(unittest.TestCase):
|
||||
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):
|
||||
"""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()
|
||||
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
|
||||
class Chooser(DikteTest):
|
||||
|
||||
+34
-1
@@ -12,6 +12,7 @@ from unittest import mock
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox
|
||||
|
||||
import config as cfg
|
||||
import hotkey
|
||||
import overlay as overlay_module
|
||||
import settings_ui
|
||||
from tests.support import DikteTest, only_these_tools
|
||||
@@ -76,6 +77,7 @@ CHANGED = {
|
||||
"file_timestamps": True,
|
||||
"file_cleanup": False,
|
||||
"shortcut": "Ctrl+Alt+Space",
|
||||
"cancel_shortcut": "Meta+Shift+Space",
|
||||
"evdev_hotkey": True,
|
||||
"history_limit": 50,
|
||||
}
|
||||
@@ -98,7 +100,7 @@ class Settings(DikteTest):
|
||||
self.path("kglobalshortcutsrc")))
|
||||
|
||||
def window(self, conf):
|
||||
window = settings_ui.SettingsWindow(conf, "dikte toggle")
|
||||
window = settings_ui.SettingsWindow(conf)
|
||||
self.addCleanup(window.deleteLater)
|
||||
self.addCleanup(window.close)
|
||||
return window
|
||||
@@ -136,6 +138,37 @@ class Settings(DikteTest):
|
||||
self.assertEqual(stored["speech_margin_db"], 15.0)
|
||||
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):
|
||||
"""So that switching the interface language switches the prompt too."""
|
||||
conf = cfg.Config()
|
||||
|
||||
Reference in New Issue
Block a user