Add Workbench-style property sheet to the Page Editor

The Page Editor's right pane now shows the selected widget's properties
under the widget tree, laid out like Workbench's Ox editor rather than
as raw Sedona slots:

- layout (x, y, width, height), font (name, size, style)
- alignment / transparent / subscriptDigits from the attributes bits
- shape, RGB565 colours with a swatch and picker
- DateLabelSet date options split out of dateFormatOptions
- lookup labelText editor (prefix, items, suffix) and NumericLabel
  printf-format editor, min/max as one row
- image name picker, slot link chooser for in/blink/visibility/touch
- Buf text decoded from base64 (Latin-1), plain text in live mode

Edits commit on Enter/focus-out/selection as one undo step and are
written to the device in live mode. Canvas drag-moves now use the same
path, so they are undoable and reach the device too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 13:52:01 +03:00
co-authored by Claude Opus 5.5
parent f241448a5b
commit 43d4849533
3 changed files with 1232 additions and 27 deletions
+72 -26
View File
@@ -23,6 +23,10 @@ except ImportError:
_HAS_PIL = False
import app_state
import live_hooks
import undo
from ox_widget_properties import OxWidgetPropertiesPanel
from sax_dom import set_prop_value
HEADER_BG = "#f2f2f2"
@@ -92,15 +96,6 @@ def _get_prop(comp, name, default=None):
return prop.get("val", default) if prop is not None else default
def _set_prop(comp, name, value):
prop = comp.find(f"prop[@name='{name}']")
if prop is not None:
prop.set("val", value)
else:
import xml.etree.ElementTree as ET
ET.SubElement(comp, "prop", name=name, val=value)
def _short_type(full_type):
short = full_type.rsplit("::", 1)[-1] if "::" in full_type else full_type
return _SHORT_TYPE_NAMES.get(short, short)
@@ -189,7 +184,7 @@ class OxPageViewPanel:
self._build_header()
self._build_body()
self._build_footer()
self._render_page()
self.refresh()
@property
def _screen_canvas_x(self):
@@ -230,9 +225,16 @@ class OxPageViewPanel:
self._build_canvas(canvas_frame)
body.add(canvas_frame, weight=1)
tree_frame = ttk.Frame(body)
# Workbench's layout: the widget tree above, the selected widget's sheet below.
side = ttk.PanedWindow(body, orient=tk.VERTICAL)
body.add(side, weight=0)
tree_frame = ttk.Frame(side)
self._build_widget_tree(tree_frame)
body.add(tree_frame, weight=0)
side.add(tree_frame, weight=1)
self._props = OxWidgetPropertiesPanel(side, self._commit_props, self._repaint_all)
side.add(self._props.frame, weight=3)
def _build_widget_tree(self, parent):
ttk.Label(parent, text="Widgets", font=("TkDefaultFont", 9, "bold")).pack(
@@ -242,7 +244,7 @@ class OxPageViewPanel:
tree_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
self._tree = ttk.Treeview(tree_container, show="tree", selectmode="browse",
height=12)
height=8)
vscroll = ttk.Scrollbar(tree_container, orient=tk.VERTICAL,
command=self._tree.yview)
self._tree.configure(yscrollcommand=vscroll.set)
@@ -386,6 +388,8 @@ class OxPageViewPanel:
c.delete("selection_handle")
self._selected_widget = widget
# With nothing selected the sheet shows the page itself, as Workbench does.
self._props.show(widget if widget is not None else self.comp)
if widget is None:
return
@@ -433,6 +437,48 @@ class OxPageViewPanel:
fill=HANDLE_COLOUR, outline=HANDLE_COLOUR,
tags=("selection_handle",))
def _repaint_all(self):
"""Every view redrawn from the document, this one included."""
if self.on_change is not None:
self.on_change()
else:
self.refresh()
def _commit_props(self, comp, values):
"""Writes {prop: raw} to `comp` as one undoable step, and to the device when live.
Returns whether anything changed; a change repaints every view, this one included.
"""
changes = []
for name, after in values.items():
prop = comp.find(f"prop[@name='{name}']")
before = prop.get("val") if prop is not None else None
if before != after:
changes.append((comp, name, before, after))
if not changes:
return False
live_failures = []
for element, name, _before, after in changes:
set_prop_value(element, name, after)
if live_hooks.is_live() and not live_hooks.live_set_prop_value(element, name, after):
live_failures.append(name)
if not app_state.live_mode:
undo.push(undo.SlotValueCommand(changes))
app_state.mark_dirty()
self._repaint_all()
who = comp.get("name", "?")
if live_failures:
app_state.set_status(
f"{who}: written locally but failed on device: {', '.join(live_failures)}")
else:
app_state.set_status(
f"{who}: {', '.join(name for _, name, _, _ in changes)} changed.")
return True
def _on_canvas_click(self, event):
c = self._canvas
cx = c.canvasx(event.x)
@@ -484,9 +530,11 @@ class OxPageViewPanel:
self._drag["tag"] = None
return
self._drag["tag"] = None
c = self._canvas
z = self._zoom
bbox = c.bbox(f"{tag} && ox_widget_rect")
moved = False
if bbox:
new_x = int((bbox[0] - self._screen_canvas_x * z) / z)
new_y = int((bbox[1] - self._screen_canvas_y * z) / z)
@@ -494,19 +542,12 @@ class OxPageViewPanel:
new_x = max(0, min(new_x, self._screen_w))
new_y = max(0, min(new_y, self._screen_h))
old_x = _get_prop(widget, "x", "0")
old_y = _get_prop(widget, "y", "0")
moved = self._commit_props(widget, {"x": str(new_x), "y": str(new_y)})
if str(new_x) != old_x or str(new_y) != old_y:
_set_prop(widget, "x", str(new_x))
_set_prop(widget, "y", str(new_y))
app_state.mark_dirty()
self._drag["tag"] = None
self._render_page()
if widget is not None:
self._select_widget(widget)
# A commit re-renders through refresh(); a drag that went nowhere still has to
# put back the handles the motion took down.
if not moved:
self.refresh()
def _on_canvas_right_click(self, event):
c = self._canvas
@@ -605,10 +646,15 @@ class OxPageViewPanel:
zw = int(self._canvas_w * z)
zh = int(self._canvas_h * z)
self._canvas.config(scrollregion=(0, 0, zw, zh))
self._render_page()
self.refresh()
def refresh(self):
"""Redraws from the document, keeping the selection when the widget survives."""
selected = self._selected_widget
self._render_page()
if selected is not None and selected not in _get_widget_children(self.comp):
selected = None
self._select_widget(selected)
def _background(self):
return app_state.DIRTY_TINT if app_state.is_dirty else CANVAS_BG