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