merge: import shortcut-manager sources with history

git-subtree-dir: shortcut-manager
git-subtree-mainline: d4134ccb0d
git-subtree-split: 32dec59d45
This commit is contained in:
dualfroz committed 2026-08-05 19:46:37 +02:00
commit 1d50e864f3
4 files changed
+467

No files matched your search

+28
View File
@@ -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"
+167
View File
@@ -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
+23
View File
@@ -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)]
+249
View File
@@ -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()