Files
DOSSIER/rhino/aliases/loader.py
T
karim 24f6b76f06 Translate remaining internal log messages to English
- EBENEN: drawing levels updated, sublayer not found, saved/verified
- GESTALTUNG: Linetypes before/after, fill field, opened/focused
- CLIP: disabled done
- ELEMENTE: Bulk-op, Listener bail
- Global: not found, not available, unchanged, failed, present
2026-06-06 12:19:10 +02:00

470 lines
19 KiB
Python

#! 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": {
"<action_id>": "<trigger_string>" // 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 unchanged (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 <entry key='<xml_key>'>expected_macro</entry> 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
pat = re.compile(
r'<entry\s+key="' + re.escape(xml_key) + r'"\s*>([^<]*)</entry>')
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 <entry key="<xml_key>"><macro></entry> direkt in Mac Rhino's
settings-Scheme__Default.xml unter <child key='ShortcutKeys'>. 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 = '<entry key="{}">{}</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'<entry\s+key="' + re.escape(xml_key) + r'"\s*(/>|>[^<]*</entry>)')
m = pat.search(content)
if m:
# Check Line-Kontext: nur diese Entry auf Zeile + unchanged?
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('<child key="ShortcutKeys">')
if sec_start < 0:
if verbose: print("[ALIAS-LOADER] ShortcutKeys-section fehlt")
continue
sec_end = content.find('</child>', sec_start)
if sec_end < 0:
if verbose: print("[ALIAS-LOADER] ShortcutKeys-close fehlt")
continue
# Indent vom letzten <entry> in der Section uebernehmen
section = content[sec_start:sec_end]
ms = list(re.finditer(r'\n([ \t]*)<entry\s', section))
entry_indent = ms[-1].group(1) if ms else " "
# Indent vor </child> (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 </child> 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 set 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 set 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 installed)"
.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))