diff --git a/README.md b/README.md index c6f68ea..4b42583 100644 --- a/README.md +++ b/README.md @@ -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,10 @@ 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. `./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**. Speech to text runs on any of them (`gpt-4o-transcribe` by default), cleanup @@ -53,7 +55,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 +129,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`. diff --git a/README.tr.md b/README.tr.md index de07629..97b007d 100644 --- a/README.tr.md +++ b/README.tr.md @@ -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,11 @@ 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ı. `./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**. 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 | | --- | --- | | 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 +128,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`. diff --git a/cli.py b/cli.py index b62047d..d907537 100644 --- a/cli.py +++ b/cli.py @@ -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 -------------------------------------------------- diff --git a/config.py b/config.py index 1febe84..6d052b0 100644 --- a/config.py +++ b/config.py @@ -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, diff --git a/dikte.py b/dikte.py index cc17c6c..1e2bfc4 100755 --- a/dikte.py +++ b/dikte.py @@ -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 diff --git a/hotkey.py b/hotkey.py index b6b3ed1..f71de71 100644 --- a/hotkey.py +++ b/hotkey.py @@ -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,14 +392,18 @@ def remove_kde_shortcut(desktop_id=DESKTOP_ID): (APPLICATIONS_DIR / desktop_id).unlink(missing_ok=True) except OSError: pass - try: - subprocess.run( - ["kwriteconfig6", "--notify", "--file", "kglobalshortcutsrc", - "--group", "services", "--group", desktop_id, "--key", "_launch", "--delete"], - capture_output=True, timeout=10, - ) - except (subprocess.SubprocessError, 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", key, "--delete"], + capture_output=True, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + pass def kde_shortcut_status(desktop_id=DESKTOP_ID): diff --git a/i18n.py b/i18n.py index 83e06ad..7466143 100644 --- a/i18n.py +++ b/i18n.py @@ -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}", diff --git a/install.sh b/install.sh index 2cbaaee..79a666d 100755 --- a/install.sh +++ b/install.sh @@ -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" < 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 + # 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 "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 echo diff --git a/settings_ui.py b/settings_ui.py index 2bf1a9e..3a36c7c 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -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): diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a11a46..cb09bc0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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", diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 4297724..01ae92d 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -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): diff --git a/tests/test_ui.py b/tests/test_ui.py index 5ca37b2..892e736 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -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() diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..dfbdaa1 --- /dev/null +++ b/uninstall.sh @@ -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 <&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 diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..9194407 --- /dev/null +++ b/update.sh @@ -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