mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Merge pull request #36 from yusufipk/generic-desktop-shortcuts
Stop telling every desktop that it is KDE
This commit is contained in:
@@ -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
|
||||
|
||||
+9
-4
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
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():
|
||||
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)
|
||||
return (gnome_shortcut_status(desktop_id) if _gnome()
|
||||
else kde_shortcut_status(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")
|
||||
|
||||
@@ -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.":
|
||||
|
||||
+18
-1
@@ -163,11 +163,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
|
||||
# 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'"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
+34
-9
@@ -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"
|
||||
|
||||
+73
-11
@@ -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()):
|
||||
|
||||
+66
-2
@@ -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)
|
||||
|
||||
+10
-3
@@ -85,17 +85,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
|
||||
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."
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user