1596bbd941
User: B/I/U Toggle-Buttons hinken hinterher oder zeigen invertiert. Im WYSIWYG sieht alles richtig aus aber Rhino zeigt nur die LETZTE gesetzte Schriftart fuer alles. Fix 1 — B/I/U sync: - selectionchange-Listener pollt jetzt queryCommandState fuer bold/italic/underline und syncen das an Toolbar-State - toggleBold/Italic/Underline machen nur noch exec() — kein manuelles setBold(b => !b) mehr (war out-of-sync wenn execCommand wegen fehlender Selection nicht griff) - B/I/U-Button-Highlight reflektiert jetzt die echte Cursor-Position Fix 2 — RTF nimmt nur letzte Schrift: Reihenfolge im _commit war falsch. Vorher: RichText → TextHeight → _apply_font → _apply_align. _apply_font setzt te.Font (Default-Font) NACH dem RichText → schiesst die per-Run-Fonts in der RTF tot. Neue Reihenfolge: 1. PlainText (Initial-Content) 2. TextHeight, Font, Align (Defaults fuer ALLES) 3. RichText (ueberschreibt Defaults pro Run) Plus Font-Tabelle in _runs_to_rtf jetzt mit \fnil\fcharset0 Marker (Rhinos RTF-Parser kann sonst font-Eintraege ignorieren und Default verwenden). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
449 lines
17 KiB
Python
449 lines
17 KiB
Python
#! python 3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
text_editor.py
|
||
React-WYSIWYG-Editor in Satellite-WebView (Topmost). User picked Frame
|
||
in create_text(), dann oeffnet sich dieser Editor neben dem Frame.
|
||
TextEditorBridge haelt Frame-Daten + Settings, auf COMMIT erstellt es
|
||
die TextEntity und schliesst das Fenster.
|
||
"""
|
||
import os
|
||
import sys
|
||
import Rhino
|
||
import Rhino.Geometry as rg
|
||
import scriptcontext as sc
|
||
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
if _HERE not in sys.path:
|
||
sys.path.insert(0, _HERE)
|
||
|
||
import panel_base
|
||
import text_create
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Doppelklick-Hook: wenn User auf einen DOSSIER-Text doppelklickt, oeffnet
|
||
# sich UNSER Editor statt Rhinos Standard-TextEdit.
|
||
|
||
class _DossierTextDoubleClickHook(Rhino.UI.MouseCallback):
|
||
def OnMouseDoubleClick(self, e):
|
||
try:
|
||
doc = Rhino.RhinoDoc.ActiveDoc
|
||
if doc is None: return
|
||
# Welches Objekt ist gerade selektiert? (Rhino's einfache
|
||
# Single-Click hat es ausgewaehlt bevor wir hier sind.)
|
||
target = None
|
||
for obj in doc.Objects.GetSelectedObjects(False, False):
|
||
try:
|
||
if isinstance(obj.Geometry, rg.TextEntity):
|
||
if obj.Attributes.GetUserString("dossier_text") == "1":
|
||
target = obj; break
|
||
except Exception: pass
|
||
if target is not None:
|
||
e.Cancel = True # Rhinos eigener TextEdit-Dialog wird geblockt
|
||
# Defer auf naechsten Idle-Tick — Eto-Form aus MouseCallback
|
||
# heraus zu oeffnen kann zu Re-Entrancy-Problemen fuehren
|
||
sc.sticky["dossier_pending_text_edit"] = str(target.Id)
|
||
except Exception as ex:
|
||
print("[TEXT-HOOK] OnDoubleClick:", ex)
|
||
|
||
|
||
_hook_instance = None
|
||
|
||
|
||
def _ensure_double_click_hook():
|
||
"""Installiert MouseCallback einmalig pro Rhino-Session."""
|
||
global _hook_instance
|
||
if _hook_instance is not None: return
|
||
try:
|
||
_hook_instance = _DossierTextDoubleClickHook()
|
||
_hook_instance.Enabled = True
|
||
# Idle-Handler einmalig registrieren fuer das deferred Opening
|
||
if not sc.sticky.get("dossier_text_idle_registered"):
|
||
Rhino.RhinoApp.Idle += _on_idle_check_pending_edit
|
||
sc.sticky["dossier_text_idle_registered"] = True
|
||
print("[TEXT-HOOK] Doppelklick-Hook installiert")
|
||
except Exception as ex:
|
||
print("[TEXT-HOOK] install:", ex)
|
||
|
||
|
||
def _on_idle_check_pending_edit(sender, e):
|
||
pid = sc.sticky.get("dossier_pending_text_edit")
|
||
if not pid: return
|
||
sc.sticky["dossier_pending_text_edit"] = None
|
||
doc = Rhino.RhinoDoc.ActiveDoc
|
||
if doc is None: return
|
||
try:
|
||
import System
|
||
obj = doc.Objects.FindId(System.Guid(pid))
|
||
if obj is None: return
|
||
open_for_edit(obj)
|
||
except Exception as ex:
|
||
print("[TEXT-HOOK] open for edit:", ex)
|
||
|
||
|
||
class TextEditorBridge(panel_base.BaseBridge):
|
||
def __init__(self, frame_data, settings, fonts,
|
||
edit_obj_id=None, initial_text=""):
|
||
panel_base.BaseBridge.__init__(self, "text_editor")
|
||
self._frame = frame_data # (origin, width, height, p1, p2)
|
||
self._initial_settings = settings
|
||
self._fonts = fonts
|
||
self._form_ref = None
|
||
self._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt
|
||
self._initial_text = initial_text
|
||
|
||
def set_form(self, form):
|
||
self._form_ref = form
|
||
|
||
def _on_ready(self):
|
||
doc = Rhino.RhinoDoc.ActiveDoc
|
||
styles = []
|
||
try: styles = text_create.list_styles(doc)
|
||
except Exception: pass
|
||
self.send("INIT", {
|
||
"settings": self._initial_settings,
|
||
"fonts": self._fonts,
|
||
"styles": styles,
|
||
"initialText": self._initial_text,
|
||
"editMode": bool(self._edit_obj_id),
|
||
})
|
||
|
||
def handle(self, data):
|
||
if not isinstance(data, dict): return
|
||
t = data.get("type", "")
|
||
p = data.get("payload") or {}
|
||
if t == "READY":
|
||
self._on_ready()
|
||
elif t == "COMMIT":
|
||
self._commit(p)
|
||
try: self._form_ref.Close()
|
||
except Exception: pass
|
||
elif t == "CANCEL":
|
||
try: self._form_ref.Close()
|
||
except Exception: pass
|
||
|
||
def _commit(self, payload):
|
||
import System
|
||
import math
|
||
doc = Rhino.RhinoDoc.ActiveDoc
|
||
if doc is None or self._frame is None: return
|
||
text = (payload.get("text") or "").strip()
|
||
if not text: return
|
||
st = payload.get("settings") or {}
|
||
runs = payload.get("runs") # Phase 2: rich-text runs (oder None)
|
||
origin, width, height, _p1, _p2 = self._frame
|
||
|
||
try:
|
||
te = rg.TextEntity()
|
||
# Plane mit optionaler Rotation um Z
|
||
rot_deg = 0.0
|
||
try: rot_deg = float(st.get("rotation") or 0)
|
||
except Exception: pass
|
||
plane = rg.Plane(origin, rg.Vector3d.ZAxis)
|
||
if abs(rot_deg) > 1e-6:
|
||
try:
|
||
plane.Rotate(math.radians(rot_deg), rg.Vector3d.ZAxis, origin)
|
||
except Exception: pass
|
||
te.Plane = plane
|
||
|
||
# REIHENFOLGE WICHTIG:
|
||
# 1. Plain-Text setzen (Initial-Content)
|
||
# 2. Default-Font / TextHeight / Align setzen (gilt fuer alles)
|
||
# 3. RichText setzen (ZULETZT — die per-Run-Format-Codes
|
||
# ueberschreiben dann die Default-Properties)
|
||
# Andere Reihenfolge → Defaults schiessen die RTF-Runs weg.
|
||
te.PlainText = text
|
||
try: te.TextHeight = float(st.get("size") or 0.2)
|
||
except Exception: pass
|
||
text_create._apply_font(
|
||
te,
|
||
st.get("font") or "Helvetica",
|
||
st.get("bold"), st.get("italic"),
|
||
st.get("underline"))
|
||
text_create._apply_align(te, st.get("align") or "left")
|
||
|
||
# RichText (Phase 2) — ZULETZT, ueberschreibt te.Font fuer
|
||
# alle Runs die eine eigene Schrift haben.
|
||
rtf = _runs_to_rtf(
|
||
runs,
|
||
st.get("font") or "Helvetica",
|
||
base_size_m=float(st.get("size") or 0.2)) if runs else None
|
||
if rtf:
|
||
try:
|
||
te.RichText = rtf
|
||
except Exception as ex:
|
||
print("[TEXT-EDITOR] RichText set fail:", ex)
|
||
|
||
for attr in ("FormatWidth", "TextWidth", "MaskWidth"):
|
||
try:
|
||
setattr(te, attr, width); break
|
||
except Exception: pass
|
||
try: te.TextIsWrapped = True
|
||
except Exception:
|
||
try: te.TextWrap = True
|
||
except Exception: pass
|
||
|
||
# Frame um den Text + Mask-Margin
|
||
frame_kind = (st.get("frame") or "none").lower()
|
||
try:
|
||
MF = Rhino.DocObjects.TextMaskFrame
|
||
te.MaskFrame = (
|
||
MF.RectFrame if frame_kind == "rect" else
|
||
MF.CapsuleFrame if frame_kind == "capsule" else
|
||
MF.NoFrame
|
||
)
|
||
except Exception as ex:
|
||
print("[TEXT-EDITOR] frame:", ex)
|
||
try:
|
||
mask_m = float(st.get("maskMargin") or 0)
|
||
if mask_m > 0:
|
||
te.MaskEnabled = True
|
||
te.MaskOffset = mask_m
|
||
te.MaskUsesViewportColor = True
|
||
except Exception as ex:
|
||
print("[TEXT-EDITOR] mask:", ex)
|
||
|
||
# Horizontal-to-view (DrawForward = text steht immer zur Kamera)
|
||
try:
|
||
te.DrawForward = bool(st.get("horizontalToView"))
|
||
except Exception: pass
|
||
|
||
attrs = Rhino.DocObjects.ObjectAttributes()
|
||
col = st.get("color") # [r,g,b] oder None
|
||
if col is not None and len(col) >= 3:
|
||
try:
|
||
attrs.ColorSource = Rhino.DocObjects.ObjectColorSource.ColorFromObject
|
||
attrs.ObjectColor = System.Drawing.Color.FromArgb(
|
||
int(col[0]), int(col[1]), int(col[2]))
|
||
except Exception as ex:
|
||
print("[TEXT-EDITOR] color:", ex)
|
||
attrs.SetUserString("dossier_text", "1")
|
||
|
||
# Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu
|
||
if self._edit_obj_id is not None:
|
||
try:
|
||
old = doc.Objects.FindId(self._edit_obj_id)
|
||
if old is not None:
|
||
# Plane vom Original uebernehmen wenn nicht rotiert
|
||
# explizit gewuenscht (sonst wandert der Text)
|
||
if abs(rot_deg) < 1e-6:
|
||
te.Plane = old.Geometry.Plane
|
||
doc.Objects.Replace(self._edit_obj_id, te)
|
||
doc.Objects.ModifyAttributes(self._edit_obj_id, attrs, True)
|
||
else:
|
||
doc.Objects.AddText(te, attrs)
|
||
except Exception as ex:
|
||
print("[TEXT-EDITOR] replace:", ex)
|
||
doc.Objects.AddText(te, attrs)
|
||
else:
|
||
doc.Objects.AddText(te, attrs)
|
||
doc.Views.Redraw()
|
||
|
||
# Defaults speichern (ohne color/rotation/frame — die sind
|
||
# situativ, nicht zu uebernehmen)
|
||
text_create.save_settings(doc, {
|
||
"font": st.get("font"),
|
||
"size": st.get("size"),
|
||
"bold": st.get("bold"),
|
||
"italic": st.get("italic"),
|
||
"underline": st.get("underline"),
|
||
"align": st.get("align"),
|
||
})
|
||
except Exception as ex:
|
||
print("[TEXT-EDITOR] commit:", ex)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Phase 2: Rich-Text-Runs (vom Frontend HTML-parsing) → Rhino-RTF.
|
||
|
||
def _parse_color_to_rgb(c):
|
||
"""CSS-Farbe ('rgb(r,g,b)' | '#rrggbb' | '#rgb') → (r,g,b) Ints 0-255."""
|
||
if not c: return (0, 0, 0)
|
||
c = c.strip()
|
||
if c.startswith("rgb"):
|
||
import re
|
||
m = re.search(r"(\d+)\D+(\d+)\D+(\d+)", c)
|
||
if m: return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||
if c.startswith("#"):
|
||
h = c.lstrip("#")
|
||
if len(h) == 3: h = "".join(ch * 2 for ch in h)
|
||
if len(h) == 6:
|
||
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||
return (0, 0, 0)
|
||
|
||
|
||
def _rtf_escape(s):
|
||
"""RTF-Escape: \\, {, }, sowie non-ASCII via \\u-Notation."""
|
||
out = []
|
||
for ch in s:
|
||
cp = ord(ch)
|
||
if ch == "\\": out.append("\\\\")
|
||
elif ch == "{": out.append("\\{")
|
||
elif ch == "}": out.append("\\}")
|
||
elif ch == "\n": out.append("\\par\n")
|
||
elif cp < 128: out.append(ch)
|
||
else:
|
||
# Signed 16-bit → Rhinos RTF erwartet das so
|
||
v = cp if cp < 0x8000 else cp - 0x10000
|
||
out.append("\\u{}?".format(v))
|
||
return "".join(out)
|
||
|
||
|
||
def _runs_to_rtf(runs, default_font, base_size_m=0.20):
|
||
"""Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von
|
||
dicts mit Keys text/font/bold/italic/underline/sup/sub/color/fontSizePx.
|
||
base_size_m: TextEntity.TextHeight (in m). Frontend rendert 1m = 100px,
|
||
also entspricht base_size_m * 100 dem "Standard" \\fs20 in RTF."""
|
||
if not runs: return None
|
||
# Triviale Runs (alle plain, ein Font) → kein RTF noetig
|
||
nontrivial = False
|
||
for r in runs:
|
||
if r.get("bold") or r.get("italic") or r.get("underline") \
|
||
or r.get("sup") or r.get("sub") or r.get("color") \
|
||
or r.get("fontSizePx") \
|
||
or (r.get("font") and r["font"] != default_font):
|
||
nontrivial = True; break
|
||
if not nontrivial: return None
|
||
|
||
# Font-Tabelle + Color-Tabelle
|
||
fonts = [default_font]
|
||
colors = [] # nur explizit gesetzte (Index 1+)
|
||
def font_idx(f):
|
||
if not f: return 0
|
||
if f not in fonts: fonts.append(f)
|
||
return fonts.index(f)
|
||
def color_idx(c):
|
||
if not c: return 0
|
||
rgb = _parse_color_to_rgb(c)
|
||
if rgb in colors: return colors.index(rgb) + 1
|
||
colors.append(rgb)
|
||
return len(colors)
|
||
|
||
parts = ["{\\rtf1\\ansi\\ansicpg1252\\deff0"]
|
||
parts.append("{\\fonttbl")
|
||
for i, f in enumerate(fonts):
|
||
# Rhinos RTF-Parser will fnil/fcharset0 — sonst kann es passieren
|
||
# dass der font-Eintrag ignoriert wird und ein Default verwendet
|
||
parts.append("{{\\f{}\\fnil\\fcharset0 {};}}".format(i, f))
|
||
parts.append("}")
|
||
if colors:
|
||
parts.append("{\\colortbl;")
|
||
for r, g, b in colors:
|
||
parts.append("\\red{}\\green{}\\blue{};".format(r, g, b))
|
||
parts.append("}")
|
||
parts.append("\\pard")
|
||
|
||
# Frontend rendert 1m = 100px; Standard-Run-Size ist base_size_m * 100
|
||
BASE_PX = max(1.0, base_size_m * 100.0)
|
||
for run in runs:
|
||
codes = []
|
||
codes.append("\\f{}".format(font_idx(run.get("font") or default_font)))
|
||
ci = color_idx(run.get("color")) if run.get("color") else 0
|
||
if ci > 0: codes.append("\\cf{}".format(ci))
|
||
# Font-Size: \fs in Halb-Punkten relativ zur TextEntity.TextHeight.
|
||
# Annahme: \fs20 = 1.0× base. Scaling: \fs = 20 * (run_px / base_px).
|
||
fsp = run.get("fontSizePx")
|
||
if fsp and abs(fsp - BASE_PX) > 0.1:
|
||
rtf_fs = max(2, int(round(20.0 * fsp / BASE_PX)))
|
||
codes.append("\\fs{}".format(rtf_fs))
|
||
codes.append("\\b" if run.get("bold") else "\\b0")
|
||
codes.append("\\i" if run.get("italic") else "\\i0")
|
||
codes.append("\\ul" if run.get("underline") else "\\ulnone")
|
||
if run.get("sup"): codes.append("\\super")
|
||
elif run.get("sub"): codes.append("\\sub")
|
||
else: codes.append("\\nosupersub")
|
||
body = _rtf_escape(run.get("text") or "")
|
||
parts.append("{} {}".format("".join(codes), body))
|
||
|
||
parts.append("}")
|
||
return "".join(parts)
|
||
|
||
|
||
def open_with_frame(p1, p2, origin, width, height):
|
||
"""Frame-Pick-Workflow → Editor oeffnen, neuer Text erstellt."""
|
||
_ensure_double_click_hook()
|
||
doc = Rhino.RhinoDoc.ActiveDoc
|
||
settings = text_create.load_settings(doc)
|
||
fonts = text_create.available_fonts()
|
||
bridge = TextEditorBridge((origin, width, height, p1, p2),
|
||
settings, fonts)
|
||
sc.sticky["text_editor_bridge"] = bridge
|
||
|
||
form = panel_base.open_satellite_window(
|
||
"text_editor",
|
||
title="Dossier Text",
|
||
size=(640, 480),
|
||
bridge=bridge,
|
||
topmost=True)
|
||
if form is not None:
|
||
bridge.set_form(form)
|
||
|
||
|
||
def open_for_edit(obj):
|
||
"""Bei Doppelklick auf einen DOSSIER-Text: Editor mit den
|
||
bestehenden Settings + Text-Inhalt oeffnen. COMMIT macht REPLACE."""
|
||
_ensure_double_click_hook()
|
||
doc = Rhino.RhinoDoc.ActiveDoc
|
||
if doc is None or obj is None: return
|
||
te = obj.Geometry
|
||
if not isinstance(te, rg.TextEntity): return
|
||
|
||
# Settings aus dem bestehenden Text lesen
|
||
settings = text_create.load_settings(doc)
|
||
try: settings["size"] = float(te.TextHeight)
|
||
except Exception: pass
|
||
try:
|
||
if te.Font:
|
||
settings["font"] = te.Font.QuartetName
|
||
settings["bold"] = bool(te.Font.Bold)
|
||
settings["italic"] = bool(te.Font.Italic)
|
||
try: settings["underline"] = bool(te.Font.Underlined)
|
||
except Exception: pass
|
||
except Exception: pass
|
||
try:
|
||
h = te.TextHorizontalAlignment
|
||
if h == Rhino.DocObjects.TextHorizontalAlignment.Center:
|
||
settings["align"] = "center"
|
||
elif h == Rhino.DocObjects.TextHorizontalAlignment.Right:
|
||
settings["align"] = "right"
|
||
else: settings["align"] = "left"
|
||
except Exception: pass
|
||
|
||
initial_text = ""
|
||
try: initial_text = te.PlainText or ""
|
||
except Exception: pass
|
||
|
||
# Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung)
|
||
p1 = te.Plane.Origin
|
||
try:
|
||
bb = te.GetBoundingBox(False)
|
||
if bb.IsValid:
|
||
p2 = rg.Point3d(bb.Max.X, bb.Min.Y, p1.Z)
|
||
origin = rg.Point3d(bb.Min.X, bb.Max.Y, p1.Z)
|
||
width = bb.Max.X - bb.Min.X
|
||
height = bb.Max.Y - bb.Min.Y
|
||
else:
|
||
p2 = p1
|
||
origin = p1
|
||
width = 1.0; height = 0.5
|
||
except Exception:
|
||
p2 = p1; origin = p1; width = 1.0; height = 0.5
|
||
|
||
fonts = text_create.available_fonts()
|
||
bridge = TextEditorBridge(
|
||
(origin, width, height, p1, p2),
|
||
settings, fonts,
|
||
edit_obj_id=obj.Id,
|
||
initial_text=initial_text)
|
||
sc.sticky["text_editor_bridge"] = bridge
|
||
|
||
form = panel_base.open_satellite_window(
|
||
"text_editor",
|
||
title="Dossier Text (Bearbeiten)",
|
||
size=(640, 480),
|
||
bridge=bridge,
|
||
topmost=True)
|
||
if form is not None:
|
||
bridge.set_form(form)
|