Render Ox widgets on the Page Editor canvas as the device draws them
The Page Editor canvas now paints widgets WYSIWYG instead of as labelled placeholder boxes, following the ontrolOx draw() code and the R-ION firmware: - text drawn with the device's own bitmap fonts (fontBank .bin / fontBankXL .oxfl), Windows font fallback; alignment, clipping at the box edge, half-size subscript digits, transparent vs filled boxes; the line is centred on the box even when the font is taller - each widget shows the text its `in` selects: NumericLabel formats, lookup items, Boolean true/false text and colours, date and time - images from the local image banks, centred or cropped; missing ones drawn as the device's red/white checker; page background image - 10x10 pixel grid over the page colour, with a Grid toggle - widgets hit-tested by layout box so transparent labels stay clickable; hidden widgets shown as a dashed outline - layout row gets an X/Y/Width/Height dialog - scaled device frame cached per zoom (redraws ~15 ms) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+87
-63
@@ -26,6 +26,7 @@ import app_state
|
||||
import live_hooks
|
||||
import undo
|
||||
from ox_widget_properties import OxWidgetPropertiesPanel
|
||||
from ox_widget_render import WidgetPainter
|
||||
from sax_dom import set_prop_value
|
||||
|
||||
HEADER_BG = "#f2f2f2"
|
||||
@@ -53,13 +54,11 @@ ZOOM_LEVELS = (0.25, 0.33, 0.5, 0.67, 0.8, 1.0, 1.25, 1.5, 2.0, 3.0)
|
||||
|
||||
CANVAS_PADDING = 16
|
||||
|
||||
WIDGET_OUTLINE = "#7f9db9"
|
||||
WIDGET_OUTLINE_WIDTH = 1
|
||||
SELECTED_OUTLINE = "#1f4e79"
|
||||
SELECTED_OUTLINE_WIDTH = 2
|
||||
HANDLE_SIZE = 6
|
||||
HANDLE_COLOUR = "#00b050"
|
||||
WIDGET_MIN_SIZE = 4
|
||||
|
||||
_SHORT_TYPE_NAMES = {
|
||||
"NumericLabel": "NL",
|
||||
@@ -143,6 +142,22 @@ def _get_widget_children(page_comp):
|
||||
|
||||
CANVAS_BG = "#e0e0e0"
|
||||
|
||||
GRID_STEP = 10 # device pixels
|
||||
GRID_MIN_SPACING = 4 # canvas pixels; a denser grid is left out rather than drawn solid
|
||||
GRID_SHADE = 0.82 # grid line = page colour scaled by this (lifted on dark pages)
|
||||
|
||||
# Whether the 10x10 grid is drawn, held for the session like the Sedona sheet's toggles.
|
||||
show_grid = True
|
||||
|
||||
|
||||
def _grid_colour(page_hex):
|
||||
r, g, b = (int(page_hex[i:i + 2], 16) for i in (1, 3, 5))
|
||||
if (r * 299 + g * 587 + b * 114) / 1000 < 60:
|
||||
r, g, b = (min(255, v + 45) for v in (r, g, b))
|
||||
else:
|
||||
r, g, b = (int(v * GRID_SHADE) for v in (r, g, b))
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
class OxPageViewPanel:
|
||||
|
||||
@@ -179,6 +194,7 @@ class OxPageViewPanel:
|
||||
self._selected_widget = None
|
||||
self._widget_items = {}
|
||||
self._tag_to_comp = {}
|
||||
self._widget_boxes = {}
|
||||
self._drag = {"tag": None, "x": 0, "y": 0}
|
||||
|
||||
self._build_header()
|
||||
@@ -217,6 +233,11 @@ class OxPageViewPanel:
|
||||
tk.Label(header, textvariable=self._widget_count_var, font=plain_font,
|
||||
fg="#555555", bg=HEADER_BG).pack(side=tk.RIGHT)
|
||||
|
||||
self._grid_var = tk.BooleanVar(value=show_grid)
|
||||
tk.Checkbutton(header, text="Grid", variable=self._grid_var, font=plain_font,
|
||||
bg=HEADER_BG, activebackground=HEADER_BG,
|
||||
command=self._on_grid_toggled).pack(side=tk.RIGHT, padx=(0, 12))
|
||||
|
||||
def _build_body(self):
|
||||
body = ttk.PanedWindow(self.frame, orient=tk.HORIZONTAL)
|
||||
body.pack(fill=tk.BOTH, expand=True)
|
||||
@@ -283,6 +304,7 @@ class OxPageViewPanel:
|
||||
c.delete("all")
|
||||
self._widget_items.clear()
|
||||
self._tag_to_comp.clear()
|
||||
self._widget_boxes.clear()
|
||||
z = self._zoom
|
||||
|
||||
sx = self._screen_canvas_x * z
|
||||
@@ -294,13 +316,22 @@ class OxPageViewPanel:
|
||||
fill=bg_colour, outline="",
|
||||
tags=("screen_bg",))
|
||||
|
||||
self._painter = WidgetPainter(c, sx, sy, z, bg_colour)
|
||||
self._painter.page_image(_get_prop(self.comp, "imageFileName", ""),
|
||||
self._screen_w, self._screen_h)
|
||||
if self._grid_var.get():
|
||||
self._draw_grid(sx, sy, _grid_colour(bg_colour))
|
||||
|
||||
if self._frame_image:
|
||||
if z != 1.0 and self._frame_pil is not None:
|
||||
orig_w, orig_h = self._frame_pil.size
|
||||
zw = max(1, int(orig_w * z))
|
||||
zh = max(1, int(orig_h * z))
|
||||
resized = self._frame_pil.resize((zw, zh), Image.LANCZOS)
|
||||
self._scaled_frame = ImageTk.PhotoImage(resized)
|
||||
# Rescaled once per zoom level; every edit redraws the page.
|
||||
if getattr(self, "_scaled_frame_zoom", None) != z:
|
||||
orig_w, orig_h = self._frame_pil.size
|
||||
zw = max(1, int(orig_w * z))
|
||||
zh = max(1, int(orig_h * z))
|
||||
resized = self._frame_pil.resize((zw, zh), Image.LANCZOS)
|
||||
self._scaled_frame = ImageTk.PhotoImage(resized)
|
||||
self._scaled_frame_zoom = z
|
||||
c.create_image(self._frame_ox * z, self._frame_oy * z, anchor="nw",
|
||||
image=self._scaled_frame, tags=("device_frame",))
|
||||
else:
|
||||
@@ -320,49 +351,55 @@ class OxPageViewPanel:
|
||||
|
||||
self._populate_tree()
|
||||
|
||||
def _draw_widget(self, widget):
|
||||
def _draw_grid(self, sx, sy, colour):
|
||||
"""The 10x10 device-pixel grid over the page background, under the widgets."""
|
||||
c = self._canvas
|
||||
z = self._zoom
|
||||
sx = self._screen_canvas_x * z
|
||||
sy = self._screen_canvas_y * z
|
||||
if GRID_STEP * z < GRID_MIN_SPACING:
|
||||
return
|
||||
right = sx + self._screen_w * z
|
||||
bottom = sy + self._screen_h * z
|
||||
for gx in range(GRID_STEP, self._screen_w, GRID_STEP):
|
||||
c.create_line(sx + gx * z, sy, sx + gx * z, bottom, fill=colour, tags=("grid",))
|
||||
for gy in range(GRID_STEP, self._screen_h, GRID_STEP):
|
||||
c.create_line(sx, sy + gy * z, right, sy + gy * z, fill=colour, tags=("grid",))
|
||||
|
||||
x = int(_get_prop(widget, "x", "0"))
|
||||
y = int(_get_prop(widget, "y", "0"))
|
||||
w = int(_get_prop(widget, "width", "80"))
|
||||
h = int(_get_prop(widget, "height", "80"))
|
||||
def _on_grid_toggled(self):
|
||||
global show_grid
|
||||
show_grid = self._grid_var.get()
|
||||
self.refresh()
|
||||
|
||||
w = max(WIDGET_MIN_SIZE, w)
|
||||
h = max(WIDGET_MIN_SIZE, h)
|
||||
|
||||
cx = sx + x * z
|
||||
cy = sy + y * z
|
||||
cw = w * z
|
||||
ch = h * z
|
||||
def _draw_widget(self, widget):
|
||||
"""Paints the widget as the device would, then lays an empty box over it.
|
||||
|
||||
The box is what selection outlines and what a drag's bbox is read from, so a
|
||||
transparent label with no text can still be found and moved.
|
||||
"""
|
||||
tag = f"ox_widget_{id(widget)}"
|
||||
comp_type = widget.get("type", "")
|
||||
short = _short_type(comp_type)
|
||||
name = widget.get("name", "?")
|
||||
tags = (tag, "ox_widget")
|
||||
x, y, w, h = self._painter.draw(widget, tags, _short_type(widget.get("type", "")))
|
||||
|
||||
bg_val = _get_prop(widget, "backgroundColor")
|
||||
fill = _rgb565_to_hex(bg_val) if bg_val is not None else "#dce6f2"
|
||||
|
||||
text_val = _get_prop(widget, "textColor")
|
||||
text_colour = _rgb565_to_hex(text_val) if text_val is not None else "#000000"
|
||||
|
||||
c.create_rectangle(cx, cy, cx + cw, cy + ch,
|
||||
fill=fill, outline=WIDGET_OUTLINE,
|
||||
width=WIDGET_OUTLINE_WIDTH,
|
||||
tags=(tag, "ox_widget", "ox_widget_rect"))
|
||||
|
||||
label = f"{name}\n({short})"
|
||||
font_size = max(7, min(10, int(min(cw, ch) // 8)))
|
||||
c.create_text(cx + cw / 2, cy + ch / 2, text=label,
|
||||
fill=text_colour, font=("Segoe UI", font_size),
|
||||
anchor="center", tags=(tag, "ox_widget", "ox_widget_text"))
|
||||
z = self._zoom
|
||||
cx = (self._screen_canvas_x + x) * z
|
||||
cy = (self._screen_canvas_y + y) * z
|
||||
self._canvas.create_rectangle(cx, cy, cx + w * z, cy + h * z, fill="", outline="",
|
||||
width=WIDGET_OUTLINE_WIDTH,
|
||||
tags=(tag, "ox_widget", "ox_widget_rect"))
|
||||
|
||||
self._widget_items[widget] = tag
|
||||
self._tag_to_comp[tag] = widget
|
||||
self._widget_boxes[widget] = (x, y, w, h)
|
||||
|
||||
def _widget_at(self, cx, cy):
|
||||
"""The topmost widget whose box holds canvas point (cx, cy): the last one drawn."""
|
||||
z = self._zoom
|
||||
px = cx / z - self._screen_canvas_x
|
||||
py = cy / z - self._screen_canvas_y
|
||||
for widget in reversed(_get_widget_children(self.comp)):
|
||||
box = self._widget_boxes.get(widget)
|
||||
if box and box[0] <= px <= box[0] + box[2] and box[1] <= py <= box[1] + box[3]:
|
||||
return widget
|
||||
return None
|
||||
|
||||
def _populate_tree(self):
|
||||
for item in self._tree.get_children():
|
||||
@@ -383,8 +420,7 @@ class OxPageViewPanel:
|
||||
def _select_widget(self, widget):
|
||||
c = self._canvas
|
||||
|
||||
c.itemconfig("ox_widget_rect", outline=WIDGET_OUTLINE,
|
||||
width=WIDGET_OUTLINE_WIDTH)
|
||||
c.itemconfig("ox_widget_rect", outline="", width=WIDGET_OUTLINE_WIDTH)
|
||||
c.delete("selection_handle")
|
||||
|
||||
self._selected_widget = widget
|
||||
@@ -397,9 +433,11 @@ class OxPageViewPanel:
|
||||
if tag is None:
|
||||
return
|
||||
|
||||
# Only the outline comes forward: raising the widget itself would paint it over
|
||||
# the ones the device draws later.
|
||||
c.itemconfig(f"{tag} && ox_widget_rect", outline=SELECTED_OUTLINE,
|
||||
width=SELECTED_OUTLINE_WIDTH)
|
||||
c.tag_raise(tag)
|
||||
c.tag_raise(f"{tag} && ox_widget_rect")
|
||||
|
||||
bbox = c.bbox(f"{tag} && ox_widget_rect")
|
||||
if bbox:
|
||||
@@ -484,18 +522,11 @@ class OxPageViewPanel:
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
clicked = c.find_closest(cx, cy, halo=3)
|
||||
if not clicked:
|
||||
self._select_widget(None)
|
||||
return
|
||||
|
||||
tags = c.gettags(clicked[0])
|
||||
widget_tag = next((t for t in tags if t.startswith("ox_widget_")), None)
|
||||
|
||||
if widget_tag and widget_tag in self._tag_to_comp:
|
||||
widget = self._tag_to_comp[widget_tag]
|
||||
# Hit-tested by box, not by canvas item: a transparent label is mostly grid.
|
||||
widget = self._widget_at(cx, cy)
|
||||
if widget is not None:
|
||||
self._select_widget(widget)
|
||||
self._drag["tag"] = widget_tag
|
||||
self._drag["tag"] = self._widget_items[widget]
|
||||
self._drag["x"] = cx
|
||||
self._drag["y"] = cy
|
||||
else:
|
||||
@@ -554,16 +585,9 @@ class OxPageViewPanel:
|
||||
cx = c.canvasx(event.x)
|
||||
cy = c.canvasy(event.y)
|
||||
|
||||
clicked = c.find_closest(cx, cy, halo=3)
|
||||
if not clicked:
|
||||
widget = self._widget_at(cx, cy)
|
||||
if widget is None:
|
||||
return
|
||||
|
||||
tags = c.gettags(clicked[0])
|
||||
widget_tag = next((t for t in tags if t.startswith("ox_widget_")), None)
|
||||
if not widget_tag or widget_tag not in self._tag_to_comp:
|
||||
return
|
||||
|
||||
widget = self._tag_to_comp[widget_tag]
|
||||
self._select_widget(widget)
|
||||
|
||||
menu = tk.Menu(self._canvas, tearoff=0)
|
||||
|
||||
Reference in New Issue
Block a user