Pikchr UI Subset
Status: Normative
Version: PUS-1 (2026-08-16)
Parent decision: D3.2 — GP-Crystal Design Overview §3
1. Purpose
This page defines the Pikchr UI Subset (PUS-1): the exact set of Pikchr constructs that GP-Crystal UI blocks may emit. The subset is sized so that both target renderers can faithfully express every construct:
- Web renderer — Fossil's built-in Pikchr→SVG pipeline.
- Embedded renderer — a Pikchr-subset→lvgl-crystal translator targeting badge-class devices (absolute-positioned, static layout; reflow out of scope).
Any Pikchr construct not listed here is excluded. UI block emitters MUST NOT emit excluded constructs.
2. Element Table
2.1 box
box wid 1.20 ht 0.40 rad 0.03 with .nw at (0.10,3.20) fill 0x4C97FF color 0x4C97FF→ /pikchrshow
| Attribute | Type | Requirement | Notes |
|---|---|---|---|
wid |
float (Pikchr in) | Required | Width; converted to pixels by the layout pass (§3). |
ht |
float (Pikchr in) | Required | Height. |
rad |
float (Pikchr in) | Optional | Corner radius; 0 = sharp. Omit for square corners. |
fill |
0xRRGGBB hex |
Required | Fill color. |
color |
0xRRGGBB hex |
Required | Border/stroke color. |
at |
(x, y) literal |
Required | Anchor position; MUST be an explicit numeric literal (see §3). |
with |
anchor selector | Optional | Which point of the box lands on at. Default .center. Subset allows .nw, .center, .ne, .sw, .se, .n, .s, .e, .w. |
lvgl-crystal mapping:
| Condition | Widget | Key calls |
|---|---|---|
No op= annotation |
Lvgl::Object.new(parent) |
set_pos, set_size, set_style_bg_color(fill), set_style_border_color(color), set_style_radius(rad_px) |
Has op= annotation |
Lvgl::Widgets::Button.new(parent) |
Same geometry/style calls; plus Lvgl::Event subscription for Code::Clicked (or ev= override) dispatching the annotated op. |
2.2 text
text "Start" color 0xFFFFFF at (0.70,3.00)→ /pikchrshow
| Attribute | Type | Requirement | Notes |
|---|---|---|---|
| string | quoted literal | Required | Visible label. Single line only; no embedded newlines. |
at |
(x, y) literal |
Required | Center of the text bounding box (absolute). |
color |
0xRRGGBB hex |
Optional | Text color; default 0x000000 (black). |
lvgl-crystal mapping: Lvgl::Widgets::Label.new(parent) with .text = <string>
and set_style_text_color(color). Position: set_pos at the resolved center minus
half the text bounds (font-dependent; best-effort on embedded).
Overlap rule: when a text element's bounding box lies within a box element's
bounds, the lvgl translator MUST create the label as a child of that box widget, not
as a sibling on the screen root.
2.3 line
line from (0.10,2.00) to (2.90,2.00) color 0xCCCCCC→ /pikchrshow
| Attribute | Type | Requirement | Notes |
|---|---|---|---|
from |
(x, y) literal |
Required | Start coordinate (absolute). |
to |
(x, y) literal |
Required | End coordinate (absolute). |
color |
0xRRGGBB hex |
Optional | Stroke color; default 0x000000. |
lvgl-crystal mapping: LibLvgl.lv_line_create(parent_ptr) with a two-point
lv_point_t array. (Lvgl::Widgets::Line is not yet a named wrapper; the
translator calls LibLvgl directly.)
2.4 arrow
An arrow is a line with one or both arrowheads. All line attributes apply, plus:
| Token | Position | Meaning |
|---|---|---|
-> |
after keyword | Arrowhead at the to end. |
<- |
after keyword | Arrowhead at the from end. |
<-> |
after keyword | Arrowheads at both ends. |
arrow from (0.50,1.50) to (2.50,1.50) color 0x4C97FF→ /pikchrshow
lvgl-crystal mapping: same as line; arrowheads are drawn as small filled
triangles computed from the endpoint direction vector, rendered as additional
lv_line segments.
2.5 circle
circle rad 0.20 at (1.50,2.80) fill 0xFF8C1A color 0xFF8C1A→ /pikchrshow
| Attribute | Type | Requirement | Notes |
|---|---|---|---|
rad |
float (Pikchr in) | Required | Radius. |
at |
(x, y) literal |
Required | Center position (absolute). |
fill |
0xRRGGBB hex |
Required | Fill color. |
color |
0xRRGGBB hex |
Required | Border color. |
lvgl-crystal mapping: LibLvgl.lv_arc_create(parent_ptr) with arc range set
to 0–360° (full circle); rad × 2 maps to set_size. The element is circle,
not ellipse, precisely because lv_arc is circular — a non-circular ellipse
has no faithful lvgl rendering, so the subset does not offer one.
3. Layout Resolution Rule
Pikchr's default placement model chains elements relative to previously placed objects. PUS-1 forbids implicit chaining and mandates explicit absolute coordinates.
Rule 1 — Mandatory at clause. Every element MUST include an explicit
at (x, y) clause where both coordinates are numeric literals.
Implicit Pikchr placement (chaining from the previous element's edge) is excluded.
Rule 2 — Mandatory scale line. Every PUS-1 diagram MUST open with:
scale = <N>
This resolves the pixel formula (both renderers apply it identically):
px = round(pikchr_inches × 72 × scale)
Typical value: scale = 0.75 (matches the gpcr renderer's current default).
Rule 3 — Y-axis inversion (lvgl only). Pikchr's origin is bottom-left; lvgl's is top-left. The embedded translator applies:
px_x = round(x_in × 72 × scale)
px_y = round((canvas_height_in − y_in) × 72 × scale)
canvas_height_in is computed from the bounding box of all declared elements.
Rule 4 — Anchor resolution. The translator resolves each element's top-left
corner from the declared at point and with anchor before calling set_pos:
with .nw:atis the top-left corner directly.with .centeror nowith: top-left =at − (wid/2, ht/2).- Other anchors: computed geometrically from element dimensions.
Rule 5 — No reflow. Dynamic layout (text wrapping, flex, grid) is out of scope for PUS-1. Screens are authored at fixed pixel dimensions matched to the target display.
4. Interactivity Annotation Convention
Pikchr only draws; it carries no event semantics. PUS-1 annotates interactive elements via a trailing Pikchr comment on the same source line as the element. Both the web JS layer and the lvgl event handler parse the identical comment text from the same Pikchr source, making the annotation the single source of truth for both renderers.
4.1 Syntax
<pikchr-element-line> # [id=<name>] [op=<json>] [ev=<event>]
Fields are space-separated within the comment. At least one of id= or op=
MUST be present for the comment to be treated as an annotation (plain comments
without these fields are ignored).
| Field | Format | Description |
|---|---|---|
id=<name> |
id= + [a-z][a-z0-9_]* |
Stable element identifier. Used as the SVG id attribute (web) and as the event registration key (lvgl). MUST be unique within a diagram. |
op=<json> |
op= + compact JSON object (no internal spaces) |
GP-Crystal op dispatched on activation; identical JSON consumed by both renderers. |
ev=<event> |
ev=clicked | ev=pressed | ev=short_clicked | ev=value_changed |
lvgl event code to subscribe to; default clicked. The web renderer always uses click regardless of ev=. |
4.2 Web renderer behavior
A single external JS asset (no inline scripts; CSP-safe per D3.1) scans the Pikchr source that produced each rendered SVG page. For each annotated element it:
- Locates the SVG element whose
idattribute matchesid=<name>. - Attaches a
clicklistener that HTTP-POSTs the JSON fromop=to the management API endpoint (ticket 21dfd164d6). - Sets
cursor: pointeron the element.
Element mapping is ordinal. Pikchr emits no id attributes in its SVG output;
it does, however, emit graphics elements in source order. The scanner therefore
reads the diagram's Pikchr source (the page embeds it — Fossil's toggle fences
already do), and maps the k-th shape-producing line of the source to the k-th
top-level graphics element of the SVG. id= is a pure annotation-namespace
identifier and has no relationship to Pikchr tags:
box wid 1.20 ht 0.40 ... # id=blink_btn op={"op":"call","target":"blink"}
Pikchr tags (Label:) remain available for positioning within the diagram; note
that Pikchr requires tag names to begin with an uppercase letter, another reason
they are unsuitable as annotation ids.
4.3 lvgl-crystal behavior
The translator parses annotation comments before constructing widgets. For each annotated element it:
- Creates the widget per §2.
- Registers a
Lvgl::Eventsubscription for the event code named byev=(defaultCode::Clicked) on the new widget. - On event fire, passes the
op=JSON string to the local op-apply engine (the same engine that processes voice and model edits — Design Overview §1).
4.4 Annotated diagram example
scale = 0.75
box wid 1.20 ht 0.40 rad 0.03 with .nw at (0.10,3.20) fill 0x4C97FF color 0x4C97FF # id=start_btn op={"op":"call","target":"blink"}
text "Start" color 0xFFFFFF at (0.70,3.00)
box wid 1.20 ht 0.40 rad 0.03 with .nw at (1.50,3.20) fill 0xFF6680 color 0xFF6680 # id=stop_btn op={"op":"call","target":"stop_blink"}
text "Stop" color 0xFFFFFF at (2.10,3.00)
circle rad 0.15 at (2.75,3.05) fill 0xFF0000 color 0xFF0000 # id=led_status
line from (0.10,2.80) to (3.00,2.80) color 0xCCCCCC
→ /pikchrshowIn this example start_btn and stop_btn are interactive (Button on lvgl; click
listeners in the web layer); led_status has an id for programmatic update but no
op= (static on lvgl; addressable by the JS layer for visual state changes via SSE
re-render); the line is a pure static separator.
5. Explicit Exclusions
5.1 Excluded shape keywords
ellipse, arc, cylinder, file, oval, diamond, dot, spline.
ellipse is excluded because lvgl has no ellipse (lv_arc is circular) — use
circle. arc is excluded because Pikchr renders arcs as open paths with
fill:none regardless of the fill attribute, so no filled dual rendering
exists.
5.2 Excluded attributes and modifiers
| Attribute / modifier | Reason excluded |
|---|---|
dashed, dotted |
No direct lvgl analogue in subset. |
thickness |
Line weight not yet mapped. |
chop |
Endpoint shortening has no lvgl analogue. |
same |
Inheriting attributes from the previous element depends on implicit ordering. |
fit |
Auto-sizing is incompatible with the fixed-layout rule (§3 Rule 5). |
invisible, invis |
No lvgl analogue in subset. |
Standalone string label on non-text elements |
Use a separate text element. |
above, below, ljust, rjust |
Relative text placement; use explicit at instead. |
5.3 Excluded placement forms
| Form | Example (excluded) | Reason |
|---|---|---|
| Implicit chaining | box; box |
Position depends on previous element without at. |
| Edge-relative expressions | at last box.e + (0.2,0) |
Requires element tracking; all positions MUST be numeric literals. |
| Direction specifiers | right 1.0 |
Relative cursor movement. |
move statements |
move right 0.5 |
Implicit cursor movement. |
5.4 Excluded control flow and macros
define, loop, until, print, assert.
5.5 Excluded interactivity
| Event / widget | Status |
|---|---|
| Hover | Web-only; no lvgl analogue. Excluded. |
| Drag, scroll | Out of scope for badge-class target. Excluded. |
| Long-press | PUS-2 candidate. |
| Text input | No Pikchr-renderable primitive. Excluded. |
Lvgl::Widgets::Slider |
No PUS-1 Pikchr primitive maps to a slider shape. PUS-2 candidate. |
6. Version History
| Version | Date | Summary |
|---|---|---|
| PUS-1 | 2026-08-16 | Initial normative subset. Five elements: box, text, line, arrow, ellipse. Annotation convention: # id= op= ev= trailing comment. Layout resolution: explicit at (x,y) + scale = + Y-inversion for lvgl. |
| PUS-2 | 2026-08-17 | ellipse → circle (lv_arc is circular; no silent degradation); arc explicitly excluded (Pikchr arcs are open, unfillable paths); element mapping made ordinal (Pikchr emits no SVG ids). |
PUS-2 will be drafted once the pixel formula is validated on real lvgl-crystal hardware and at least one working UI block has been rendered on both targets.
First artifact of D3.2. Implementation in a separate ticket.