mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
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:
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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 --------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user