From 1f5ea70fa6b877599590a5aa3df65b565d04686a Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 13:41:27 +0300 Subject: [PATCH] Stop telling every desktop that it is KDE Only GNOME was recognised, and everything else was handed to KWin. On i3, XFCE, Cinnamon, MATE, sway and the rest, Dikte wrote an entry into kglobalshortcutsrc that nothing reads, called the session KDE, and promised that the keys would work after the next login. They never did. There is no backend to write for any of them. The /dev/input listener is already desktop-agnostic, so those sessions are the case macOS has always been: no registry, nothing to install, nothing to remove, and the combination held by the running process. One backend() function decides which of the four this session has, and the name shown, the status read back, what Install writes, what Settings explains and what the installer promises are all taken from it, so they cannot disagree. A desktop now only counts when the program that writes its registry is there too. A GNOME session without gsettings and a Plasma one without kwriteconfig6 fall to the listener rather than to a file, which is also how Plasma 5 stops erroring on a kwriteconfig6 it never had. What Settings shows on those desktops is the truth: no Install button, no KWin, no listener checkbox (it is the mechanism, not a choice), what reading /dev/input costs, that the focused application sees the keys too, and the command to bind if you would rather your desktop owned them. The evdev listener records what it is listening for the way the Carbon one does, so the status line has something to say there at all. Closes #28 --- README.md | 14 +++-- README.tr.md | 13 ++-- cli.py | 6 +- dikte.py | 4 +- hotkey.py | 141 +++++++++++++++++++++++++++++++------------ i18n.py | 20 ++++++ install.sh | 27 +++++++-- ipc.py | 6 +- settings_ui.py | 43 ++++++++++--- tests/test_hotkey.py | 84 ++++++++++++++++++++++---- tests/test_ui.py | 68 ++++++++++++++++++++- uninstall.sh | 17 ++++-- update.sh | 2 +- 13 files changed, 360 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 6ef12b6..c047b5a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ machine by default, a model cleans it up (dropping the *uh*s, the restarts, the missing punctuation), and the result lands in your clipboard and is pasted into whatever window you were typing in. -Built for KDE Plasma 6 on Wayland, and runs on GNOME X11 and macOS too. No +Built for KDE Plasma 6 on Wayland, and runs on GNOME X11, macOS and any other +Linux desktop that will let it read the keyboard. No dependencies beyond system packages: just the Python standard library, 3.11 or newer, and PyQt6. @@ -184,14 +185,19 @@ running. right-click to delete. - **Turkish and English interface**, following the system locale by default. -## The global shortcuts need one logout +## The global shortcuts, and the logout KDE needs 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 → 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`. +needs your user in the `input` group: `sudo usermod -aG input $USER`. On GNOME +the shortcut works the moment it is installed, and on a desktop that keeps no +registry at all (i3, XFCE, sway and most others) the listener is the whole +mechanism: nothing is installed, nothing waits for a logout, and Settings → +Shortcuts shows the command to bind if you would rather your desktop owned the +keys. ## Layout @@ -211,7 +217,7 @@ vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps overlay.py the corner indicator settings_ui.py settings window -hotkey.py KDE shortcut installation, the evdev listener, Carbon on a Mac +hotkey.py the desktop's shortcut registry, the evdev listener, Carbon on a Mac paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics trayicon.py the tray icons, drawn where there is no icon theme i18n.py the string table diff --git a/README.tr.md b/README.tr.md index 0ac4d6b..38a7537 100644 --- a/README.tr.md +++ b/README.tr.md @@ -4,7 +4,8 @@ çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. -KDE Plasma 6 / Wayland için yazıldı, GNOME X11 ve macOS'ta da çalışır. Sistem +KDE Plasma 6 / Wayland için yazıldı; GNOME X11'de, macOS'ta ve klavyeyi +okumasına izin veren diğer Linux masaüstlerinde de çalışır. Sistem paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya üstü) ve PyQt6. @@ -182,14 +183,18 @@ olmasını ister. silebilirsin. - **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler. -## Global kısayollar için bir kez oturum kapatmak gerekir +## Global kısayollar ve KDE'nin istediği oturum kapatma KWin `kglobalshortcutsrc` dosyasını yalnızca açılışta okur, yani `install.sh`'ın 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`. +grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. GNOME'da kısayol +kurulduğu anda çalışır; hiç kayıt defteri tutmayan masaüstlerinde (i3, XFCE, +sway ve çoğu diğeri) dinleyici mekanizmanın kendisidir: hiçbir şey kurulmaz, +oturum kapatmak gerekmez, tuşları masaüstünün sahiplenmesini istersen Ayarlar → +Kısayollar sekmesi bağlanacak komutu gösterir. ## Dosyalar @@ -209,7 +214,7 @@ vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları overlay.py köşedeki gösterge settings_ui.py ayarlar penceresi -hotkey.py KDE kısayol kurulumu, evdev dinleyici, Mac'te Carbon +hotkey.py masaüstünün kısayol kaydı, evdev dinleyici, Mac'te Carbon paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş i18n.py metin tablosu diff --git a/cli.py b/cli.py index ba643c2..7ab4d99 100644 --- a/cli.py +++ b/cli.py @@ -35,9 +35,9 @@ import paste NOT_RUNNING = 3 -# Verbs that start the application when none is running, which is what the KDE -# shortcut has always relied on: press the key on a fresh login and Dikte comes -# up recording. +# Verbs that start the application when none is running, which is what a +# shortcut registered with the desktop has always relied on: press the key on a +# fresh login and Dikte comes up recording. GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"} # Asking a process that is not there to stop, cancel or quit is not a failure; diff --git a/dikte.py b/dikte.py index 4718c46..3b7986e 100755 --- a/dikte.py +++ b/dikte.py @@ -365,8 +365,8 @@ class Dikte: 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 - # that same press. Its lateness is also the proof we were waiting for + # toggle arriving right behind one is the desktop's own shortcut catching + # up on that same press. Its lateness is also the proof we were waiting for # that the shortcut is live, which leaves the listener with nothing to # do but double every press. # Where nothing was installed there is no shortcut to catch up, and diff --git a/hotkey.py b/hotkey.py index 7026dee..1060c08 100644 --- a/hotkey.py +++ b/hotkey.py @@ -1,11 +1,16 @@ """Global shortcuts: the desktop's own registry, plus a listener of our own. Two things have to happen for a key combination to reach Dikte. Somewhere has -to be told about it, and something has to be listening. On Linux that is the -desktop's shortcut registry (KDE's file, GNOME's gsettings) and a reader of -/dev/input for the wait until the registry is live. macOS has no registry to -write into: the application asks Carbon for the combination while it runs, so -there the listener is not a fallback but the whole mechanism. +to be told about it, and something has to be listening. Two desktops keep a +registry we can write into and read back, and something outside Dikte acts on +it: KDE has a file, GNOME has gsettings. There the /dev/input listener only +covers the wait until the registry is live. + +Everywhere else there is nothing to write into, so Dikte holds the combination +itself for as long as it runs: macOS asks Carbon for it, and every other Linux +session (i3, XFCE, Cinnamon, sway, whatever the session calls itself) leans on +the /dev/input listener. That is not a fallback there, it is the mechanism, so +nothing about installing or removing a registry entry should be offered. """ import ast @@ -105,13 +110,23 @@ def parse_shortcut(text): return mods, key +# What the running listener holds. Where there is no registry this is the whole +# of "installed", and it lasts as long as the process does: there is no file, +# and no other program to read one. Written by the listener that is in use, +# read by the status line, so what Settings shows is what is actually being +# listened for. +_REGISTERED = {} + + # --- built-in listener ---------------------------------------------------- class EvdevHotkey(QObject): """Catches global shortcuts by reading /dev/input directly. It does not swallow the key; the focused application sees the combination - too. This is the fallback that works before the KDE shortcut goes live. + too. On KDE that is the price of not waiting for the next login, and the + registry takes over once it is live. On a desktop with no registry at all + it is the only way the keys arrive, and the price is permanent. """ triggered = pyqtSignal(str) # the name the binding was registered under @@ -154,6 +169,10 @@ class EvdevHotkey(QObject): )) return False self._bindings = parsed + for name, shortcut in bindings.items(): + spec = SHORTCUTS.get(name) + if spec and shortcut: + _REGISTERED[spec.desktop_id] = shortcut self._stop.clear() self._thread = threading.Thread(target=self._loop, args=(devices,), daemon=True) self._thread.start() @@ -164,6 +183,7 @@ class EvdevHotkey(QObject): if self._thread: self._thread.join(timeout=1.5) self._thread = None + _REGISTERED.clear() def _open_devices(self): fds = [] @@ -246,12 +266,6 @@ HOTKEY_PRESSED = 5 # kEventHotKeyPressed PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard HOTKEY_ID_PARAMETER = "hkid" -# What the running listener holds. This is the whole of "installed" on macOS, -# and it lasts as long as the process does: there is no file, and no other -# program to read one. Written by CarbonHotkey.start(), read by the status -# line, so what Settings shows is what the Mac actually gave us. -_REGISTERED = {} - def parse_macos_shortcut(text): """'Cmd+Space' -> (256, 49), or (None, None) when unusable.""" @@ -428,13 +442,39 @@ def _carbon(): # --- the desktop's own shortcut ------------------------------------------- +# The four ways a combination can reach Dikte. Everything below asks backend() +# rather than looking at the session itself, so the name shown, the status read +# back, what Install writes and what the installer promises cannot disagree +# about which one this session got. +KDE = "kde" +GNOME = "gnome" +MACOS = "macos" +LISTENER = "listener" + + def _macos(): return sys.platform == "darwin" -def _gnome(): - desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() - return "gnome" in desktop and shutil.which("gsettings") is not None +def backend(): + """Which shortcut mechanism this session has. + + A desktop only counts when the program that writes its registry is + installed too: a GNOME session without gsettings, or a Plasma one without + kwriteconfig6, has nothing we can register into. Anything unrecognised is + the listener's, which is every other Linux desktop and needs nothing from + the session at all. + """ + if _macos(): + return MACOS + names = os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":") + names = [name.strip() for name in names if name.strip()] + if any("gnome" in name for name in names) and shutil.which("gsettings"): + return GNOME + if (any("kde" in name or "plasma" in name for name in names) + and shutil.which("kwriteconfig6")): + return KDE + return LISTENER def _gnome_path(desktop_id): @@ -581,55 +621,77 @@ def installs_shortcuts(): """Whether this system keeps a shortcut registry to write into. KDE and GNOME do, and something outside Dikte reads it, so the combination - survives Dikte being closed. macOS does not: there is nothing to install, - nothing to remove, and Settings should not offer either. + survives Dikte being closed. macOS and the plain listener do not: there is + nothing to install, nothing to remove, and Settings should not offer either. """ - return not _macos() + return backend() in (KDE, GNOME) def shortcut_needs_restart(): """Whether an installed shortcut waits for the next login before it works. KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding - up as it is written, and macOS never had one to write. + up as it is written, and the other two never had one to write. """ - return not _macos() and not _gnome() + return backend() == KDE def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", desktop_id=DESKTOP_ID): - if _macos(): - _REGISTERED[desktop_id] = shortcut + which = backend() + if which == GNOME: + return install_gnome_shortcut(shortcut, exec_command, name, desktop_id) + if which == KDE: + return install_kde_shortcut(shortcut, exec_command, name, desktop_id) + _REGISTERED[desktop_id] = shortcut + if which == MACOS: return True, t( "Shortcut saved: {shortcut}\nDikte holds this one itself while it " "is running, so it works as soon as the settings are saved.", shortcut=shortcut, ) - if _gnome(): - return install_gnome_shortcut(shortcut, exec_command, name, desktop_id) - return install_kde_shortcut(shortcut, exec_command, name, desktop_id) + return True, t( + "Shortcut saved: {shortcut}\n{desktop} has no shortcut registry to " + "install into, so Dikte listens for this one itself while it is " + "running. It works as soon as the settings are saved.", + shortcut=shortcut, desktop=desktop_name(), + ) def remove_shortcut(desktop_id=DESKTOP_ID): - if _macos(): - _REGISTERED.pop(desktop_id, None) - elif _gnome(): + which = backend() + if which == GNOME: remove_gnome_shortcut(desktop_id) - else: + elif which == KDE: remove_kde_shortcut(desktop_id) + else: + _REGISTERED.pop(desktop_id, None) def shortcut_status(desktop_id=DESKTOP_ID): - if _macos(): - return _REGISTERED.get(desktop_id) - return (gnome_shortcut_status(desktop_id) if _gnome() - else kde_shortcut_status(desktop_id)) + which = backend() + if which == GNOME: + return gnome_shortcut_status(desktop_id) + if which == KDE: + return kde_shortcut_status(desktop_id) + return _REGISTERED.get(desktop_id) def desktop_name(): - if _macos(): + """What to call this session in the interface. + + The listener's desktops get the name the session gave itself, so an i3 user + is told about i3 rather than about a KDE that is not running. + """ + which = backend() + if which == MACOS: return "macOS" - return "GNOME" if _gnome() else "KDE" + if which == GNOME: + return "GNOME" + if which == KDE: + return "KDE" + name = os.environ.get("XDG_CURRENT_DESKTOP", "").split(":")[0].strip() + return name or "This desktop" # --- KDE ------------------------------------------------------------------ @@ -713,9 +775,12 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): """Names of other KDE entries bound to the same combination.""" - if _macos(): - # There is no list to read: macOS answers the question by refusing the - # registration, which CarbonHotkey reports when it asks for the key. + if backend() != KDE: + # Nowhere else has a list to read. macOS answers the question by + # refusing the registration, which CarbonHotkey reports when it asks + # for the key; the other two would only be reading a file their session + # never looks at, and a leftover one from a Plasma install the user has + # since left would refuse perfectly good combinations. return [] try: text = SHORTCUTS_FILE.read_text(encoding="utf-8") diff --git a/i18n.py b/i18n.py index 8f6aa89..6a29fec 100644 --- a/i18n.py +++ b/i18n.py @@ -308,6 +308,8 @@ TR = { "Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}", "No KDE shortcut installed.": "KDE kısayolu kurulu değil.", "Registered in {desktop}: {shortcut}": "{desktop}'da kayıtlı: {shortcut}", + "Held by Dikte while it runs: {shortcut}": + "Dikte çalıştığı sürece tutuyor: {shortcut}", "No global shortcut installed.": "Global kısayol kurulu değil.", "No global shortcut installed. The tray menu starts a meeting too.": "Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.", @@ -351,6 +353,18 @@ TR = { "meantime.": "Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey " "kurulmaz ve o sırada başka hiçbir uygulama bu tuşları almaz.", + "{desktop} keeps no shortcut registry, so Dikte listens for these " + "combinations itself while it is running. Your user has to be able to read " + "/dev/input for that, and the focused application receives the keys as " + "well. To have the desktop own them instead, bind this command in its own " + "configuration, with the last word swapped for pause, cancel, ask or " + "meeting:": + "{desktop} kısayol kaydı tutmaz, bu yüzden Dikte bu kombinasyonları " + "çalıştığı sürece kendisi dinler. Bunun için kullanıcının /dev/input'u " + "okuyabilmesi gerekir, ayrıca tuşlar odaktaki uygulamaya da iletilir. " + "Tuşları masaüstünün sahiplenmesini istersen, son kelimeyi pause, " + "cancel, ask veya meeting ile değiştirerek şu komutu kendi " + "yapılandırmasında bir tuşa bağla:", "Shortcut conflict": "Kısayol çakışması", "{shortcut} is also used by:\n\n{list}\n\nInstall anyway?": "{shortcut} şu girdilerde de kullanılıyor:\n\n{list}\n\nYine de kurulsun mu?", @@ -367,6 +381,12 @@ TR = { "running, so it works as soon as the settings are saved.": "Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi " "tutar, yani ayarlar kaydedilir kaydedilmez çalışır.", + "Shortcut saved: {shortcut}\n{desktop} has no shortcut registry to install " + "into, so Dikte listens for this one itself while it is running. It works " + "as soon as the settings are saved.": + "Kısayol kaydedildi: {shortcut}\n{desktop} kurulacak bir kısayol kaydı " + "tutmadığı için Dikte bunu çalıştığı sürece kendisi dinler. Ayarlar " + "kaydedilir kaydedilmez çalışır.", "Could not reach the macOS shortcut service: {error}": "macOS kısayol servisine ulaşılamadı: {error}", "macOS would not give Dikte {shortcut}; another application already holds it.": diff --git a/install.sh b/install.sh index 7d4f94f..4ff5c22 100755 --- a/install.sh +++ b/install.sh @@ -142,11 +142,28 @@ if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then 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 + # Which of the three mechanisms this session got is Dikte's answer to give, + # not this script's. Guessing from XDG_CURRENT_DESKTOP here is how every + # session that was neither GNOME nor KDE used to be promised a KWin that was + # never running. + case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in + kde) + 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." + ;; + gnome) ;; + *) + if id -nG 2>/dev/null | tr ' ' '\n' | grep -qx input; then + say "Your desktop keeps no shortcut registry, so Dikte listens for these" + say "keys itself while it is running." + else + warn "Your desktop keeps no shortcut registry, so Dikte listens for these" + say "keys itself, and it cannot read /dev/input yet:" + say " sudo usermod -aG input $(id -un) (then log out and back in)" + fi + ;; + esac else warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:" say "dikte shortcut install toggle --combo '$SHORTCUT'" diff --git a/ipc.py b/ipc.py index 541e067..fa436e2 100644 --- a/ipc.py +++ b/ipc.py @@ -28,7 +28,11 @@ def script_path(): def command_for(verb): - """The command line a KDE shortcut runs for one of the verbs.""" + """The command line a desktop's shortcut runs for one of the verbs. + + Also what Settings shows an i3 or XFCE user to paste into their own + configuration, since there is no registry there for Dikte to write into. + """ return f"{sys.executable} {script_path()} {verb}" diff --git a/settings_ui.py b/settings_ui.py index b8610ce..e7a7f6d 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1363,15 +1363,34 @@ class SettingsWindow(QDialog): ) elif hotkey.installs_shortcuts(): explanation = t("The shortcut starts working as soon as it is installed.") - else: + elif hotkey.backend() == hotkey.MACOS: explanation = t( "Dikte asks macOS for these combinations itself, while it is " "running. Nothing is installed, and no other application receives " "them in the meantime." ) + else: + # The desktops nobody writes a backend for. Saying "installed" here + # would be the old bug in words: there is no registry, the listener + # is the whole mechanism, and both of its costs are permanent + # rather than lasting until the next login. + explanation = t( + "{desktop} keeps no shortcut registry, so Dikte listens for " + "these combinations itself while it is running. Your user has " + "to be able to read /dev/input for that, and the focused " + "application receives the keys as well. To have the desktop own " + "them instead, bind this command in its own configuration, with " + "the last word swapped for pause, cancel, ask or meeting:", + desktop=hotkey.desktop_name(), + ) note = QLabel(explanation) note.setWordWrap(True) layout.addWidget(note) + if hotkey.backend() == hotkey.LISTENER: + command = QLineEdit(ipc.command_for("toggle")) + command.setReadOnly(True) + command.setCursorPosition(0) + layout.addWidget(command) layout.addStretch(1) return page @@ -1442,7 +1461,7 @@ class SettingsWindow(QDialog): """The field a global shortcut is typed or picked in.""" box = QComboBox() box.setEditable(True) - box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS" + box.addItems(MAC_SHORTCUTS if hotkey.backend() == hotkey.MACOS else SHORTCUTS) box.setCurrentText("") if placeholder: @@ -1491,8 +1510,9 @@ class SettingsWindow(QDialog): def _install_buttons(install_handler, remove_handler): """Install and Remove, where this system has somewhere to install into. - macOS has not: Dikte asks for the combination itself while it runs, so - there is nothing to write down and nothing to take back out. + macOS has not, and neither has a Linux desktop that keeps no registry: + Dikte holds the combination itself while it runs, so there is nothing + to write down and nothing to take back out. """ if not hotkey.installs_shortcuts(): return [] @@ -1990,11 +2010,16 @@ class SettingsWindow(QDialog): 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 missing - ) + if not current: + status.setText(missing) + elif hotkey.installs_shortcuts(): + status.setText(t("Registered in {desktop}: {shortcut}", + desktop=hotkey.desktop_name(), shortcut=current)) + else: + # Nothing was written anywhere: this is the combination the running + # process is holding, which is the only sense in which it exists. + status.setText(t("Held by Dikte while it runs: {shortcut}", + shortcut=current)) def _cleanup_provider_changed(self): provider = self.cleanup_provider.currentData() or "openrouter" diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index 388c41b..fa15c34 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -174,38 +174,69 @@ class Bindings(DikteTest): class Chooser(DikteTest): - """Which desktop is asked to register the shortcut.""" + """Which mechanism the session gets, and everything keyed off that.""" def setUp(self): super().setUp() self.patch_attr(hotkey.sys, "platform", "linux") + self.addCleanup(hotkey._REGISTERED.clear) @contextlib.contextmanager - def under(self, desktop, has_gsettings=True): - """A session that says it is this desktop, with or without gsettings.""" + def under(self, desktop, tools=True): + """A session that says it is this desktop, with or without its tools.""" with mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": desktop}), \ mock.patch.object(hotkey.shutil, "which", - return_value="/usr/bin/gsettings" - if has_gsettings else None): + return_value="/usr/bin/tool" if tools else None): yield def test_gnome_when_the_session_says_so_and_gsettings_is_there(self): with self.under("GNOME"): + self.assertEqual(hotkey.backend(), hotkey.GNOME) self.assertEqual(hotkey.desktop_name(), "GNOME") - def test_kde_otherwise(self): + def test_kde_when_the_session_says_so_and_kwriteconfig_is_there(self): with self.under("KDE"): + self.assertEqual(hotkey.backend(), hotkey.KDE) self.assertEqual(hotkey.desktop_name(), "KDE") - def test_a_gnome_session_with_no_gsettings_falls_back(self): - """Nothing to write the binding with, so KDE's file is the only try.""" - with self.under("GNOME", has_gsettings=False): - self.assertEqual(hotkey.desktop_name(), "KDE") + def test_a_desktop_with_no_registry_is_the_listeners(self): + """The bug this replaced: i3 was told KDE, and KWin was not running.""" + for desktop in ("i3", "XFCE", "X-Cinnamon", "sway", "MATE", ""): + with self.subTest(desktop=desktop), self.under(desktop): + self.assertEqual(hotkey.backend(), hotkey.LISTENER) + + def test_the_desktop_that_has_no_registry_is_called_by_its_own_name(self): + with self.under("i3"): + self.assertEqual(hotkey.desktop_name(), "i3") + with self.under("XFCE:GNOME-Flashback", tools=False): + self.assertEqual(hotkey.desktop_name(), "XFCE") + with self.under(""): + self.assertEqual(hotkey.desktop_name(), "This desktop") + + def test_a_gnome_session_with_no_gsettings_falls_back_to_the_listener(self): + """Nothing to write the binding with, and KDE's file is not an answer: + KWin is no more running here than it is on i3.""" + with self.under("GNOME", tools=False): + self.assertEqual(hotkey.backend(), hotkey.LISTENER) def test_the_desktop_is_matched_loosely(self): for desktop in ("GNOME", "ubuntu:GNOME", "gnome"): with self.subTest(desktop=desktop), self.under(desktop): - self.assertEqual(hotkey.desktop_name(), "GNOME") + self.assertEqual(hotkey.backend(), hotkey.GNOME) + for desktop in ("KDE", "KDE:plasma", "plasma"): + with self.subTest(desktop=desktop), self.under(desktop): + self.assertEqual(hotkey.backend(), hotkey.KDE) + + def test_only_a_registry_is_installed_into_and_only_kwin_waits(self): + with self.under("KDE"): + self.assertTrue(hotkey.installs_shortcuts()) + self.assertTrue(hotkey.shortcut_needs_restart()) + with self.under("GNOME"): + self.assertTrue(hotkey.installs_shortcuts()) + self.assertFalse(hotkey.shortcut_needs_restart()) + with self.under("i3"): + self.assertFalse(hotkey.installs_shortcuts()) + self.assertFalse(hotkey.shortcut_needs_restart()) def test_installing_goes_to_whichever_it_is(self): with self.under("GNOME"), \ @@ -220,6 +251,20 @@ class Chooser(DikteTest): hotkey.install_shortcut("Ctrl+Space", "dikte toggle") kde.assert_called_once() + def test_a_desktop_with_no_registry_installs_nothing_anywhere(self): + with self.under("i3"), \ + mock.patch.object(hotkey, "install_kde_shortcut") as kde, \ + mock.patch.object(hotkey, "install_gnome_shortcut") as gnome: + ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space") + hotkey.remove_shortcut() + self.assertIsNone(hotkey.shortcut_status()) + kde.assert_not_called() + gnome.assert_not_called() + self.assertTrue(ok) + self.assertIn("i3", message) + self.assertNotIn("log out", message) + def test_removing_and_reading_back_go_to_the_same_one(self): with self.under("GNOME"), \ mock.patch.object(hotkey, "remove_gnome_shortcut") as remove, \ @@ -230,6 +275,17 @@ class Chooser(DikteTest): remove.assert_called_once() status.assert_called_once() + def test_only_kde_has_a_list_of_conflicts_to_read(self): + """A leftover kglobalshortcutsrc from a Plasma the user has since left + would otherwise refuse combinations nothing is holding.""" + rc = self.path("kglobalshortcutsrc") + rc.write_text(SHORTCUTS_RC, encoding="utf-8") + self.patch_attr(hotkey, "SHORTCUTS_FILE", rc) + with self.under("KDE"): + self.assertTrue(hotkey.conflicting_shortcuts("Meta+W")) + with self.under("i3"): + self.assertEqual(hotkey.conflicting_shortcuts("Meta+W"), []) + @linux_only class GnomeAccelerator(DikteTest): @@ -372,6 +428,12 @@ class KdeShortcut(DikteTest): self.rc = self.path("kglobalshortcutsrc") self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps) self.patch_attr(hotkey, "SHORTCUTS_FILE", self.rc) + # A Plasma session with kwriteconfig6 on it, whatever the machine + # running the suite happens to be logged into. + session = mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}) + session.start() + self.addCleanup(session.stop) + self.patch_attr(hotkey.shutil, "which", lambda _name: "/usr/bin/tool") def test_installing_writes_a_desktop_file_kwin_will_launch(self): with mock.patch.object(subprocess, "run", return_value=FakeCompleted()): diff --git a/tests/test_ui.py b/tests/test_ui.py index 9159f6b..444d402 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -6,6 +6,7 @@ save, so a setting added to one half and not the other is silently reset the next time anybody presses Save. That is the failure this catches. """ +import os import sys import unittest from typing import ClassVar @@ -107,13 +108,21 @@ class Settings(DikteTest): # one. Everything else about the window is the same on both. changed = CHANGED platform = "linux" + # A session with no shortcut registry, which is what most Linux desktops + # are. The subclasses below stand on the other two. Pinned rather than + # inherited from whatever the machine running the suite is logged into, + # since half the shortcut tab is built from the answer. + desktop = "i3" + tools: ClassVar[tuple] = () def setUp(self): super().setUp() # No pactl, no model lists over the network, and no modal dialogue # waiting for somebody to press OK. self.enterContext(mock.patch.object(sys, "platform", self.platform)) - self.enterContext(only_these_tools()) + self.enterContext(only_these_tools(*self.tools)) + self.enterContext(mock.patch.dict( + os.environ, {"XDG_CURRENT_DESKTOP": self.desktop})) self.enterContext(mock.patch.object(QMessageBox, "information")) self.enterContext(mock.patch.object(settings_ui.SettingsWindow, "_load_models")) @@ -254,6 +263,31 @@ class Settings(DikteTest): window = self.window(cfg.Config()) self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS)) + def shortcut_tab_text(self, window): + """Everything the shortcut tab says, as one string.""" + return "\n".join( + widget.text() for widget in + window.findChildren(settings_ui.QLabel) + + window.findChildren(settings_ui.QLineEdit) + + window.findChildren(settings_ui.QPushButton) + ) + + def test_the_shortcut_tab_talks_about_this_session_and_no_other(self): + """A desktop with no registry is told the truth: nothing is installed + anywhere, Dikte is listening, and here is the command to bind if the + desktop should own the keys instead. It used to be promised a KWin that + was not running.""" + window = self.window(cfg.Config()) + text = self.shortcut_tab_text(window) + self.assertIn("i3 keeps no shortcut registry", text) + self.assertNotIn("KWin", text) + self.assertIn("dikte.py toggle", text) + # Not a choice to offer where it is the only mechanism there is. + self.assertTrue(window.evdev_enabled.isHidden()) + self.assertFalse([button for button in + window.findChildren(settings_ui.QPushButton) + if "install" in button.text().lower()]) + 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. @@ -415,7 +449,16 @@ class MacSettings(Settings): def test_the_listener_is_not_offered_as_a_choice(self): """It is the whole mechanism there; turning it off would leave nothing.""" window = self.window(cfg.Config()) - self.assertFalse(window.evdev_enabled.isVisible()) + self.assertTrue(window.evdev_enabled.isHidden()) + + def test_the_shortcut_tab_talks_about_this_session_and_no_other(self): + """Carbon holds the keys here, so there is no command to bind and no + /dev/input to be let into.""" + window = self.window(cfg.Config()) + text = self.shortcut_tab_text(window) + self.assertIn("Dikte asks macOS for these combinations", text) + self.assertNotIn("KWin", text) + self.assertNotIn("dikte.py toggle", text) def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self): window = self.window(cfg.Config()) @@ -424,6 +467,27 @@ class MacSettings(Settings): self.assertEqual(offered, paste.MACOS.shortcuts) +class KdeSettings(Settings): + """The same window on the one desktop that keeps a registry and makes you + wait for it. Nothing here is about KDE: it is the rest of the window, + checked on the platform where Install, Remove and the listener's own + checkbox are all on screen.""" + + desktop = "KDE" + tools: ClassVar[tuple] = ("kwriteconfig6",) + + def test_the_shortcut_tab_talks_about_this_session_and_no_other(self): + window = self.window(cfg.Config()) + text = self.shortcut_tab_text(window) + self.assertIn("KWin only reads shortcut settings at startup", text) + self.assertIn("Install as a KDE shortcut", text) + self.assertNotIn("keeps no shortcut registry", text) + self.assertNotIn("dikte.py toggle", text) + # Here it is a choice: the wait for the next login, or the key press + # reaching the focused application as well. + self.assertFalse(window.evdev_enabled.isHidden()) + + class Overlay(DikteTest): def overlay(self, **kwargs): widget = overlay_module.Overlay(**kwargs) diff --git a/uninstall.sh b/uninstall.sh index a9c2ad9..980772a 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -84,17 +84,24 @@ 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. macOS -# keeps no registry: the combinations are held by the running process and are -# gone the moment it stops, so there is nothing here to take back. +# whether they went into KDE's kglobalshortcutsrc, GNOME's gsettings, or +# nowhere at all. macOS and the desktops with no registry hold the combinations +# in the running process, where they are gone the moment it stops, so there is +# nothing there to take back. if ((MACOS)); then say "Nothing to unregister: macOS shortcuts live only while Dikte runs." elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then for which in toggle pause 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." + case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in + kde) + ok "Global shortcuts unregistered" + say "KWin reads that file at startup, so the keys are free after your next login." + ;; + gnome) ok "Global shortcuts unregistered" ;; + *) say "Nothing to unregister: Dikte listened for the keys itself, and they stop with it." ;; + esac else warn "PyQt6 is missing, so the shortcuts were left registered." say "Remove them in your desktop's shortcut settings." diff --git a/update.sh b/update.sh index 18cc40d..e88db46 100755 --- a/update.sh +++ b/update.sh @@ -25,7 +25,7 @@ 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. +# from and the one place that is the same whichever mechanism the session has. setting() { [[ -n "$PY" ]] || return 0 "$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true