merge: import shortcut-manager sources with history
git-subtree-dir: shortcut-manager git-subtree-mainline:d4134ccb0dgit-subtree-split:32dec59d45
This commit is contained in:
commit
1d50e864f3
4 files changed
+467
No files matched your search
Executable
+28
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
Executable
+249
@@ -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()
|
||||
Reference in new issue
Block a user