168 lines
5.3 KiB
Python
168 lines
5.3 KiB
Python
"""
|
|
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
|