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
This commit is contained in:
2026-08-16 13:41:27 +03:00
parent 416c788421
commit 1f5ea70fa6
13 changed files with 360 additions and 85 deletions
+10 -4
View File
@@ -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 missing punctuation), and the result lands in your clipboard and is pasted into
whatever window you were typing in. 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 dependencies beyond system packages: just the Python standard library, 3.11 or
newer, and PyQt6. newer, and PyQt6.
@@ -184,14 +185,19 @@ running.
right-click to delete. right-click to delete.
- **Turkish and English interface**, following the system locale by default. - **Turkish and English interface**, following the system locale by default.
## The global shortcuts need one logout ## The global shortcuts, and the logout KDE needs
KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh` KWin only reads `kglobalshortcutsrc` at startup, so the shortcuts `install.sh`
writes will not fire until you log out and back in. Until then, Settings → writes will not fire until you log out and back in. Until then, Settings →
Shortcuts → **built-in listener** reads `/dev/input` and catches the combination Shortcuts → **built-in listener** reads `/dev/input` and catches the combination
itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches itself. The difference: it does not swallow the key, so `Ctrl+Space` also reaches
the focused application (some editors will pop up autocomplete). The listener the focused application (some editors will pop up autocomplete). The listener
needs your user in the `input` group: `sudo usermod -aG input $USER`. needs your user in the `input` group: `sudo usermod -aG input $USER`. 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 ## Layout
@@ -211,7 +217,7 @@ vad.py deciding whether a recording holds speech at all
filetranscribe.py file transcription: ffmpeg, chunking, timestamps filetranscribe.py file transcription: ffmpeg, chunking, timestamps
overlay.py the corner indicator overlay.py the corner indicator
settings_ui.py settings window 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 paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics
trayicon.py the tray icons, drawn where there is no icon theme trayicon.py the tray icons, drawn where there is no icon theme
i18n.py the string table i18n.py the string table
+9 -4
View File
@@ -4,7 +4,8 @@
çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama), çevrilir, bir model transkripti temizler (ıı'lar, tekrarlar, eksik noktalama),
sonuç panoya kopyalanır ve o an yazdığın pencereye yapıştırılır. 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 paketleri dışında bağımlılığı yok: sadece Python standart kütüphanesi (3.11 veya
üstü) ve PyQt6. üstü) ve PyQt6.
@@ -182,14 +183,18 @@ olmasını ister.
silebilirsin. silebilirsin.
- **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler. - **Türkçe ve İngilizce arayüz**, varsayılan olarak sistem dilini izler.
## Global kı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 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 → yazdığı kısayollar oturumu yeniden açana kadar tetiklenmez. O zamana kadar Ayarlar →
Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi Kısayollar → **yerleşik dinleyici** `/dev/input` üzerinden kombinasyonu kendisi
yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir yakalar. Tek farkı: tuşu yutmaz, yani `Ctrl+Space` odaktaki uygulamaya da iletilir
(bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input` (bazı editörlerde otomatik tamamlama açılabilir). Dinleyici kullanıcının `input`
grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. grubunda olmasını gerektirir: `sudo usermod -aG input $USER`. 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 ## 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ı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları
overlay.py köşedeki gösterge overlay.py köşedeki gösterge
settings_ui.py ayarlar penceresi 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 paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics
trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş
i18n.py metin tablosu i18n.py metin tablosu
+3 -3
View File
@@ -35,9 +35,9 @@ import paste
NOT_RUNNING = 3 NOT_RUNNING = 3
# Verbs that start the application when none is running, which is what the KDE # Verbs that start the application when none is running, which is what a
# shortcut has always relied on: press the key on a fresh login and Dikte comes # shortcut registered with the desktop has always relied on: press the key on a
# up recording. # fresh login and Dikte comes up recording.
GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"} GUI_VERBS = {"", "settings", "toggle", "ask", "meeting"}
# Asking a process that is not there to stop, cancel or quit is not a failure; # Asking a process that is not there to stop, cancel or quit is not a failure;
+2 -2
View File
@@ -365,8 +365,8 @@ class Dikte:
def _external(self, name, handler): def _external(self, name, handler):
# The built-in listener sees the key press the instant it happens, so a # The built-in listener sees the key press the instant it happens, so a
# toggle arriving right behind one is the KDE shortcut catching up on # toggle arriving right behind one is the desktop's own shortcut catching
# that same press. Its lateness is also the proof we were waiting for # 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 # that the shortcut is live, which leaves the listener with nothing to
# do but double every press. # do but double every press.
# Where nothing was installed there is no shortcut to catch up, and # Where nothing was installed there is no shortcut to catch up, and
+103 -38
View File
@@ -1,11 +1,16 @@
"""Global shortcuts: the desktop's own registry, plus a listener of our own. """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 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 to be told about it, and something has to be listening. Two desktops keep a
desktop's shortcut registry (KDE's file, GNOME's gsettings) and a reader of registry we can write into and read back, and something outside Dikte acts on
/dev/input for the wait until the registry is live. macOS has no registry to it: KDE has a file, GNOME has gsettings. There the /dev/input listener only
write into: the application asks Carbon for the combination while it runs, so covers the wait until the registry is live.
there the listener is not a fallback but the whole mechanism.
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 import ast
@@ -105,13 +110,23 @@ def parse_shortcut(text):
return mods, key 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 ---------------------------------------------------- # --- built-in listener ----------------------------------------------------
class EvdevHotkey(QObject): class EvdevHotkey(QObject):
"""Catches global shortcuts by reading /dev/input directly. """Catches global shortcuts by reading /dev/input directly.
It does not swallow the key; the focused application sees the combination 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 triggered = pyqtSignal(str) # the name the binding was registered under
@@ -154,6 +169,10 @@ class EvdevHotkey(QObject):
)) ))
return False return False
self._bindings = parsed 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._stop.clear()
self._thread = threading.Thread(target=self._loop, args=(devices,), daemon=True) self._thread = threading.Thread(target=self._loop, args=(devices,), daemon=True)
self._thread.start() self._thread.start()
@@ -164,6 +183,7 @@ class EvdevHotkey(QObject):
if self._thread: if self._thread:
self._thread.join(timeout=1.5) self._thread.join(timeout=1.5)
self._thread = None self._thread = None
_REGISTERED.clear()
def _open_devices(self): def _open_devices(self):
fds = [] fds = []
@@ -246,12 +266,6 @@ HOTKEY_PRESSED = 5 # kEventHotKeyPressed
PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard PARAMETER_ANY = "----" # kEventParamDirectObject / typeWildCard
HOTKEY_ID_PARAMETER = "hkid" 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): def parse_macos_shortcut(text):
"""'Cmd+Space' -> (256, 49), or (None, None) when unusable.""" """'Cmd+Space' -> (256, 49), or (None, None) when unusable."""
@@ -428,13 +442,39 @@ def _carbon():
# --- the desktop's own shortcut ------------------------------------------- # --- 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(): def _macos():
return sys.platform == "darwin" return sys.platform == "darwin"
def _gnome(): def backend():
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() """Which shortcut mechanism this session has.
return "gnome" in desktop and shutil.which("gsettings") is not None
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): def _gnome_path(desktop_id):
@@ -581,55 +621,77 @@ def installs_shortcuts():
"""Whether this system keeps a shortcut registry to write into. """Whether this system keeps a shortcut registry to write into.
KDE and GNOME do, and something outside Dikte reads it, so the combination 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, survives Dikte being closed. macOS and the plain listener do not: there is
nothing to remove, and Settings should not offer either. nothing to install, nothing to remove, and Settings should not offer either.
""" """
return not _macos() return backend() in (KDE, GNOME)
def shortcut_needs_restart(): def shortcut_needs_restart():
"""Whether an installed shortcut waits for the next login before it works. """Whether an installed shortcut waits for the next login before it works.
KWin reads kglobalshortcutsrc once, when it starts. GNOME picks a binding 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", def install_shortcut(shortcut, exec_command, name="Dikte: start/stop recording",
desktop_id=DESKTOP_ID): desktop_id=DESKTOP_ID):
if _macos(): which = backend()
_REGISTERED[desktop_id] = shortcut 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( return True, t(
"Shortcut saved: {shortcut}\nDikte holds this one itself while it " "Shortcut saved: {shortcut}\nDikte holds this one itself while it "
"is running, so it works as soon as the settings are saved.", "is running, so it works as soon as the settings are saved.",
shortcut=shortcut, shortcut=shortcut,
) )
if _gnome(): return True, t(
return install_gnome_shortcut(shortcut, exec_command, name, desktop_id) "Shortcut saved: {shortcut}\n{desktop} has no shortcut registry to "
return install_kde_shortcut(shortcut, exec_command, name, desktop_id) "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): def remove_shortcut(desktop_id=DESKTOP_ID):
if _macos(): which = backend()
_REGISTERED.pop(desktop_id, None) if which == GNOME:
elif _gnome():
remove_gnome_shortcut(desktop_id) remove_gnome_shortcut(desktop_id)
else: elif which == KDE:
remove_kde_shortcut(desktop_id) remove_kde_shortcut(desktop_id)
else:
_REGISTERED.pop(desktop_id, None)
def shortcut_status(desktop_id=DESKTOP_ID): def shortcut_status(desktop_id=DESKTOP_ID):
if _macos(): which = backend()
return _REGISTERED.get(desktop_id) if which == GNOME:
return (gnome_shortcut_status(desktop_id) if _gnome() return gnome_shortcut_status(desktop_id)
else kde_shortcut_status(desktop_id)) if which == KDE:
return kde_shortcut_status(desktop_id)
return _REGISTERED.get(desktop_id)
def desktop_name(): 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 "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 ------------------------------------------------------------------ # --- KDE ------------------------------------------------------------------
@@ -713,9 +775,12 @@ def kde_shortcut_status(desktop_id=DESKTOP_ID):
def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID): def conflicting_shortcuts(shortcut, desktop_id=DESKTOP_ID):
"""Names of other KDE entries bound to the same combination.""" """Names of other KDE entries bound to the same combination."""
if _macos(): if backend() != KDE:
# There is no list to read: macOS answers the question by refusing the # Nowhere else has a list to read. macOS answers the question by
# registration, which CarbonHotkey reports when it asks for the key. # 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 [] return []
try: try:
text = SHORTCUTS_FILE.read_text(encoding="utf-8") text = SHORTCUTS_FILE.read_text(encoding="utf-8")
+20
View File
@@ -308,6 +308,8 @@ TR = {
"Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}", "Registered in KDE: {shortcut}": "KDE'de kayıtlı: {shortcut}",
"No KDE shortcut installed.": "KDE kısayolu kurulu değil.", "No KDE shortcut installed.": "KDE kısayolu kurulu değil.",
"Registered in {desktop}: {shortcut}": "{desktop}'da kayıtlı: {shortcut}", "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.": "Global kısayol kurulu değil.",
"No global shortcut installed. The tray menu starts a meeting too.": "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.", "Global kısayol kurulu değil. Toplantı tepsi menüsünden de başlatılabilir.",
@@ -351,6 +353,18 @@ TR = {
"meantime.": "meantime.":
"Dikte bu kombinasyonları çalışırken macOS'tan kendisi ister. Hiçbir şey " "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.", "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 conflict": "Kısayol çakışması",
"{shortcut} is also used by:\n\n{list}\n\nInstall anyway?": "{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?", "{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.": "running, so it works as soon as the settings are saved.":
"Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi " "Kısayol kaydedildi: {shortcut}\nDikte bunu çalıştığı sürece kendisi "
"tutar, yani ayarlar kaydedilir kaydedilmez çalışır.", "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}": "Could not reach the macOS shortcut service: {error}":
"macOS kısayol servisine ulaşılamadı: {error}", "macOS kısayol servisine ulaşılamadı: {error}",
"macOS would not give Dikte {shortcut}; another application already holds it.": "macOS would not give Dikte {shortcut}; another application already holds it.":
+22 -5
View File
@@ -142,11 +142,28 @@ if python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then
if [[ -n "$CANCEL_SHORTCUT" ]]; then if [[ -n "$CANCEL_SHORTCUT" ]]; then
register cancel "$CANCEL_SHORTCUT" "Discard the recording" register cancel "$CANCEL_SHORTCUT" "Discard the recording"
fi fi
if [[ "${XDG_CURRENT_DESKTOP:-}" != *[Gg][Nn][Oo][Mm][Ee]* ]]; then # Which of the three mechanisms this session got is Dikte's answer to give,
warn "KWin only reads these at startup, so they go live after your next" # not this script's. Guessing from XDG_CURRENT_DESKTOP here is how every
say "login. Until then open Settings → Shortcuts and turn on the" # session that was neither GNOME nor KDE used to be promised a KWin that was
say "built-in listener to use them right away." # never running.
fi 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 else
warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:" warn "PyQt6 is missing, so no shortcut was registered. Install it, then run:"
say "dikte shortcut install toggle --combo '$SHORTCUT'" say "dikte shortcut install toggle --combo '$SHORTCUT'"
+5 -1
View File
@@ -28,7 +28,11 @@ def script_path():
def command_for(verb): 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}" return f"{sys.executable} {script_path()} {verb}"
+34 -9
View File
@@ -1363,15 +1363,34 @@ class SettingsWindow(QDialog):
) )
elif hotkey.installs_shortcuts(): elif hotkey.installs_shortcuts():
explanation = t("The shortcut starts working as soon as it is installed.") explanation = t("The shortcut starts working as soon as it is installed.")
else: elif hotkey.backend() == hotkey.MACOS:
explanation = t( explanation = t(
"Dikte asks macOS for these combinations itself, while it is " "Dikte asks macOS for these combinations itself, while it is "
"running. Nothing is installed, and no other application receives " "running. Nothing is installed, and no other application receives "
"them in the meantime." "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 = QLabel(explanation)
note.setWordWrap(True) note.setWordWrap(True)
layout.addWidget(note) 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) layout.addStretch(1)
return page return page
@@ -1442,7 +1461,7 @@ class SettingsWindow(QDialog):
"""The field a global shortcut is typed or picked in.""" """The field a global shortcut is typed or picked in."""
box = QComboBox() box = QComboBox()
box.setEditable(True) box.setEditable(True)
box.addItems(MAC_SHORTCUTS if hotkey.desktop_name() == "macOS" box.addItems(MAC_SHORTCUTS if hotkey.backend() == hotkey.MACOS
else SHORTCUTS) else SHORTCUTS)
box.setCurrentText("") box.setCurrentText("")
if placeholder: if placeholder:
@@ -1491,8 +1510,9 @@ class SettingsWindow(QDialog):
def _install_buttons(install_handler, remove_handler): def _install_buttons(install_handler, remove_handler):
"""Install and Remove, where this system has somewhere to install into. """Install and Remove, where this system has somewhere to install into.
macOS has not: Dikte asks for the combination itself while it runs, so macOS has not, and neither has a Linux desktop that keeps no registry:
there is nothing to write down and nothing to take back out. 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(): if not hotkey.installs_shortcuts():
return [] return []
@@ -1990,11 +2010,16 @@ class SettingsWindow(QDialog):
def _refresh_shortcut_status(self, which): def _refresh_shortcut_status(self, which):
_box, status, missing = self._shortcut_rows[which] _box, status, missing = self._shortcut_rows[which]
current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id) current = hotkey.shortcut_status(hotkey.SHORTCUTS[which].desktop_id)
status.setText( if not current:
t("Registered in {desktop}: {shortcut}", status.setText(missing)
desktop=hotkey.desktop_name(), shortcut=current) if current elif hotkey.installs_shortcuts():
else missing 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): def _cleanup_provider_changed(self):
provider = self.cleanup_provider.currentData() or "openrouter" provider = self.cleanup_provider.currentData() or "openrouter"
+73 -11
View File
@@ -174,38 +174,69 @@ class Bindings(DikteTest):
class Chooser(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): def setUp(self):
super().setUp() super().setUp()
self.patch_attr(hotkey.sys, "platform", "linux") self.patch_attr(hotkey.sys, "platform", "linux")
self.addCleanup(hotkey._REGISTERED.clear)
@contextlib.contextmanager @contextlib.contextmanager
def under(self, desktop, has_gsettings=True): def under(self, desktop, tools=True):
"""A session that says it is this desktop, with or without gsettings.""" """A session that says it is this desktop, with or without its tools."""
with mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": desktop}), \ with mock.patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": desktop}), \
mock.patch.object(hotkey.shutil, "which", mock.patch.object(hotkey.shutil, "which",
return_value="/usr/bin/gsettings" return_value="/usr/bin/tool" if tools else None):
if has_gsettings else None):
yield yield
def test_gnome_when_the_session_says_so_and_gsettings_is_there(self): def test_gnome_when_the_session_says_so_and_gsettings_is_there(self):
with self.under("GNOME"): with self.under("GNOME"):
self.assertEqual(hotkey.backend(), hotkey.GNOME)
self.assertEqual(hotkey.desktop_name(), "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"): with self.under("KDE"):
self.assertEqual(hotkey.backend(), hotkey.KDE)
self.assertEqual(hotkey.desktop_name(), "KDE") self.assertEqual(hotkey.desktop_name(), "KDE")
def test_a_gnome_session_with_no_gsettings_falls_back(self): def test_a_desktop_with_no_registry_is_the_listeners(self):
"""Nothing to write the binding with, so KDE's file is the only try.""" """The bug this replaced: i3 was told KDE, and KWin was not running."""
with self.under("GNOME", has_gsettings=False): for desktop in ("i3", "XFCE", "X-Cinnamon", "sway", "MATE", ""):
self.assertEqual(hotkey.desktop_name(), "KDE") 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): def test_the_desktop_is_matched_loosely(self):
for desktop in ("GNOME", "ubuntu:GNOME", "gnome"): for desktop in ("GNOME", "ubuntu:GNOME", "gnome"):
with self.subTest(desktop=desktop), self.under(desktop): 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): def test_installing_goes_to_whichever_it_is(self):
with self.under("GNOME"), \ with self.under("GNOME"), \
@@ -220,6 +251,20 @@ class Chooser(DikteTest):
hotkey.install_shortcut("Ctrl+Space", "dikte toggle") hotkey.install_shortcut("Ctrl+Space", "dikte toggle")
kde.assert_called_once() 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): def test_removing_and_reading_back_go_to_the_same_one(self):
with self.under("GNOME"), \ with self.under("GNOME"), \
mock.patch.object(hotkey, "remove_gnome_shortcut") as remove, \ mock.patch.object(hotkey, "remove_gnome_shortcut") as remove, \
@@ -230,6 +275,17 @@ class Chooser(DikteTest):
remove.assert_called_once() remove.assert_called_once()
status.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 @linux_only
class GnomeAccelerator(DikteTest): class GnomeAccelerator(DikteTest):
@@ -372,6 +428,12 @@ class KdeShortcut(DikteTest):
self.rc = self.path("kglobalshortcutsrc") self.rc = self.path("kglobalshortcutsrc")
self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps) self.patch_attr(hotkey, "APPLICATIONS_DIR", self.apps)
self.patch_attr(hotkey, "SHORTCUTS_FILE", self.rc) 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): def test_installing_writes_a_desktop_file_kwin_will_launch(self):
with mock.patch.object(subprocess, "run", return_value=FakeCompleted()): with mock.patch.object(subprocess, "run", return_value=FakeCompleted()):
+66 -2
View File
@@ -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. next time anybody presses Save. That is the failure this catches.
""" """
import os
import sys import sys
import unittest import unittest
from typing import ClassVar from typing import ClassVar
@@ -107,13 +108,21 @@ class Settings(DikteTest):
# one. Everything else about the window is the same on both. # one. Everything else about the window is the same on both.
changed = CHANGED changed = CHANGED
platform = "linux" 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): def setUp(self):
super().setUp() super().setUp()
# No pactl, no model lists over the network, and no modal dialogue # No pactl, no model lists over the network, and no modal dialogue
# waiting for somebody to press OK. # waiting for somebody to press OK.
self.enterContext(mock.patch.object(sys, "platform", self.platform)) 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(QMessageBox, "information"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow, self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
"_load_models")) "_load_models"))
@@ -254,6 +263,31 @@ class Settings(DikteTest):
window = self.window(cfg.Config()) window = self.window(cfg.Config())
self.assertEqual(set(window._shortcut_rows), set(hotkey.SHORTCUTS)) 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): def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self):
"""The application is unusable without the toggle, so that one box """The application is unusable without the toggle, so that one box
falls back. The rest stay empty, which is how they are switched off. 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): def test_the_listener_is_not_offered_as_a_choice(self):
"""It is the whole mechanism there; turning it off would leave nothing.""" """It is the whole mechanism there; turning it off would leave nothing."""
window = self.window(cfg.Config()) 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): def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config()) window = self.window(cfg.Config())
@@ -424,6 +467,27 @@ class MacSettings(Settings):
self.assertEqual(offered, paste.MACOS.shortcuts) 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): class Overlay(DikteTest):
def overlay(self, **kwargs): def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs) widget = overlay_module.Overlay(**kwargs)
+12 -5
View File
@@ -84,17 +84,24 @@ echo "──────────────────"
# 1. Global shortcuts ------------------------------------------------------ # 1. Global shortcuts ------------------------------------------------------
# Handed to Dikte while it can still run, because it is the half that knows # 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 # whether they went into KDE's kglobalshortcutsrc, GNOME's gsettings, or
# keeps no registry: the combinations are held by the running process and are # nowhere at all. macOS and the desktops with no registry hold the combinations
# gone the moment it stops, so there is nothing here to take back. # in the running process, where they are gone the moment it stops, so there is
# nothing there to take back.
if ((MACOS)); then if ((MACOS)); then
say "Nothing to unregister: macOS shortcuts live only while Dikte runs." say "Nothing to unregister: macOS shortcuts live only while Dikte runs."
elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then
for which in toggle pause cancel ask meeting; do for which in toggle pause cancel ask meeting; do
"$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true
done done
ok "Global shortcuts unregistered" case "$("$PY" -c 'import sys; sys.path.insert(0, sys.argv[1]); import hotkey; print(hotkey.backend())' "$DIR" 2>/dev/null)" in
say "KWin reads that file at startup, so the keys are free after your next login." 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 else
warn "PyQt6 is missing, so the shortcuts were left registered." warn "PyQt6 is missing, so the shortcuts were left registered."
say "Remove them in your desktop's shortcut settings." say "Remove them in your desktop's shortcut settings."
+1 -1
View File
@@ -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; } 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 # 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() { setting() {
[[ -n "$PY" ]] || return 0 [[ -n "$PY" ]] || return 0
"$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true "$PY" "$DIR/dikte.py" config get "$1" 2>/dev/null || true