From 1544cca15c84bb37508fc074082ef962f58b7796 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sat, 1 Aug 2026 20:32:07 +0700 Subject: [PATCH] Install the GNOME shortcut this already knew how to install install_shortcut(), remove_shortcut(), shortcut_status() and desktop_name() were added and nothing called them: the settings window and the command line both still went straight to the KDE ones. On GNOME, pressing Install ran kwriteconfig6, which is not there, and the three interface strings written for this never appeared. README said the settings window installs a GNOME or KDE shortcut, and it installed neither. Both callers go through the chooser now, and the status line names whichever desktop answered. --- cli.py | 6 +- hotkey.py | 5 +- settings_ui.py | 33 ++++---- tests/test_hotkey.py | 191 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 215 insertions(+), 20 deletions(-) diff --git a/cli.py b/cli.py index b584458..facb667 100644 --- a/cli.py +++ b/cli.py @@ -702,7 +702,7 @@ def cmd_shortcut(opts): if opts.shortcut == "status": rows = {} for name, (desktop_id, _label, key) in SHORTCUTS.items(): - rows[name] = {"registered": hotkey.kde_shortcut_status(desktop_id), + rows[name] = {"registered": hotkey.shortcut_status(desktop_id), "configured": conf[key]} lines = [f"{name:8} {row['registered'] or '(not installed)':16} " f"setting: {row['configured'] or '(none)'}" @@ -713,7 +713,7 @@ def cmd_shortcut(opts): desktop_id, label, key = SHORTCUTS[opts.which] if opts.shortcut == "remove": - hotkey.remove_kde_shortcut(desktop_id) + hotkey.remove_shortcut(desktop_id) return out(opts, {"ok": True, "removed": opts.which}, f"Removed the {opts.which} shortcut.") @@ -727,7 +727,7 @@ def cmd_shortcut(opts): return fail(opts, f"{combo} is also used by: {', '.join(clashes[:6])}. " "Pass --force to install it anyway.", 1, conflicts=clashes) - ok, message = hotkey.install_kde_shortcut( + ok, message = hotkey.install_shortcut( combo, ipc.command_for(opts.which), name=label, desktop_id=desktop_id, ) if not ok: diff --git a/hotkey.py b/hotkey.py index 8347f50..b6b3ed1 100644 --- a/hotkey.py +++ b/hotkey.py @@ -176,7 +176,7 @@ class EvdevHotkey(QObject): return True -# --- KDE custom shortcut -------------------------------------------------- +# --- the desktop's own shortcut ------------------------------------------- def _gnome(): desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() @@ -322,6 +322,9 @@ def shortcut_status(desktop_id=DESKTOP_ID): def desktop_name(): return "GNOME" if _gnome() else "KDE" + +# --- KDE ------------------------------------------------------------------ + def install_kde_shortcut(shortcut, exec_command, name="Dikte: start/stop recording", desktop_id=DESKTOP_ID): """Write the desktop file and the kglobalshortcutsrc entry. diff --git a/settings_ui.py b/settings_ui.py index 9638ae8..1690a6b 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1289,7 +1289,7 @@ class SettingsWindow(QDialog): ) if answer != QMessageBox.StandardButton.Yes: return - ok, message = hotkey.install_kde_shortcut(combo, self.launch_command) + ok, message = hotkey.install_shortcut(combo, self.launch_command) QMessageBox.information(self, t("Shortcut"), message) if ok: self.conf["shortcut"] = combo @@ -1297,14 +1297,15 @@ class SettingsWindow(QDialog): self._refresh_shortcut_status() def _remove_shortcut(self): - hotkey.remove_kde_shortcut() + hotkey.remove_shortcut() self._refresh_shortcut_status() def _refresh_shortcut_status(self): - current = hotkey.kde_shortcut_status() + current = hotkey.shortcut_status() self.shortcut_status.setText( - t("Registered in KDE: {shortcut}", shortcut=current) if current - else t("No KDE shortcut installed.") + t("Registered in {desktop}: {shortcut}", + desktop=hotkey.desktop_name(), shortcut=current) if current + else t("No global shortcut installed.") ) def _install_meeting_shortcut(self): @@ -1322,7 +1323,7 @@ class SettingsWindow(QDialog): ) if answer != QMessageBox.StandardButton.Yes: return - ok, message = hotkey.install_kde_shortcut( + ok, message = hotkey.install_shortcut( combo, self.meeting_command, name="Dikte: start/end a meeting recording", desktop_id=hotkey.MEETING_DESKTOP_ID, ) @@ -1333,14 +1334,15 @@ class SettingsWindow(QDialog): self._refresh_meeting_shortcut_status() def _remove_meeting_shortcut(self): - hotkey.remove_kde_shortcut(hotkey.MEETING_DESKTOP_ID) + hotkey.remove_shortcut(hotkey.MEETING_DESKTOP_ID) self._refresh_meeting_shortcut_status() def _refresh_meeting_shortcut_status(self): - current = hotkey.kde_shortcut_status(hotkey.MEETING_DESKTOP_ID) + current = hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID) self.meeting_shortcut_status.setText( - t("Registered in KDE: {shortcut}", shortcut=current) if current - else t("No KDE shortcut installed. The tray menu starts a meeting too.") + t("Registered in {desktop}: {shortcut}", + desktop=hotkey.desktop_name(), shortcut=current) if current + else t("No global shortcut installed. The tray menu starts a meeting too.") ) # ---- Claude ---------------------------------------------------------- @@ -1360,7 +1362,7 @@ class SettingsWindow(QDialog): ) if answer != QMessageBox.StandardButton.Yes: return - ok, message = hotkey.install_kde_shortcut( + ok, message = hotkey.install_shortcut( combo, self.ask_command, name="Dikte: ask Claude Code", desktop_id=hotkey.ASK_DESKTOP_ID, ) @@ -1371,14 +1373,15 @@ class SettingsWindow(QDialog): self._refresh_ask_shortcut_status() def _remove_ask_shortcut(self): - hotkey.remove_kde_shortcut(hotkey.ASK_DESKTOP_ID) + hotkey.remove_shortcut(hotkey.ASK_DESKTOP_ID) self._refresh_ask_shortcut_status() def _refresh_ask_shortcut_status(self): - current = hotkey.kde_shortcut_status(hotkey.ASK_DESKTOP_ID) + current = hotkey.shortcut_status(hotkey.ASK_DESKTOP_ID) self.assistant_shortcut_status.setText( - t("Registered in KDE: {shortcut}", shortcut=current) if current - else t("No KDE shortcut installed. The tray menu asks it too.") + t("Registered in {desktop}: {shortcut}", + desktop=hotkey.desktop_name(), shortcut=current) if current + else t("No global shortcut installed. The tray menu asks it too.") ) def _assistant_provider_changed(self): diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index b3e0d70..4297724 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -1,5 +1,7 @@ -"""Parsing a shortcut, and the KDE entry it is installed as.""" +"""Parsing a shortcut, and the entry the desktop is asked to register it as.""" +import contextlib +import os import subprocess import unittest from unittest import mock @@ -121,6 +123,193 @@ class Bindings(DikteTest): self.assertEqual(len(listener._bindings[57]), 2) +@linux_only +class Chooser(DikteTest): + """Which desktop is asked to register the shortcut.""" + + @contextlib.contextmanager + def under(self, desktop, has_gsettings=True): + """A session that says it is this desktop, with or without gsettings.""" + 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): + yield + + def test_gnome_when_the_session_says_so_and_gsettings_is_there(self): + with self.under("GNOME"): + self.assertEqual(hotkey.desktop_name(), "GNOME") + + def test_kde_otherwise(self): + with self.under("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_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") + + def test_installing_goes_to_whichever_it_is(self): + with self.under("GNOME"), \ + mock.patch.object(hotkey, "install_gnome_shortcut", + return_value=(True, "ok")) as gnome: + hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + gnome.assert_called_once() + + with self.under("KDE"), \ + mock.patch.object(hotkey, "install_kde_shortcut", + return_value=(True, "ok")) as kde: + hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + kde.assert_called_once() + + 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, \ + mock.patch.object(hotkey, "gnome_shortcut_status", + return_value="Ctrl+Space") as status: + hotkey.remove_shortcut() + self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space") + remove.assert_called_once() + status.assert_called_once() + + +@linux_only +class GnomeAccelerator(DikteTest): + """Qt spells a combination one way, GNOME another.""" + + def test_the_default_shortcut(self): + self.assertEqual(hotkey.gnome_accelerator("Ctrl+Space"), "Space") + + def test_several_modifiers_keep_their_order(self): + self.assertEqual(hotkey.gnome_accelerator("Ctrl+Alt+A"), "a") + + def test_the_synonyms(self): + self.assertEqual(hotkey.gnome_accelerator("Meta+A"), + hotkey.gnome_accelerator("Super+A")) + self.assertEqual(hotkey.gnome_accelerator("Control+A"), + hotkey.gnome_accelerator("Ctrl+A")) + + def test_a_modifier_repeated_is_written_once(self): + self.assertEqual(hotkey.gnome_accelerator("Ctrl+Control+A"), "a") + + def test_modifiers_with_no_key_are_not_a_shortcut(self): + self.assertEqual(hotkey.gnome_accelerator("Ctrl+Alt"), "") + self.assertEqual(hotkey.gnome_accelerator(""), "") + + def test_what_goes_out_comes_back_the_way_dikte_writes_it(self): + for shortcut in ("Ctrl+Space", "Ctrl+Alt+A", "Shift+F9", "Super+M"): + with self.subTest(shortcut=shortcut): + accelerator = hotkey.gnome_accelerator(shortcut) + self.assertEqual(hotkey.display_accelerator(accelerator), shortcut) + + def test_the_control_spelling_gnome_also_uses(self): + self.assertEqual(hotkey.display_accelerator("a"), "Ctrl+A") + + def test_an_empty_binding(self): + self.assertEqual(hotkey.display_accelerator(""), "") + + +@linux_only +class GsettingsArray(DikteTest): + def test_a_list_of_paths(self): + self.assertEqual( + hotkey._gsettings_array("['/org/gnome/one/', '/org/gnome/two/']"), + ["/org/gnome/one/", "/org/gnome/two/"]) + + def test_the_empty_form_gsettings_prints(self): + self.assertEqual(hotkey._gsettings_array("@as []"), []) + + def test_nothing_at_all(self): + self.assertEqual(hotkey._gsettings_array(""), []) + + def test_something_that_is_not_an_array(self): + with self.assertRaises(ValueError): + hotkey._gsettings_array("'just a string'") + + +@linux_only +class GnomeShortcut(DikteTest): + """The gsettings calls, without a session bus to make them against.""" + + def setUp(self): + super().setUp() + self.enterContext(mock.patch.dict(os.environ, + {"XDG_CURRENT_DESKTOP": "GNOME"})) + self.enterContext(mock.patch.object(hotkey.shutil, "which", + return_value="/usr/bin/gsettings")) + + def gsettings(self, listed="@as []", binding="'Space'"): + def run(cmd, **kwargs): + if cmd[1] == "get" and cmd[3] == "custom-keybindings": + return FakeCompleted(stdout=listed) + return FakeCompleted(stdout=binding) + return mock.patch.object(subprocess, "run", side_effect=run) + + def written(self, run, key): + """The value the last `gsettings set ... ` was given.""" + for call in reversed(run.mock_calls): + cmd = call.args[0] if call.args else [] + if len(cmd) > 4 and cmd[1] == "set" and cmd[3] == key: + return cmd[4] + return None + + def test_installing_registers_the_path_the_name_and_the_binding(self): + with self.gsettings() as run: + ok, message = hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + self.assertTrue(ok) + self.assertIn("Ctrl+Space", message) + self.assertIn(hotkey.DESKTOP_ID.removesuffix(".desktop"), + self.written(run, "custom-keybindings")) + self.assertEqual(self.written(run, "command"), repr("dikte toggle")) + self.assertEqual(self.written(run, "binding"), repr("Space")) + + def test_installing_twice_does_not_list_the_path_twice(self): + path = hotkey._gnome_path(hotkey.DESKTOP_ID) + with self.gsettings(listed=repr([path])) as run: + hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + self.assertIsNone(self.written(run, "custom-keybindings")) + + def test_each_verb_gets_its_own_path(self): + self.assertNotEqual(hotkey._gnome_path(hotkey.DESKTOP_ID), + hotkey._gnome_path(hotkey.ASK_DESKTOP_ID)) + + def test_a_shortcut_gnome_cannot_express(self): + with self.gsettings(): + ok, message = hotkey.install_shortcut("Ctrl+Alt", "dikte toggle") + self.assertFalse(ok) + self.assertIn("Ctrl+Alt", message) + + def test_no_session_bus_to_talk_to(self): + with mock.patch.object(subprocess, "run", side_effect=OSError("no bus")): + ok, _ = hotkey.install_shortcut("Ctrl+Space", "dikte toggle") + self.assertFalse(ok) + + def test_reading_back_a_shortcut_that_is_registered(self): + path = hotkey._gnome_path(hotkey.DESKTOP_ID) + with self.gsettings(listed=repr([path])): + self.assertEqual(hotkey.shortcut_status(), "Ctrl+Space") + + def test_reading_back_one_that_is_not(self): + with self.gsettings(listed="@as []"): + self.assertIsNone(hotkey.shortcut_status()) + + def test_removing_takes_the_path_off_the_list(self): + path = hotkey._gnome_path(hotkey.DESKTOP_ID) + with self.gsettings(listed=repr([path, "/org/gnome/other/"])) as run: + hotkey.remove_shortcut() + self.assertEqual(self.written(run, "custom-keybindings"), + repr(["/org/gnome/other/"])) + + def test_removing_one_that_was_never_installed(self): + with self.gsettings(listed="@as []"): + hotkey.remove_shortcut() # must not raise + + @linux_only class KdeShortcut(DikteTest): def setUp(self):