Files
DOSSIER/rhino/welcome.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

563 lines
21 KiB
Python

#! python3
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 Karim Gabriele Varano
"""
welcome.py
Welcome-Screen + Shortcuts-Cheatsheet als WebView-Dialog im DOSSIER-Style
(passend zum Splashscreen — Petrol-Gradient, Mono-Font).
Funktionen:
- show_welcome() — erscheint NACH dem Splash (eigener Idle-Timer), einmal
pro Version. User kann "nicht mehr anzeigen" rechts unten anklicken.
- show_cheatsheet() — DOSSIER-Shortcut-Liste, aufrufbar via dkeys-Alias.
Marker-Datei fuer "schon gesehen" wird in
~/Library/Application Support/ch.gabrielevarano.Dossier/welcome_shown abgelegt.
"""
import os
import json
import Rhino
DOSSIER_VERSION = "0.6.3"
DOSSIER_GITHUB = "https://github.com/karimgvarano/DOSSIER"
DOSSIER_SUPPORT_EMAIL = "karim@gabrielevarano.ch"
_WELCOME_DIR = os.path.expanduser(
"~/Library/Application Support/ch.gabrielevarano.Dossier")
_WELCOME_FLAG = os.path.join(_WELCOME_DIR, "welcome_shown.txt")
_WELCOME_OPTOUT = os.path.join(_WELCOME_DIR, "welcome_dontshow.txt")
_SPLASH_MIN_DELAY_SEC = 3.5
_HERE = os.path.dirname(os.path.abspath(__file__))
_SHORTCUTS_JSON = os.path.join(_HERE, "aliases", "shortcuts_default.json")
def _has_optout():
return os.path.exists(_WELCOME_OPTOUT)
def _has_seen_version(version):
try:
if not os.path.exists(_WELCOME_FLAG): return False
with open(_WELCOME_FLAG, "r") as f:
return f.read().strip() == version
except Exception:
return False
def _mark_seen(version):
try:
os.makedirs(_WELCOME_DIR, exist_ok=True)
with open(_WELCOME_FLAG, "w") as f:
f.write(version)
except Exception as ex:
print("[WELCOME] mark-seen err:", ex)
def _write_optout():
try:
os.makedirs(_WELCOME_DIR, exist_ok=True)
with open(_WELCOME_OPTOUT, "w") as f:
f.write("1")
except Exception as ex:
print("[WELCOME] optout-write err:", ex)
def _load_shortcuts():
try:
with open(_SHORTCUTS_JSON, "r", encoding="utf-8") as f:
data = json.load(f)
items = []
for k, v in data.items():
if k.startswith("_") or not isinstance(v, dict): continue
items.append({
"id": k,
"trigger": v.get("trigger", ""),
"label": v.get("label", k),
"type": v.get("type", ""),
})
return items
except Exception as ex:
print("[WELCOME] shortcuts-load err:", ex)
return []
# ---- HTML — DOSSIER-Style passend zum Splash ----------------------------
_WELCOME_HTML = """<!DOCTYPE html>
<html lang="de"><head><meta charset="utf-8"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:ital,wght@0,300;0,400;0,500&family=Playfair+Display:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root {{
--accent: #5fa896; --accent-soft: #6fb5a3; --accent-deep: #2f5d54;
--paper: #fff; --paper-mute: rgba(255,255,255,0.78); --paper-faint: rgba(255,255,255,0.5);
--font-display: Krungthep, 'Archivo Black', sans-serif;
--font-serif: 'Playfair Display', serif;
--font-mono: 'DM Mono', 'Menlo', monospace;
}}
* {{ box-sizing:border-box; }}
html, body {{
margin:0; padding:0; width:100%; height:100%; background:transparent !important;
color:var(--paper); overflow:hidden; font-family:var(--font-mono); user-select:none;
-webkit-user-select:none;
}}
.frame {{
box-sizing:border-box; width:100%; height:100%; padding:28px 32px 24px;
display:flex; flex-direction:column;
background: radial-gradient(120% 140% at 0% 0%, var(--accent-soft) 0%, var(--accent) 55%, var(--accent-deep) 130%);
border-radius:16px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.18);
}}
.brand-row {{ display:flex; align-items:baseline; justify-content:space-between; gap:12px; }}
.brand {{
font-family:var(--font-display); font-size:32px; letter-spacing:-0.01em;
line-height:1; color:var(--paper);
}}
.brand-dot {{ color:var(--accent-deep); }}
.version {{
font-family:var(--font-mono); font-size:10px; letter-spacing:0.10em;
color:var(--paper-mute); text-transform:uppercase;
}}
.title {{
font-family:var(--font-serif); font-size:22px; line-height:1.3;
color:var(--paper); margin-top:20px; font-weight:500;
}}
.intro {{
font-size:11px; line-height:1.65; color:var(--paper-mute); margin-top:10px;
letter-spacing:0.02em;
}}
.section-title {{
font-size:9px; letter-spacing:0.18em; text-transform:uppercase;
color:var(--paper-faint); margin:22px 0 10px;
}}
.links {{ display:flex; flex-direction:column; gap:8px; }}
a {{ color:inherit; text-decoration:none; }}
.link {{
display:flex; align-items:flex-start; gap:12px;
padding:10px 14px; border-radius:6px; cursor:pointer;
background:rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.12);
transition:background 0.15s;
color:var(--paper); text-decoration:none;
}}
.link:hover {{ background:rgba(255,255,255,0.16); }}
.link-icon {{
font-family:var(--font-display); font-size:14px; color:var(--accent-deep);
background:var(--paper); width:24px; height:24px; border-radius:50%;
display:flex; align-items:center; justify-content:center; flex-shrink:0;
margin-top:1px;
}}
.link-content {{ flex:1; min-width:0; }}
.link-title {{ font-size:12px; color:var(--paper); font-weight:500; }}
.link-desc {{ font-size:10px; color:var(--paper-mute); margin-top:2px; }}
kbd {{
background:rgba(0,0,0,0.18); padding:1px 6px; border-radius:3px;
font-family:var(--font-mono); font-size:10px; color:var(--paper);
border:1px solid rgba(255,255,255,0.15);
}}
.footer {{
margin-top:auto; display:flex; align-items:center; justify-content:space-between;
padding-top:18px; gap:12px;
}}
.footer-meta {{
font-size:9px; letter-spacing:0.14em; color:var(--paper-faint);
text-transform:uppercase;
}}
.optout {{
display:flex; align-items:center; gap:6px; cursor:pointer;
font-size:10px; color:var(--paper-mute); user-select:none;
}}
.optout:hover {{ color:var(--paper); }}
.optout input {{ accent-color:var(--paper); margin:0; }}
.win-ctrl {{
position:absolute; top:14px; right:16px; display:flex; gap:6px; z-index:20;
}}
.win-btn {{
width:22px; height:22px; border-radius:50%; cursor:pointer;
display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,0.18); border:1px solid rgba(255,255,255,0.18);
color:var(--paper); font-family:var(--font-mono); font-size:13px;
text-decoration:none; transition:background 0.12s;
line-height:1; user-select:none;
}}
.win-btn:hover {{ background:rgba(0,0,0,0.32); }}
</style></head><body>
<div class="frame">
<div class="win-ctrl">
<a class="win-btn" href="dossier:close" title="Schliessen">&times;</a>
</div>
<div class="brand-row">
<div class="brand">DOSSIER<span class="brand-dot">.</span></div>
<div class="version">Version {ver}</div>
</div>
<div class="title">Willkommen im Studio</div>
<div class="intro">
DOSSIER ist dein Architektur-Studio-Plugin fuer Rhino 8 —
Waende, Decken, Treppen, Fenster, Tueren, Raumstempel,
Layouts. Alles aus einer Hand, im selben Stil.
</div>
<div class="section-title">Einstieg</div>
<div class="links">
<a class="link" href="dossier:cheatsheet">
<div class="link-icon">⌘</div>
<div class="link-content">
<div class="link-title">Shortcuts &amp; Cheatsheet</div>
<div class="link-desc">Tippe <kbd>dkeys</kbd> im Command-Prompt fuer die volle Liste</div>
</div>
</a>
<a class="link" href="{github}" target="_blank">
<div class="link-icon">i</div>
<div class="link-content">
<div class="link-title">Einfuehrung &amp; Doku</div>
<div class="link-desc">{github}</div>
</div>
</a>
<a class="link" href="{github}/releases" target="_blank">
<div class="link-icon">v</div>
<div class="link-content">
<div class="link-title">Changelog</div>
<div class="link-desc">Was ist neu in dieser Version</div>
</div>
</a>
<a class="link" href="mailto:{email}" target="_blank">
<div class="link-icon">?</div>
<div class="link-content">
<div class="link-title">Support &amp; Problem melden</div>
<div class="link-desc">{email} oder GitHub-Issues</div>
</div>
</a>
</div>
<div class="footer">
<div class="footer-meta">AGPL-3.0 &middot; Karim Gabriele Varano</div>
<label class="optout">
<input type="checkbox" id="optout" onchange="window.location='dossier:optout?'+this.checked"/>
Nicht mehr anzeigen
</label>
</div>
</div>
</body></html>"""
_CHEATSHEET_HTML = """<!DOCTYPE html>
<html><head><meta charset="utf-8"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono:ital,wght@0,300;0,400;0,500&display=swap" rel="stylesheet"/>
<style>
:root {{
--accent: #5fa896; --accent-soft: #6fb5a3; --accent-deep: #2f5d54;
--paper: #fff; --paper-mute: rgba(255,255,255,0.78); --paper-faint: rgba(255,255,255,0.5);
--font-display: Krungthep, 'Archivo Black', sans-serif;
--font-mono: 'DM Mono', 'Menlo', monospace;
}}
* {{ box-sizing:border-box; }}
html, body {{
margin:0; padding:0; width:100%; height:100%; background:transparent !important;
color:var(--paper); overflow:auto; font-family:var(--font-mono);
}}
.frame {{
box-sizing:border-box; min-height:100%; padding:24px 28px;
background: radial-gradient(120% 140% at 0% 0%, var(--accent-soft) 0%, var(--accent) 55%, var(--accent-deep) 130%);
border-radius:16px;
}}
.brand-row {{ display:flex; align-items:baseline; justify-content:space-between; gap:12px; }}
.brand {{ font-family:var(--font-display); font-size:24px; line-height:1; color:var(--paper); }}
.brand-dot {{ color:var(--accent-deep); }}
.version {{ font-family:var(--font-mono); font-size:10px; letter-spacing:0.10em; color:var(--paper-mute); text-transform:uppercase; }}
h2 {{
font-size:10px; letter-spacing:0.18em; color:var(--paper); margin:18px 0 8px;
text-transform:uppercase; font-weight:500;
}}
table {{ width:100%; border-collapse:collapse; }}
td {{ padding:5px 8px; border-bottom:1px solid rgba(255,255,255,0.12); vertical-align:middle; }}
td:first-child {{ width:170px; }}
kbd {{
background:rgba(0,0,0,0.18); padding:2px 8px; border-radius:3px;
font-family:var(--font-mono); font-size:11px; color:var(--paper);
border:1px solid rgba(255,255,255,0.18);
}}
.lab {{ color:var(--paper); font-size:11px; }}
.badge {{
font-size:9px; padding:1px 5px; border-radius:2px; margin-left:6px;
background:rgba(255,255,255,0.12); color:var(--paper-mute);
font-family:var(--font-mono);
}}
.win-ctrl {{
position:fixed; top:14px; right:18px; display:flex; gap:6px; z-index:20;
}}
.win-btn {{
width:22px; height:22px; border-radius:50%; cursor:pointer;
display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,0.22); border:1px solid rgba(255,255,255,0.18);
color:var(--paper); font-family:var(--font-mono); font-size:13px;
text-decoration:none; transition:background 0.12s;
line-height:1; user-select:none;
}}
.win-btn:hover {{ background:rgba(0,0,0,0.38); }}
</style></head><body>
<div class="frame">
<div class="win-ctrl">
<a class="win-btn" href="dossier:back" title="Zurueck">&lsaquo;</a>
<a class="win-btn" href="dossier:close" title="Schliessen">&times;</a>
</div>
<div class="brand-row">
<div class="brand">DOSSIER<span class="brand-dot">.</span> Shortcuts</div>
<div class="version">v {ver}</div>
</div>
{sections}
</div></body></html>"""
def _build_cheatsheet_html():
items = _load_shortcuts()
groups = {
"DOSSIER BIM": [],
"2D-Werkzeuge": [],
"Views & Navigation": [],
"Modify-Tools": [],
"Sonstige Aliases": [],
}
bim_ids = {"wand", "tuer", "fenster", "decke", "treppe", "stuetze",
"traeger", "raum", "symbol", "stempel", "dach", "aussparung"}
view_ids = {"view_plan", "view_3d", "view_material", "zoom_ext",
"zoom_sel", "geschoss_up", "geschoss_down",
"panel_layer", "panel_elemente"}
twod_ids = {"text", "line", "arc", "rectangle", "polyline", "curve",
"hatch", "polygon", "ellipse", "circle"}
for it in items:
i = it["id"]
if i in bim_ids: groups["DOSSIER BIM"].append(it)
elif i in view_ids: groups["Views & Navigation"].append(it)
elif i.startswith("mod_"): groups["Modify-Tools"].append(it)
elif i in twod_ids or i.endswith("_alias"): groups["2D-Werkzeuge"].append(it)
else: groups["Sonstige Aliases"].append(it)
def _row(it):
trig = it["trigger"]
trig = trig.replace("Cmd+", "⌘+").replace("Shift+", "⇧+").replace("Alt+", "⌥+")
return ('<tr><td><kbd>{}</kbd></td>'
'<td class="lab">{}</td></tr>'
.format(trig, it["label"]))
sections = []
for gname, gitems in groups.items():
if not gitems: continue
rows = "".join(_row(it) for it in gitems)
sections.append('<h2>{}</h2><table>{}</table>'.format(gname, rows))
return _CHEATSHEET_HTML.format(ver=DOSSIER_VERSION, sections="".join(sections))
# ---- Dialog-Anzeige ------------------------------------------------------
def _try_borderless_mac(form):
"""Borderless NSWindow + transparenten Hintergrund (analog _startup_splash)."""
try:
import System
nsw = getattr(form, "ControlObject", None)
if nsw is None: return False
# StyleMask = 0 (Borderless)
try:
cur = nsw.StyleMask
nsw.StyleMask = System.Enum.ToObject(type(cur), 0)
except Exception as ex:
print("[WELCOME] StyleMask:", ex)
# Transparent background damit border-radius vom HTML sichtbar
for prop, val in [("TitlebarAppearsTransparent", True),
("IsOpaque", False), ("HasShadow", True),
("MovableByWindowBackground", True)]:
try: setattr(nsw, prop, val)
except Exception: pass
try:
tv_type = type(nsw.TitleVisibility)
nsw.TitleVisibility = System.Enum.ToObject(tv_type, 1)
except Exception: pass
try:
from AppKit import NSColor as _NSC
clear = getattr(_NSC, "Clear", None) or getattr(_NSC, "ClearColor", None)
if clear is not None: nsw.BackgroundColor = clear
except Exception: pass
return True
except Exception as ex:
print("[WELCOME] borderless:", ex)
return False
def _webview_transparent(web):
"""WKWebView vollstaendig transparent — KVC drawsBackground=NO,
UnderPageBackgroundColor=Clear, Layer.BackgroundColor=CGColor.Clear."""
wk = getattr(web, "ControlObject", None)
if wk is None: return
try:
from Foundation import NSNumber, NSString
try: wk.SetValueForKey(NSNumber.FromBoolean(False), NSString("drawsBackground"))
except Exception as ex: print("[WELCOME] KVC drawsBackground:", ex)
except Exception as ex: print("[WELCOME] Foundation:", ex)
try:
from AppKit import NSColor as _NSC
clear = getattr(_NSC, "Clear", None) or getattr(_NSC, "ClearColor", None)
if clear is not None:
try: wk.UnderPageBackgroundColor = clear
except Exception: pass
try:
layer = getattr(wk, "Layer", None)
if layer is not None:
layer.BackgroundColor = clear.CGColor
layer.Opaque = False
except Exception as ex: print("[WELCOME] Layer:", ex)
except Exception as ex: print("[WELCOME] NSColor:", ex)
def _show_html_form(title, html, width=620, height=720, on_navigating=None,
borderless=True):
"""Eto.Forms.Form mit WebView + Inline-HTML. Optional borderless +
Navigation-Hook fuer custom URL-Schemes."""
try:
import Eto.Forms as ef
import Eto.Drawing as ed
except Exception as ex:
print("[WELCOME] Eto.Forms not available:", ex)
return None
try:
form = ef.Form()
form.Title = title
form.ClientSize = ed.Size(width, height)
form.Topmost = False
form.Resizable = False
if borderless:
try: form.WindowStyle = getattr(ef.WindowStyle, "None")
except Exception: pass
for attr, val in (("Minimizable", False), ("Maximizable", False),
("Closeable", False), ("ShowInTaskbar", False)):
try: setattr(form, attr, val)
except Exception: pass
try: form.BackgroundColor = ed.Colors.Transparent
except Exception: pass
web = ef.WebView()
web.Size = ed.Size(width, height)
if borderless:
try: web.BackgroundColor = ed.Colors.Transparent
except Exception: pass
if on_navigating is not None:
try: web.DocumentLoading += on_navigating
except Exception as ex: print("[WELCOME] nav-hook:", ex)
try: web.LoadHtml(html)
except Exception as e: print("[WELCOME] LoadHtml:", e)
form.Content = web
try: form.Owner = Rhino.UI.RhinoEtoApp.MainWindow
except Exception: pass
form.Show()
if borderless:
_try_borderless_mac(form)
_webview_transparent(web)
try: ef.Application.Instance.RunIteration()
except Exception: pass
return form
except Exception as ex:
print("[WELCOME] form show:", ex)
return None
def show_welcome(force=False):
"""Zeigt Welcome NACH Splash. Erscheint bei jedem Start ausser der
User klickt 'Nicht mehr anzeigen' (= optout-File).
WICHTIG: UI muss auf Main-Thread laufen (Mac Cocoa) — Rhino-Idle-Event
feuert dort, deshalb defern wir die Anzeige."""
if not force and _has_optout():
print("[WELCOME] optout active ({}) — skip".format(_WELCOME_OPTOUT))
return
print("[WELCOME] geplant — Anzeige nach Splash (>{:.1f}s)".format(_SPLASH_MIN_DELAY_SEC))
import time
state = {"start": time.time(), "fired": False}
def _on_idle(sender, e):
if state["fired"]: return
if time.time() - state["start"] < _SPLASH_MIN_DELAY_SEC: return
state["fired"] = True
try:
Rhino.RhinoApp.Idle -= _on_idle
except Exception: pass
try:
print("[WELCOME] Anzeige starten")
_show_welcome_now()
except Exception as ex:
print("[WELCOME] show err:", ex)
try:
Rhino.RhinoApp.Idle += _on_idle
except Exception as ex:
print("[WELCOME] idle-hook err:", ex)
def _show_welcome_now():
html = _WELCOME_HTML.format(
ver=DOSSIER_VERSION, github=DOSSIER_GITHUB, email=DOSSIER_SUPPORT_EMAIL)
form_ref = [None]
def _on_nav(sender, e):
try:
url = e.Uri.ToString() if hasattr(e, "Uri") else str(getattr(e, "Url", ""))
except Exception:
url = ""
if not url: return
if url.startswith("dossier:optout"):
# Optout-Checkbox-Klick. URL-Form: dossier:optout?true/false
checked = url.endswith("true")
if checked: _write_optout()
else:
try:
if os.path.exists(_WELCOME_OPTOUT):
os.remove(_WELCOME_OPTOUT)
except Exception: pass
try: e.Cancel = True
except Exception: pass
elif url.startswith("dossier:cheatsheet"):
try: e.Cancel = True
except Exception: pass
show_cheatsheet()
try:
if form_ref[0] is not None: form_ref[0].Close()
except Exception: pass
elif url.startswith("dossier:close"):
try: e.Cancel = True
except Exception: pass
try:
if form_ref[0] is not None: form_ref[0].Close()
except Exception: pass
form_ref[0] = _show_html_form("Willkommen bei DOSSIER", html, 600, 620,
on_navigating=_on_nav)
def show_cheatsheet():
html = _build_cheatsheet_html()
form_ref = [None]
def _on_nav(sender, e):
try:
url = e.Uri.ToString() if hasattr(e, "Uri") else str(getattr(e, "Url", ""))
except Exception:
url = ""
if not url: return
if url.startswith("dossier:close"):
try: e.Cancel = True
except Exception: pass
try:
if form_ref[0] is not None: form_ref[0].Close()
except Exception: pass
elif url.startswith("dossier:back"):
try: e.Cancel = True
except Exception: pass
try:
if form_ref[0] is not None: form_ref[0].Close()
except Exception: pass
try: _show_welcome_now()
except Exception as ex: print("[WELCOME] back:", ex)
form_ref[0] = _show_html_form("DOSSIER Shortcuts", html, 640, 760,
on_navigating=_on_nav)
if __name__ == "__main__":
show_cheatsheet()