From 32dec59d45fad5700a152aa6a52a1992eb4531c3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 29 Jul 2026 15:26:23 +0200 Subject: [PATCH] feat: add shortcut-manager for GNOME keybinding presets --- install.sh | 28 +++++ keybindings.py | 167 +++++++++++++++++++++++++++++ presets.py | 23 ++++ shortcut_manager.py | 249 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 467 insertions(+) create mode 100755 install.sh create mode 100644 keybindings.py create mode 100644 presets.py create mode 100755 shortcut_manager.py diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..6ee32cc --- /dev/null +++ b/install.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Installs cinnamon-shortcut-manager for the current user: +# - copies the app into ~/.local/share/cinnamon-shortcut-manager/ +# - creates a menu launcher (~/.local/share/applications/) +set -euo pipefail + +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$HOME/.local/share/cinnamon-shortcut-manager" +DESKTOP_FILE="$HOME/.local/share/applications/cinnamon-shortcut-manager.desktop" + +mkdir -p "$APP_DIR" "$(dirname "$DESKTOP_FILE")" +cp "$SRC_DIR/shortcut_manager.py" "$SRC_DIR/keybindings.py" "$SRC_DIR/presets.py" "$APP_DIR/" +chmod +x "$APP_DIR/shortcut_manager.py" + +cat > "$DESKTOP_FILE" << EOF +[Desktop Entry] +Type=Application +Name=Custom Shortcuts +Comment=Add, edit and delete custom Cinnamon keyboard shortcuts +Exec=python3 $APP_DIR/shortcut_manager.py +Icon=preferences-desktop-keyboard-shortcuts +Terminal=false +Categories=Settings; +EOF + +echo "Installed -> $APP_DIR" +echo "Menu launcher -> $DESKTOP_FILE" +echo "Run directly with: python3 $APP_DIR/shortcut_manager.py" diff --git a/keybindings.py b/keybindings.py new file mode 100644 index 0000000..1cc2462 --- /dev/null +++ b/keybindings.py @@ -0,0 +1,167 @@ +""" +Backend for reading/writing Cinnamon custom keyboard shortcuts. + +Cinnamon (like GNOME) stores custom shortcuts as a relocatable schema: +each shortcut lives at /org/cinnamon/desktop/keybindings/custom-keybindings/customN/ +under the "org.cinnamon.desktop.keybindings.custom-keybinding" schema, with +three keys: name, command, binding (an array of accelerator strings, in +practice always a single entry). The top-level +"org.cinnamon.desktop.keybindings" schema holds "custom-list", the array of +customN ids Cinnamon actually reads at login. Writing straight to gsettings +is enough to make the shortcut live system-wide - no daemon or restart +needed, Cinnamon's keybinding manager watches these keys. +""" + +import subprocess + +import gi +gi.require_version("Gtk", "3.0") +from gi.repository import Gio, Gtk + +LIST_SCHEMA = "org.cinnamon.desktop.keybindings" +ITEM_SCHEMA = "org.cinnamon.desktop.keybindings.custom-keybinding" +ITEM_PATH_ROOT = "/org/cinnamon/desktop/keybindings/custom-keybindings/" + +CONFLICT_SCHEMAS = [ + "org.cinnamon.desktop.keybindings", + "org.cinnamon.desktop.keybindings.wm", + "org.cinnamon.desktop.keybindings.media-keys", + "org.gnome.desktop.wm.keybindings", + "org.cinnamon.muffin.keybindings", +] + +IGNORED_KEYVALS = { + "Shift_L", "Shift_R", "Control_L", "Control_R", "Alt_L", "Alt_R", + "Super_L", "Super_R", "Meta_L", "Meta_R", "Caps_Lock", "Num_Lock", + "ISO_Level3_Shift", +} + + +class Shortcut: + def __init__(self, customId, name, command, binding): + self.customId = customId + self.name = name + self.command = command + self.binding = binding + + def label(self): + if not self.binding: + return "(unset)" + keyval, mods = Gtk.accelerator_parse(self.binding) + if not keyval: + return self.binding + return Gtk.accelerator_get_label(keyval, mods) + + +def _itemSettings(customId): + return Gio.Settings.new_with_path(ITEM_SCHEMA, ITEM_PATH_ROOT + customId + "/") + + +def listShortcuts(): + listSettings = Gio.Settings.new(LIST_SCHEMA) + ids = list(listSettings.get_strv("custom-list")) + shortcuts = [] + for customId in ids: + item = _itemSettings(customId) + binding = item.get_strv("binding") + shortcuts.append(Shortcut( + customId, + item.get_string("name"), + item.get_string("command"), + binding[0] if binding else "", + )) + return shortcuts + + +def _existingIndices(): + listSettings = Gio.Settings.new(LIST_SCHEMA) + indices = set() + for customId in listSettings.get_strv("custom-list"): + if customId.startswith("custom") and customId[6:].isdigit(): + indices.add(int(customId[6:])) + try: + raw = subprocess.run( + ["dconf", "list", ITEM_PATH_ROOT], + capture_output=True, text=True, check=True, + ).stdout + for line in raw.splitlines(): + entry = line.strip().rstrip("/") + if entry.startswith("custom") and entry[6:].isdigit(): + indices.add(int(entry[6:])) + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return indices + + +def _nextCustomId(): + used = _existingIndices() + nextIndex = 0 + while nextIndex in used: + nextIndex += 1 + return f"custom{nextIndex}" + + +def addShortcut(name, command, binding): + customId = _nextCustomId() + item = _itemSettings(customId) + item.set_string("name", name) + item.set_string("command", command) + item.set_strv("binding", [binding]) + + listSettings = Gio.Settings.new(LIST_SCHEMA) + ids = list(listSettings.get_strv("custom-list")) + ids.append(customId) + listSettings.set_strv("custom-list", ids) + return customId + + +def updateShortcut(customId, name, command, binding): + item = _itemSettings(customId) + item.set_string("name", name) + item.set_string("command", command) + item.set_strv("binding", [binding]) + + +def deleteShortcut(customId): + listSettings = Gio.Settings.new(LIST_SCHEMA) + ids = [existingId for existingId in listSettings.get_strv("custom-list") if existingId != customId] + listSettings.set_strv("custom-list", ids) + try: + subprocess.run( + ["dconf", "reset", "-f", ITEM_PATH_ROOT + customId + "/"], + capture_output=True, check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + pass + + +def findConflicts(binding, excludeCustomId=None): + if not binding: + return [] + conflicts = [] + for shortcut in listShortcuts(): + if shortcut.customId == excludeCustomId: + continue + if shortcut.binding == binding: + conflicts.append(f"custom shortcut \"{shortcut.name}\"") + + for schemaId in CONFLICT_SCHEMAS: + settings = Gio.Settings.new(schemaId) + for key in settings.list_keys(): + value = settings.get_value(key) + if value.get_type_string() != "as": + continue + if binding in value.unpack(): + conflicts.append(f"system shortcut \"{key}\" ({schemaId})") + return conflicts + + +def keyEventToAccelerator(keyval, state): + keyvalName = Gtk.accelerator_name(keyval, 0) + if keyvalName in IGNORED_KEYVALS: + return None + mask = state & Gtk.accelerator_get_default_mod_mask() + accel = Gtk.accelerator_name(keyval, mask) + if not accel or not Gtk.accelerator_valid(keyval, mask): + return None + return accel diff --git a/presets.py b/presets.py new file mode 100644 index 0000000..19a5f52 --- /dev/null +++ b/presets.py @@ -0,0 +1,23 @@ +""" +Quick-fill command presets for the shortcut manager's "new shortcut" dialog. +Only offered when the underlying binary is actually installed, so the list +never suggests a dead command. Purely a convenience for the command entry - +any preset is still just a plain command string once saved. +""" + +import shutil + +_CANDIDATES = [ + ("Terminal", "gnome-terminal", "gnome-terminal"), + ("File manager", "nemo", "nemo"), + ("Lock screen", "cinnamon-screensaver-command", "cinnamon-screensaver-command --lock"), + ("Screenshot - area", "flameshot", "flameshot gui"), + ("Screenshot - full screen", "gnome-screenshot", "gnome-screenshot"), + ("Web browser", "xdg-open", "xdg-open http://"), + ("Mute volume toggle", "amixer", "amixer -q set Master toggle"), + ("Text editor", "gedit", "gedit"), +] + + +def availablePresets(): + return [(label, command) for label, binary, command in _CANDIDATES if shutil.which(binary)] diff --git a/shortcut_manager.py b/shortcut_manager.py new file mode 100755 index 0000000..8d82aff --- /dev/null +++ b/shortcut_manager.py @@ -0,0 +1,249 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +""" +cinnamon-shortcut-manager +========================== + +A small GTK3 GUI for managing Cinnamon custom keyboard shortcuts: add, edit, +delete, and see everything currently registered - no manual editing of +Cinnamon Settings -> Keyboard -> Shortcuts -> Custom Shortcuts required. + +Shortcuts are written straight to gsettings/dconf (see keybindings.py), the +same storage Cinnamon's own keybinding manager reads from, so anything +created here is live system-wide immediately, survives logout/login, and +shows up in Cinnamon Settings too. + +Run directly: python3 shortcut_manager.py +""" + +import os +import sys + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +from gi.repository import Gtk, Gdk, GLib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import keybindings +import presets + + +class AccelCaptureEntry(Gtk.Entry): + """A read-only entry that captures the next key combo pressed into it.""" + + def __init__(self): + super().__init__() + self.set_editable(False) + self.set_placeholder_text("Click here, then press a key combo") + self.accelerator = "" + self.connect("key-press-event", self._onKeyPress) + self.connect("button-press-event", self._onFocusRequest) + + def _onFocusRequest(self, widget, event): + self.grab_focus() + return False + + def _onKeyPress(self, widget, event): + if event.keyval == Gdk.KEY_Escape: + self.setAccelerator("") + return True + if event.keyval == Gdk.KEY_BackSpace and not (event.state & Gtk.accelerator_get_default_mod_mask()): + self.setAccelerator("") + return True + accel = keybindings.keyEventToAccelerator(event.keyval, event.state) + if accel: + self.setAccelerator(accel) + return True + + def setAccelerator(self, accel): + self.accelerator = accel + if not accel: + self.set_text("") + return + keyval, mods = Gtk.accelerator_parse(accel) + self.set_text(Gtk.accelerator_get_label(keyval, mods) if keyval else accel) + + +class ShortcutDialog(Gtk.Dialog): + def __init__(self, parent, shortcut=None): + title = "Edit shortcut" if shortcut else "Add shortcut" + super().__init__(title=title, transient_for=parent, modal=True) + self.set_default_size(420, -1) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK, + ) + self.editingId = shortcut.customId if shortcut else None + + grid = Gtk.Grid(column_spacing=10, row_spacing=8, margin=12) + self.get_content_area().add(grid) + + grid.attach(Gtk.Label(label="Name", xalign=0), 0, 0, 1, 1) + self.nameEntry = Gtk.Entry() + grid.attach(self.nameEntry, 1, 0, 1, 1) + + grid.attach(Gtk.Label(label="Command", xalign=0), 0, 1, 1, 1) + self.commandEntry = Gtk.Entry() + grid.attach(self.commandEntry, 1, 1, 1, 1) + + available = presets.availablePresets() + if available: + grid.attach(Gtk.Label(label="Preset", xalign=0), 0, 2, 1, 1) + presetCombo = Gtk.ComboBoxText() + presetCombo.append_text("(custom command)") + for label, _command in available: + presetCombo.append_text(label) + presetCombo.set_active(0) + presetCombo.connect("changed", self._onPresetChanged, available) + grid.attach(presetCombo, 1, 2, 1, 1) + + grid.attach(Gtk.Label(label="Shortcut", xalign=0), 0, 3, 1, 1) + self.accelEntry = AccelCaptureEntry() + grid.attach(self.accelEntry, 1, 3, 1, 1) + + self.warningLabel = Gtk.Label(xalign=0) + self.warningLabel.set_line_wrap(True) + self.warningLabel.get_style_context().add_class("warning") + grid.attach(self.warningLabel, 0, 4, 2, 1) + + if shortcut: + self.nameEntry.set_text(shortcut.name) + self.commandEntry.set_text(shortcut.command) + self.accelEntry.setAccelerator(shortcut.binding) + + self.accelEntry.connect("key-release-event", lambda w, e: self._refreshConflictWarning()) + self.show_all() + self._refreshConflictWarning() + + def _onPresetChanged(self, combo, available): + index = combo.get_active() + if index <= 0: + return + label, command = available[index - 1] + self.commandEntry.set_text(command) + if not self.nameEntry.get_text(): + self.nameEntry.set_text(label) + + def _refreshConflictWarning(self): + accel = self.accelEntry.accelerator + conflicts = keybindings.findConflicts(accel, excludeCustomId=self.editingId) if accel else [] + if conflicts: + self.warningLabel.set_text("Conflicts with: " + ", ".join(conflicts)) + else: + self.warningLabel.set_text("") + + def result(self): + return self.nameEntry.get_text().strip(), self.commandEntry.get_text().strip(), self.accelEntry.accelerator + + +class ShortcutManagerWindow(Gtk.Window): + def __init__(self): + super().__init__(title="Custom Shortcuts") + self.set_default_size(640, 420) + self.set_border_width(8) + + rootBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add(rootBox) + + toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + rootBox.pack_start(toolbar, False, False, 0) + + addButton = Gtk.Button(label="Add") + addButton.connect("clicked", self._onAdd) + toolbar.pack_start(addButton, False, False, 0) + + self.editButton = Gtk.Button(label="Edit") + self.editButton.connect("clicked", self._onEdit) + self.editButton.set_sensitive(False) + toolbar.pack_start(self.editButton, False, False, 0) + + self.deleteButton = Gtk.Button(label="Delete") + self.deleteButton.connect("clicked", self._onDelete) + self.deleteButton.set_sensitive(False) + toolbar.pack_start(self.deleteButton, False, False, 0) + + refreshButton = Gtk.Button(label="Refresh") + refreshButton.connect("clicked", lambda w: self._reload()) + toolbar.pack_end(refreshButton, False, False, 0) + + self.store = Gtk.ListStore(str, str, str, str) + self.treeView = Gtk.TreeView(model=self.store) + for index, columnTitle in enumerate(["Name", "Shortcut", "Command"]): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(columnTitle, renderer, text=index) + column.set_resizable(True) + column.set_expand(index == 2) + self.treeView.append_column(column) + self.treeView.get_selection().connect("changed", self._onSelectionChanged) + self.treeView.connect("row-activated", lambda *_: self._onEdit(None)) + + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroller.add(self.treeView) + rootBox.pack_start(scroller, True, True, 0) + + self.connect("destroy", Gtk.main_quit) + self._reload() + + def _reload(self): + self.store.clear() + for shortcut in keybindings.listShortcuts(): + self.store.append([shortcut.name, shortcut.label(), shortcut.command, shortcut.customId]) + + def _onSelectionChanged(self, selection): + hasSelection = selection.get_selected()[1] is not None + self.editButton.set_sensitive(hasSelection) + self.deleteButton.set_sensitive(hasSelection) + + def _selectedShortcut(self): + model, treeIter = self.treeView.get_selection().get_selected() + if not treeIter: + return None + customId = model[treeIter][3] + return next((s for s in keybindings.listShortcuts() if s.customId == customId), None) + + def _onAdd(self, widget): + dialog = ShortcutDialog(self) + if dialog.run() == Gtk.ResponseType.OK: + name, command, binding = dialog.result() + if name and command and binding: + keybindings.addShortcut(name, command, binding) + self._reload() + dialog.destroy() + + def _onEdit(self, widget): + shortcut = self._selectedShortcut() + if not shortcut: + return + dialog = ShortcutDialog(self, shortcut) + if dialog.run() == Gtk.ResponseType.OK: + name, command, binding = dialog.result() + if name and command: + keybindings.updateShortcut(shortcut.customId, name, command, binding) + self._reload() + dialog.destroy() + + def _onDelete(self, widget): + shortcut = self._selectedShortcut() + if not shortcut: + return + confirm = Gtk.MessageDialog( + transient_for=self, modal=True, message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, text=f"Delete shortcut \"{shortcut.name}\"?", + ) + response = confirm.run() + confirm.destroy() + if response == Gtk.ResponseType.YES: + keybindings.deleteShortcut(shortcut.customId) + self._reload() + + +def main(): + window = ShortcutManagerWindow() + window.show_all() + Gtk.main() + + +if __name__ == "__main__": + main()