#! python3 # -*- coding: utf-8 -*- # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 Karim Gabriele Varano """ aliases/loader.py Liest shortcuts_default.json + User-Overrides aus dossier_settings.json, merged und wendet via Rhino.ApplicationSettings.CommandAliasList / ShortcutKeySettings an. Wird einmal beim Rhino-Start aus startup.py aufgerufen (idempotent — SetMacro ueberschreibt). User-Override-Format in dossier_settings.json: "shortcuts_user": { "": "" // leer = Default } """ import os import json import Rhino _HERE = os.path.dirname(os.path.abspath(__file__)) _quit_xml_pairs = [] # gefuellt in apply_all(), genutzt vom Closing-Hook _DEFAULTS_PATH = os.path.join(_HERE, "shortcuts_default.json") _SETTINGS_PATHS = [ os.path.expanduser("~/Library/Application Support/ch.gabrielevarano.Dossier/dossier_settings.json"), os.path.expanduser("~/Library/Application Support/RhinoPanel/dossier_settings.json"), # legacy ] def _read_defaults(): try: with open(_DEFAULTS_PATH, "r", encoding="utf-8") as f: data = json.load(f) out = {} for k, v in data.items(): if k.startswith("_"): continue if not isinstance(v, dict): continue out[k] = v return out except Exception as ex: print("[ALIAS-LOADER] Defaults lesen:", ex) return {} def _read_user_overrides(): """Liest 'shortcuts_user' aus dossier_settings.json. Format: { action_id: trigger_string }. Leerer String / None = Default.""" for path in _SETTINGS_PATHS: if not os.path.exists(path): continue try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) so = data.get("shortcuts_user") if isinstance(so, dict): return so except Exception as ex: print("[ALIAS-LOADER] Settings lesen:", ex) return {} def _expand_macro(macro): """Platzhalter {ALIASDIR} → absoluter Pfad zum aliases/-Ordner.""" return macro.replace("{ALIASDIR}", _HERE) # Sonderzeichen → Rhino-Enum-Namen (Mac XML + ShortcutKey-API) _SPECIAL_KEY_NAMES = { "-": "Minus", "+": "Plus", "=": "Equals", "/": "Slash", "\\": "Backslash", ".": "Period", ",": "Comma", ";": "Semicolon", "'": "Quote", "`": "Backquote", "[": "OpenBracket", "]": "CloseBracket", } def _normalize_key_part(key_part): """Mapped Sonderzeichen wie '-' auf Enum-Namen ('Minus'). Buchstaben/F-Keys bleiben unveraendert (Case-preserved).""" if key_part in _SPECIAL_KEY_NAMES: return _SPECIAL_KEY_NAMES[key_part] return key_part def _xml_key_from_trigger(trigger): """'Cmd+Shift+F3' → 'CommandShiftF3' (Mac Rhino XML-Schema). Cmd/Ctrl → 'Command', Shift → 'Shift', Alt/Option → 'Option'. Sonderzeichen ('-', '/', etc.) werden auf Enum-Namen gemapped.""" t = trigger.replace(" ", "") parts = t.split("+") if "+" in t[1:] else [t] # Edge-Case: trigger endet auf literal '+' oder '-' → letztes Element ist Key # 'Cmd+-' → ['Cmd', '', '-'] via split. Fix: re-split last token wenn leer parts = [p for p in parts if p != ""] # Sonderfall trigger == 'Cmd+-' → split('+') = ['Cmd', '-'], OK # Sonderfall trigger == 'Cmd++' → split('+') = ['Cmd', '', ''] → key = '+' if "Cmd++" in trigger or "Ctrl++" in trigger or "Shift++" in trigger: parts = trigger.replace(" ", "").rstrip("+").split("+") + ["+"] if not parts: return None key_part = _normalize_key_part(parts[-1]) mods = set(p.lower() for p in parts[:-1]) has_cmd = ("cmd" in mods) or ("ctrl" in mods) or ("command" in mods) has_shift = "shift" in mods has_alt = ("alt" in mods) or ("option" in mods) or ("opt" in mods) prefix = "" if has_cmd: prefix += "Command" if has_shift: prefix += "Shift" if has_alt: prefix += "Option" return prefix + key_part def _entry_in_xml(xml_key, expected_macro): """True wenn expected_macro bereits im Mac Rhino settings-XML existiert.""" import os import re paths = [ os.path.expanduser("~/Library/Application Support/McNeel/Rhinoceros/8.0/settings/settings-Scheme__Default.xml"), ] _esc = lambda s: s.replace("&", "&").replace("<", "<").replace(">", ">") pat = re.compile( r'([^<]*)') for path in paths: if not os.path.exists(path): continue try: with open(path, "r", encoding="utf-8") as f: content = f.read() m = pat.search(content) if m and m.group(1) == _esc(expected_macro): return True except Exception: pass return False def _xml_persist_shortcut(xml_key, macro, verbose=False): """Schreibt direkt in Mac Rhino's settings-Scheme__Default.xml unter . String- basiert damit die Original-Formatierung 1:1 erhalten bleibt.""" import os import re paths = [ os.path.expanduser("~/Library/Application Support/McNeel/Rhinoceros/8.0/settings/settings-Scheme__Default.xml"), ] n_written = 0 _esc = lambda s: s.replace("&", "&").replace("<", "<").replace(">", ">") for path in paths: if not os.path.exists(path): continue try: with open(path, "r", encoding="utf-8") as f: content = f.read() new_entry = '{}'.format(xml_key, _esc(macro)) # Existing entry? Loeschen (mit umgebendem Whitespace+Newline) # und neu hinzufuegen mit sauberem Format. Vermeidet # kaputt-formatierte Entries. pat = re.compile( r'|>[^<]*)') m = pat.search(content) if m: # Check Line-Kontext: nur diese Entry auf Zeile + unveraendert? line_start = content.rfind("\n", 0, m.start()) + 1 line_end = content.find("\n", m.end()) if line_end < 0: line_end = len(content) line_trim = content[line_start:line_end].strip() if line_trim == new_entry: if verbose: print("[ALIAS-LOADER] XML '{}' unchanged".format(xml_key)) continue # Sonst: loeschen inkl. preceding-newline+whitespace damit # keine orphan-line uebrig bleibt del_start = m.start() while del_start > 0 and content[del_start-1] in " \t": del_start -= 1 if del_start > 0 and content[del_start-1] == "\n": del_start -= 1 content = content[:del_start] + content[m.end():] if True: # ShortcutKeys-Section finden sec_start = content.find('') if sec_start < 0: if verbose: print("[ALIAS-LOADER] ShortcutKeys-section fehlt") continue sec_end = content.find('', sec_start) if sec_end < 0: if verbose: print("[ALIAS-LOADER] ShortcutKeys-close fehlt") continue # Indent vom letzten in der Section uebernehmen section = content[sec_start:sec_end] ms = list(re.finditer(r'\n([ \t]*) (typisch 6 spaces) close_match = re.search(r'\n([ \t]*)$', content[:sec_end]) close_indent = close_match.group(1) if close_match else " " # Section neu zusammensetzen: alles vor bereinigt # + sauberer Insert before = content[:sec_end].rstrip(" \t") + "\n" content = (before + entry_indent + new_entry + "\n" + close_indent + content[sec_end:]) action = "added" with open(path, "w", encoding="utf-8") as f: f.write(content) n_written += 1 if verbose: print("[ALIAS-LOADER] XML {} '{}'".format(action, xml_key)) except Exception as ex: print("[ALIAS-LOADER] XML-Write {}: {}".format(path, ex)) return n_written def _install_quit_xml_save(pairs): """Rhino's Closing-Event fired auf Mac NICHT zuverlaessig. Wir installieren MEHRERE Hooks parallel: 1. Rhino.RhinoApp.Closing (Mac: meist No-op, Windows: ok) 2. Python atexit (laeuft wenn Interpreter terminiert) 3. AppDomain.ProcessExit (.NET-Level Hook) 4. Idle-Watcher: schreibt XML alle 30s wenn Aenderung erkannt (Fallback fuer Rhino's runtime-flush) Marker-Logging zur Verifikation welcher Hook wirklich feuert.""" import os as _os import datetime as _dt _marker = _os.path.expanduser("~/Library/Logs/dossier_quit_hook.log") try: _os.makedirs(_os.path.dirname(_marker), exist_ok=True) except Exception: pass def _log(msg): try: with open(_marker, "a") as f: f.write("[{}] {}\n".format(_dt.datetime.now().isoformat(), msg)) except Exception: pass def _write_all(source): n_ok = 0 for xml_key, macro in pairs: if _xml_persist_shortcut(xml_key, macro, verbose=False) > 0: n_ok += 1 _log("{} FIRED — {}/{} ok".format(source, n_ok, len(pairs))) return n_ok n_hooks = 0 try: import Rhino def _on_closing(*_): try: _write_all("RhinoClosing") except Exception as ex: _log("RhinoClosing ERROR: {}".format(ex)) Rhino.RhinoApp.Closing += _on_closing n_hooks += 1 except Exception as ex: _log("RhinoClosing install err: {}".format(ex)) try: import atexit def _on_atexit(): try: _write_all("atexit") except Exception as ex: _log("atexit ERROR: {}".format(ex)) atexit.register(_on_atexit) n_hooks += 1 except Exception as ex: _log("atexit install err: {}".format(ex)) try: import System def _on_process_exit(*_): try: _write_all("ProcessExit") except Exception as ex: _log("ProcessExit ERROR: {}".format(ex)) System.AppDomain.CurrentDomain.ProcessExit += _on_process_exit n_hooks += 1 except Exception as ex: _log("ProcessExit install err: {}".format(ex)) # Idle-Watcher: periodisch (alle ~30s) checken ob unsere XML-Entries # noch da sind. Wenn nein → wieder reinschreiben. Ueberlebt Rhino- # Runtime-Flushes auch ohne Close-Event. try: import Rhino import time as _time _state = {"last": 0.0} def _idle_watcher(*_): try: now = _time.time() if now - _state["last"] < 30.0: return _state["last"] = now # Pruefen ob entries fehlen — wenn ja, alle re-schreiben _write_all("IdleWatch") except Exception as ex: _log("IdleWatch ERROR: {}".format(ex)) Rhino.RhinoApp.Idle += _idle_watcher n_hooks += 1 _log("IdleWatch installed (30s interval)") except Exception as ex: _log("IdleWatch install err: {}".format(ex)) _log("Hooks INSTALLED ({} of 4) for {} shortcuts".format(n_hooks, len(pairs))) # Initiale Schreibung im ersten Pass auch — falls Rhino sofort flusht _write_all("InitialWrite") return n_hooks > 0 def _resolve_fkey(trigger): """'F3' / 'Shift+F3' / 'Cmd+F3' / 'Cmd+Alt+F3' → ShortcutKey-Enum-Wert. Enum-Naming-Konvention von Rhino: Ctrl → Shift → Alt → KeyName (z.B. CtrlAltF3, CtrlShiftAltF3). Cmd auf Mac mappt auf Ctrl, Option/Opt auf Alt. Sonderzeichen via _SPECIAL_KEY_NAMES.""" SK = Rhino.ApplicationSettings.ShortcutKey t = trigger.replace(" ", "") parts = t.split("+") parts = [p for p in parts if p != ""] if not parts: return None raw_last = parts[-1] if raw_last in _SPECIAL_KEY_NAMES: key_part = _SPECIAL_KEY_NAMES[raw_last] else: key_part = raw_last.upper() mods = set(p.lower() for p in parts[:-1]) has_ctrl = ("ctrl" in mods) or ("cmd" in mods) or ("command" in mods) has_shift = "shift" in mods has_alt = ("alt" in mods) or ("option" in mods) or ("opt" in mods) prefix = "" if has_ctrl: prefix += "Ctrl" if has_shift: prefix += "Shift" if has_alt: prefix += "Alt" return getattr(SK, prefix + key_part, None) def _resolve_cmd_letter(trigger): """'Cmd+W' / 'Cmd+Shift+W' → ShortcutKey-Enum (Ctrl* auf Rhino-Naming- Konvention; Mac mappt Ctrl auf Cmd intern).""" SK = Rhino.ApplicationSettings.ShortcutKey t = trigger.replace(" ", "") parts = t.split("+") if len(parts) < 2: return None letter = parts[-1].upper() if not (len(letter) == 1 and letter.isalpha()): return None mods = set(p.lower() for p in parts[:-1]) has_cmd = ("cmd" in mods) or ("ctrl" in mods) if not has_cmd: return None name = "Ctrl" if "shift" in mods: name += "Shift" if "alt" in mods: name += "Alt" name += letter return getattr(SK, name, None) def apply_all(): """Liest Defaults + Overrides, wendet alle Aliases + Shortcuts an. Returnt (n_alias, n_fkey, n_cmd, n_skipped).""" global _quit_xml_pairs _quit_xml_pairs = [] defaults = _read_defaults() overrides = _read_user_overrides() aliases = Rhino.ApplicationSettings.CommandAliasList skset = Rhino.ApplicationSettings.ShortcutKeySettings n_alias = n_fkey = n_cmd = n_skipped = 0 seen_triggers = {} # trigger_normalized -> action_id (Konflikt-Erkennung) for action_id, spec in defaults.items(): # User-Override hat Vorrang. Leerer String = Default, None/missing = Default. user_trig = overrides.get(action_id) if user_trig is not None and str(user_trig).strip() == "": user_trig = None trigger = user_trig if user_trig else spec.get("trigger", "") if not trigger: n_skipped += 1 continue spec_type = spec.get("type", "alias") macro = _expand_macro(spec.get("macro", "")) if not macro: n_skipped += 1; continue # Konflikt-Check (gleicher Trigger → letzter gewinnt, Warning) norm = (spec_type, str(trigger).lower()) if norm in seen_triggers: print("[ALIAS-LOADER] Konflikt: '{}' fuer {} bereits von {} belegt" .format(trigger, action_id, seen_triggers[norm])) seen_triggers[norm] = action_id try: if spec_type == "alias": tname = str(trigger) try: if aliases.IsAlias(tname): aliases.Delete(tname) except Exception: pass added = False try: added = aliases.Add(tname, macro) except Exception as _addex: print("[ALIAS-LOADER] Add({}, ...) Exception: {}" .format(tname, _addex)) if not added: try: aliases.SetMacro(tname, macro) except Exception: pass # Verifizieren ob Alias wirklich registriert ist try: is_ok = aliases.IsAlias(tname) if not is_ok: print("[ALIAS-LOADER] WARN: '{}' (action={}) NICHT registriert " "— Rhino lehnt Namen wahrscheinlich ab (z.B. reine Zahl)" .format(tname, action_id)) n_skipped += 1 continue except Exception: pass n_alias += 1 elif spec_type == "fkey": sk = _resolve_fkey(str(trigger)) xml_key = _xml_key_from_trigger(str(trigger)) api_ok = False if sk is not None: try: skset.SetMacro(sk, macro) got = skset.GetMacro(sk) api_ok = (got == macro) except Exception as _sex: print("[ALIAS-LOADER] SetMacro({}): {}".format(trigger, _sex)) if not api_ok and xml_key: # Enum-Wert fehlt → direkt ins XML (mit verbose-Log). # n_xml=0 kann "schon korrekt" ODER "gescheitert" heissen # — wir checken explizit ob Entry im XML existiert. n_xml = _xml_persist_shortcut(xml_key, macro, verbose=True) if n_xml > 0: _quit_xml_pairs.append((xml_key, macro)) else: # n_xml == 0 → entweder "unchanged" (= schon korrekt # im XML) oder "missing path/section". Check via # IsAliasInXml damit wir nicht falsch warnen. if _entry_in_xml(xml_key, macro): # Schon korrekt im XML → fuer Quit-Hook merken # damit Rhino-Quit-Save sie nicht ueberschreibt _quit_xml_pairs.append((xml_key, macro)) else: print("[ALIAS-LOADER] WARN F-Key {} ({}) konnte weder " "API noch XML gesetzt werden".format(trigger, action_id)) n_skipped += 1; continue n_fkey += 1 elif spec_type == "cmd": sk = _resolve_cmd_letter(str(trigger)) if sk is None: # Fallback: Cmd+Letter API u.U. nicht im Enum → als Alias mit dem # Letter (single-char) registrieren. User tippt dann Letter+Enter. letter_only = str(trigger).split("+")[-1].lower() if len(letter_only) == 1 and letter_only.isalpha(): aliases.SetMacro(letter_only, macro) n_alias += 1 print("[ALIAS-LOADER] {} ({}): Cmd+Letter nicht im Enum, " "fallback Alias '{}'".format(action_id, trigger, letter_only)) else: n_skipped += 1 continue skset.SetMacro(sk, macro) n_cmd += 1 else: print("[ALIAS-LOADER] Unbekannter Type:", spec_type); n_skipped += 1 except Exception as ex: print("[ALIAS-LOADER] Apply", action_id, "->", trigger, ":", ex) n_skipped += 1 # Quit-Hook installieren falls XML-only Shortcuts gesetzt wurden — diese # ueberlebt sonst Rhino's Auto-Save beim Quit nicht. if _quit_xml_pairs: _install_quit_xml_save(list(_quit_xml_pairs)) print("[ALIAS-LOADER] {} XML-only Shortcuts werden bei Quit " "re-persistiert (Closing-Hook installiert)" .format(len(_quit_xml_pairs))) return n_alias, n_fkey, n_cmd, n_skipped if __name__ == "__main__": a, f, c, s = apply_all() print("[ALIAS-LOADER] OK: {} alias, {} fkey, {} cmd, {} skipped" .format(a, f, c, s))