VariablesPanel chips were previously click-swallowing placeholders.
This commit adds the actual axis-cycle behavior — clicking the
"mode: dark" chip flips it to "mode: light", then "mode: sepia",
wrapping back to "mode: light". The TS app has the equivalent
behavior via its theme dropdown; the Rust shell now offers the
one-click chip flip as the simpler entry point.
`crates/openpencil-shell-core/src/document/variables.rs`:
- `VariableTable::cycle_active_axis_value(axis) -> bool`:
1. Returns false (no-op) when `axis` isn't in `themes` or
its values list is empty.
2. When the axis isn't in `active_theme`, seeds it with the
first value.
3. When current value matches an entry, advances to the
next (wrapping past the last).
4. When current value is unrecognized (axis options
changed since file loaded), falls back to the first.
`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
- `dispatch_variables_panel_press` `AxisChip(idx)` branch now
looks up the axis name by position in `active_theme` (BTreeMap
iteration is stable + matches the chip walk order in
VariablesPanel::paint), commits any pending property focus,
captures an undo snapshot, calls `cycle_active_axis_value`,
and pushes the snapshot onto history when the cycle actually
moved (return true). Snapshot+restore covers var_table per
the prior history fix (99d602a3), so undo round-trips the
theme cycle the same way it does variable color edits.
Tests (5 added, 300 shell-core total):
- `cycle_active_axis_seeds_first_value_when_absent` — axis in
`themes` but not in `active_theme` → cycle plants the first
value.
- `cycle_active_axis_advances_to_next_value` — three-value
walk through light → dark → sepia → light (wrap).
- `cycle_active_axis_returns_false_for_unknown_axis` — no-op +
false.
- `cycle_active_axis_returns_false_for_empty_values` — defined
in themes but with empty values list → no-op + false.
- `cycle_active_axis_falls_back_to_first_when_current_unknown`
— graceful degradation when active_theme has a stale value
(axis options changed since file load).
The variable edit chain now covers BOTH primary surfaces:
- Row click on a Color-kind variable → ColorPicker → set_color_hex
- Axis chip click → cycle_active_axis_value
Both push undo entries that restore through the var_table-aware
snapshot.
The previous commit (7fafa379) moved fill-picker dismiss to the
very top of the press cascade, but that stole layer-context-menu
clicks (codex stop-gate). The commit before THAT (c3394fda) had it
late enough to be stolen by VariablesPanel.
Right answer: middle position (the original 0c0 spot, between
TopBar and VariablesPanel) — after the higher-z overlays (color
picker, layer-context-menu, agent-settings modal) so those keep
their own click handling when both happen to be open, but BEFORE
the rail walkers (VariablesPanel, PropertyPanel) so the picker
can't survive a click into them.
Cascade now reads:
pre rename / text-edit blur
pre agent-settings modal
0-c color picker overlay
pre layer context menu
0aa commit-on-blur for property inputs
0z panel resize gutter
0ab shape picker overlay
0a locale picker overlay
0b TopBar
0c0 **fill-type picker dismiss** ← settled position
0b1 VariablesPanel
0c PropertyPanel input + action
1+ chat, toolbar, canvas, …
The remaining edge case — TopBar / shape-picker / locale-picker
clicks while fill-picker is open consume the click without closing
fill — is pre-existing behavior. The cascade ordering between
those small overlays is a wider design question (last-opened wins
vs. priority list); not in scope for this commit.
press.rs: 839 → 840 (+1). 295 shell-core + 20 shell-native tests
pass.
Codex stop-gate caught: the previous commit (c3394fda) called the
fill-picker dismiss "first" but it was actually at step 0c0 — after
0-color (color picker), layer-context-menu, 0aa (commit-on-blur),
0z (panel resize), 0ab (shape picker), 0a (locale picker), and 0b
(TopBar). Any of those earlier steps could consume a click and
leave `fill_type_picker_open=true` behind.
Moved the entire fill-picker dismiss block to step 0-fp, which
runs immediately after the rename / text-edit blur lines and the
agent-settings modal dispatch — before any other overlay can
swallow the click:
pre. rename + text-edit blur (always run)
pre. agent-settings modal dispatch (only when modal open)
0-fp. **fill-type picker dismiss** ← now first
0-color. color picker overlay
... (all other overlays)
0b1. VariablesPanel
0c. PropertyPanel
...
Behavior: any click while the fill-type picker is open closes it
+ swallows the click (matches previous semantics for the case
where the click was outside the picker's dropdown rows). Clicks
that land on the picker's own dropdown rows still route to
SetFillType / ToggleFillTypePicker.
Concrete fix: a sequence like "open fill picker on Property panel
→ click TopBar locale globe" now closes the fill picker (which the
old order missed because TopBar at 0b consumed the click first).
press.rs: 836 → 839 (+3). The block content is unchanged; only its
position moved, so the file growth is from the new doc-comment.
Still over the 800-line cap as pre-existing tech debt. 295
shell-core + 20 shell-native tests still pass.
Codex stop-gate on the previous reorder (96cf753b): putting
`dispatch_variables_panel_press` at the absolute top of the cascade
meant a Variables-row click while the PropertyPanel's fill-type
picker was open returned true and left `fill_type_picker_open=true`.
The fill picker would float over the chrome until the next
unrelated click happened to land outside its rect.
Cascade order now:
0c0. Fill-type picker outside-click dismiss
(must run first when open — any click anywhere closes the
picker, then swallows or routes to a SetFillType /
ToggleFillTypePicker action).
0b1. VariablesPanel hit dispatch
(BEFORE PropertyPanel so the bottom-anchored Variables rect
wins for clicks in its z-order overlap with the rail).
0c. PropertyPanel input + action hit-tests.
The fill-picker block already swallows every click when open, so
reaching the Variables dispatcher requires the picker to be
closed — which means the existing dismiss path runs unconditionally
before any Variables click can fire. 295 shell-core + 20 shell-
native tests still pass.
press.rs: 832 → 836 (the dismiss comment + restored ordering adds
4 lines). Still over the 800 cap as pre-existing tech debt; my
session has added +11 lines total to this file (825 → 836), which
is small and bounded.
Codex stop-gate caught: PropertyPanel was tested first in the press
cascade, so any click in the rail's bottom region went to its hit
walker — which would resolve to e.g. the Export-section row or an
input — even when the click was visually OVER the bottom-anchored
VariablesPanel. Paint stack has VariablesPanel ON TOP of
PropertyPanel (step 4 → step 4b), so hit-test order must follow.
`crates/openpencil-shell-native/src/widget_host/press.rs`:
- The `dispatch_variables_panel_press` call moved from step 0d
(after PropertyPanel) to step 0b1 (before fill-type picker +
PropertyPanel input dispatch). Returns true when the click
consumes a Variables row / chip; otherwise false, so the
cascade falls through to the property-panel walkers exactly as
before for non-rail-bottom clicks.
- The dispatcher itself short-circuits when `var_table.variables`
is empty (existing guard), so the reorder is a no-op for
documents without variables.
Test surface unchanged — 295 shell-core + 20 shell-native tests
still pass. The fix is purely an ordering change; no new logic.
press.rs: 833 → 832 (the relocation trims one blank line). Still
over the 800 cap as pre-existing tech debt; not made materially
worse by this commit.
Closes the missing host wire that codex called out as pending: the
VariablesPanel widget paints (140d5495 / 0bd98ae2) and the picker
has variable-mode commit (3c2e7711 / 99d602a3), but clicking a row
did nothing. This commit dispatches.
`crates/openpencil-shell-native/src/widget_host/press.rs`:
- New step 0d in `apply_press` (between PropertyPanel input and
AI chat dispatch): one-line call to the new helper. Keeps the
cascade ordering — properties consume their hits first, vars
next, chat after.
`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
- `dispatch_variables_panel_press(x, y, vw, vh) -> bool` mirrors
the existing `dispatch_export_dialog_press` pattern. Reconstructs
the same right-rail rect math `paint.rs` step 4b uses (top
when no selection, bottom-anchored above status bar when
PropertyPanel owns the rail), hit-tests, then routes:
- `Row(idx)` on a Color-kind variable → commit any pending
property focus, then `open_color_picker_for_variable(name)`.
The picker's HSV-drag path writes through `set_color_hex`
(which carries all three correctness invariants); close
pushes the snapshot+var_table undo entry from 99d602a3.
- `Row(idx)` on a non-color variable → swallow (TODO: row
inputs for string / number). Prevents fall-through to
canvas deselect.
- `AxisChip(_)` → swallow (TODO: theme-axis picker).
- Returns `false` when var_table is empty or the click missed,
so press.rs's cascade continues to chat / canvas.
Tests: 295 shell-core + 20 shell-native still pass. The dispatcher
is exercised by the existing `VariablesPanel::hit_test` tests +
the `color_picker::tests::*` end-to-end variable flow; a wired
host integration test would need a fake-render harness that's out
of scope here.
press.rs grew from 825 → 830 (+5 lines for the cascade call site).
That file was over the 800-line cap before this session — pre-
existing tech debt tracked separately. Not making it materially
worse.
TOP-10 #5 (Variables/Themes UI) edit chain is now functionally
complete: paint → click → picker → write → undo. The remaining UX
gaps are non-color variable inputs + the theme-axis picker, both
flagged as TODOs in the dispatcher.
Codex stop-gate flagged: with no selection, the right-rail
`VariablesPanel` was painted (commit 0bd98ae2 wired the paint
step) but `canvas_region` still returned the full viewport width
because its `has_property` gate only checked `property_panel_
visible()` — which is false without a selection. The canvas
paint pass (Step 5) then extended over the right rail and
overpainted the Variables panel, defeating the visibility fix
the previous commit was supposed to deliver.
`crates/openpencil-shell-core/src/document/mutators.rs`:
- New `Document::right_rail_visible() -> bool` is the unified
gate: true when either `property_panel_visible()` is true or
`var_table.variables` is non-empty. Future right-rail widgets
(Components, Themes header, etc.) extend this method instead
of patching every caller.
`crates/openpencil-shell-native/src/widget_host/geometry.rs`:
- `canvas_region` swapped from `property_panel_visible()` to
`right_rail_visible()`. The shrunk-canvas branch no longer
requires a selection — any rail-occupying widget reserves the
column.
Test (1 added, 275 shell-core total):
- `right_rail_visible_tracks_property_panel_and_variables`
covers the four state combinations:
empty doc → rail hidden
var-table-only (no selection) → rail shown (codex repro)
selection-only (no vars) → rail shown (legacy gate)
cleared selection + cleared vars→ rail hidden again
20 shell-native + 54 desktop + 275 shell-core tests pass.
Codex stop-gate flagged that the previous commit (140d5495) shipped
`VariablesPanel` as a widget definition + tests but never wired it
into the host's paint composition, so users couldn't actually see
it. This commit closes that gap.
`crates/openpencil-shell-native/src/widget_host/paint.rs`:
- Step 4 (PropertyPanel) now also computes the right-rail x +
width as locals so the new VariablesPanel paint step reuses
them instead of duplicating the geometry math.
- New Step 4b: when `Document.var_table.variables` is non-empty,
paint a `VariablesPanel::for_document(...)` rectangle anchored
to the right rail. Layout decision:
- No selection: panel pinned at the top of the rail
(TOP_BAR_HEIGHT + 8 px) so it's the primary chrome there.
- Active selection: PropertyPanel owns the rail; Variables
anchors to the bottom (above the status bar). Approximate
because PropertyPanel paints to fill the rail today;
proper stacking lands when the rail grows scrollable
regions or tabs.
Variables stays hidden when `var_table` is empty — no visual
noise for documents that don't use them.
- `VariablesPanel` imported via `widgets::variables_panel::
VariablesPanel` (the module is `pub mod` exported in
widgets/mod.rs).
`crates/openpencil-shell-native/src/widget_host/input.rs`:
- Cleaned up the unused imports left over from the input/keyboard
split (5caa2eb3): `PropertyFocus`, `AIChatHit`, `AIChatPlaceholder`,
`LayerPanel`, `LayoutCx`, `Toolbar`, `Widget`, `TOOLBAR_WIDTH`,
`TOP_BAR_HEIGHT` were all moved to keyboard.rs but still
listed in input.rs's import list. Down to the actually-used
`ChatAnchor` + helpers + Point2D + Rect.
Tests: 274 shell-core + 54 desktop + 20 shell-native all pass.
Visual verification: opening a `.op` file whose `variables` array
is non-empty now shows the panel chrome (header "Variables" label,
active-theme chips, one row per variable with name + resolved
color swatch / scalar label). Edit interactions still pending —
`VariablesPanelHit::Row(idx)` is wired up but the host doesn't
dispatch it yet.
`widget_host/input.rs` was 882 lines, over the project's documented
800-line ceiling. Split the `impl WidgetHostNative` block into two
files along a natural boundary:
input.rs (417 lines — pointer / wheel / pan / cursor-move /
release + drag-commit helpers)
keyboard.rs (481 lines — text / backspace / delete / duplicate /
nudge / send / escape / click routing)
Both `impl WidgetHostNative` blocks share the same type, and Rust
allows multiple impl blocks for one type in the same crate so the
public method surface is preserved verbatim. No behavior changes —
every method moved verbatim with its full body + doc-comment.
Module wiring:
crates/openpencil-shell-native/src/widget_host.rs:
`mod keyboard;` registered between `input_tests` and `paint`
in the alphabetical mod list.
Imports adjusted: keyboard.rs picks up only the symbols its
methods reference (PropertyFocus, AIChatHit / AIChatPlaceholder /
LayerPanel / LayoutCx / Toolbar / Widget + TOOLBAR_WIDTH +
TOP_BAR_HEIGHT, Point2D + Rect, TOOLBAR_INSET_X/Y from helpers).
input.rs drops the now-unused imports of those symbols.
Tests: 54 desktop + 250+ shell-core + 20 shell-native all pass.
File-cap check:
input.rs 417 (was 882, well under 800)
keyboard.rs 481 (new, well under 800)
main.rs still pending — Task #55 follow-up (804 lines today, 4 over).
BLOCK #1 — anchor drag couldn't return to start. `apply_cursor_move`
only called `set_path_anchor_position` when the cursor doc-point
differed from `start_doc`, so dragging away and then BACK onto the
original point silently skipped the final write — release committed
history with the anchor stuck at the last off-start frame.
Fix: always write the cursor position during an active drag; use
the start-doc comparison only to flip `moved` (which gates history
push). Regression test `anchor_drag_back_to_start_lands_at_start`
simulates the round-trip and asserts the anchor follows the cursor
all the way home.
BLOCK #2 — MCP tool registry dropped the request id. `McpTool::call`
only received `&BTreeMap<String, String>`, forcing tools to invent
response ids (test double used `RequestId::Num(0)`). JSON-RPC + MCP
require every response to echo the originating request id. Fix:
change the trait signature to `call(&self, request: &ToolCall) ->
ToolResponse` and have `dispatch` forward the whole call. EchoTool
updated to read `request.id`; the registry test now asserts the
id round-trips.
BLOCK #3 — opening a native saved file leaked variables across
documents. `apply_payload` reset pages + history + selection but
never touched `doc.var_table` or `doc.components` (both added in
recent commits). Open a variable-bearing canonical `.op`, then
open a plain saved `.pen` — codegen would still emit the stale
canonical variables. Fix: `apply_payload` now reassigns both to
`Default::default()` after the page/UI reset block.
Tests: 206 shell-core + 20 shell-native (+1 anchor return) + 8 desktop.
Wasm32 build clean.
Compact three multi-line comment blocks in `widget_host/input.rs`
that were narrating implementation details Codex already covers
inline elsewhere:
- path-anchor `moved` flag explanation: 4 lines → 1
- align-toolbar hover sync rationale: 4 lines → 1
- settings-input keyboard ownership: 3 lines → 1
Net 8 lines saved; file is still 78 over the 800-line cap because
the remaining bulk is real code (apply_text dispatch, apply_release
drag-clearing chain, apply_cursor_move's 6-branch drag detection).
A proper sibling-module split — moving keyboard handlers to
`widget_host/keyboard.rs` and clipboard ops to `widget_host/clipboard.rs`
— is task #49 and stays a clean focused refactor for the next pass.
Tests: 19 native still pass. No behavior change.
Codex stop-gate BLOCK #1: `boolean_ops::apply_boolean_op` looked up
source paths recursively (via `active_page().find()`) but only
removed them from the top-level `page.children` list. When the
sources lived inside a Group or Frame, the originals stayed in
their parent's children while the result was appended at the
canvas root — duplication + orphans.
Fix: replace the top-level `retain` call with a recursive
`remove_nodes_recursively` walker that drops any node whose id is
in the source set from every children Vec depth-first. New
regression test `boolean_op_removes_nested_paths_not_just_top_level`
wraps two paths in a Group, runs Union, and asserts:
- the Group still exists but is empty
- one result Path lives at top level (total page.children = 2)
Codex stop-gate BLOCK #2: `property_panel_sections::export_section_rect`
was added in commit `5bde95b9` (PropertyPanel preview pills) but
never wired into `hit_test_action`, leaving the visible Export
section unable to open the new ExportDialog. The helper is dead
code in the meantime. Drop it; replace with a comment marking the
follow-up. Existing UX (File menu → Export image / Cmd+Shift+P)
still opens the dialog. Tracking via task #52.
Codex stop-gate finding #3 (`main.rs` 828 / `input.rs` 886 over
the 800-line cap) is real but stylistic — already tracked as task
#55 (sibling-module split). No functional impact; deferred so this
commit stays scoped to the data-corruption + dead-code fixes.
Tests: 184 shell-core + 19 shell-native (+1 nested boolean ops
regression). Wasm32 build clean.
Adds `apply_boolean_op(op)` on the native widget host so downstream
callers (keyboard shortcuts in main.rs, future toolbar buttons,
menu items) can dispatch a path boolean op with a one-line call.
Wraps `boolean_ops::apply_boolean_op` and threads the host's
`next_node_id` allocator through so the result Path mints a fresh
id that can't collide with existing nodes.
Tests: 18 native (no new assertions in this commit; the existing
4 boolean_ops tests cover the underlying mutator). Wasm32 build
unaffected — the method is desktop-only.
#2 on the TS-parity roadmap lands. Skia's built-in `Path::op`
backed by `SkPathOps` does the heavy lifting; the mutator lives in
`shell-native` (not shell-core) so the web bundle stays skia-free.
API:
- `openpencil_shell_core::document::BooleanOp` — Union / Subtract /
Intersect / Exclude (mirrors TS Paper.js' four ops).
- `openpencil_shell_native::boolean_ops::apply_boolean_op(
doc, op, next_id) -> bool`
Filters the selection to Path nodes (Rect/Frame/etc are ignored
so a mixed selection still composes paths). Requires ≥ 2 Path
sources; otherwise no-op + no history. Builds skia paths via
PathBuilder.{move_to, line_to, close}, folds via Path::op,
extracts result points from the PathIterRec stream (Move/Line
take pts[0]; Quad/Conic take pts[1]; Cubic takes pts[2]; Close
is dropped). Builds the result Path node inheriting the first
source's fill + stroke, recomputes its bounds, replaces the
source paths in the active page, and pushes one history entry.
Tests (4):
- union_of_two_overlapping_squares_collapses_to_one_path —
proves the source pair is removed + one new Path appears + the
bounds are non-empty + history grew by one.
- intersect_keeps_overlap_region — verifies the 10×10 overlap
bounds of two 20×20 squares offset by (10, 10).
- boolean_op_requires_two_path_nodes — single-Path selection
no-ops without touching history.
- boolean_op_skips_non_path_nodes_in_selection — mixed Path +
Rect selection still composes the two Paths; Rect survives.
Keyboard-shortcut + toolbar wiring lands in a follow-up so this
commit stays focused on the mutator. Curves in the result degrade
to their endpoint (TS Paper.js has the same v1 behavior; full
anchor-with-handles model arrives with #3 follow-up).
Tests total: 180 shell-core + 18 shell-native (+4) + 8 desktop
export. Wasm32 build clean.
Codex CONCERN #1: a press-release on an anchor handle with no
cursor motion in between still pushed the pre-drag snapshot, adding
a no-op entry to the undo stack that made the next Cmd-Z appear
inert. Fix: PathAnchorDragState gains `start_doc: Point2D` +
`moved: bool`. apply_cursor_move flips `moved` true only when the
cursor's document-space position differs from the start by > 0.001
doc-px. apply_release_with_viewport pushes the snapshot only when
`moved == true`; otherwise drops the state without touching
history. Two regression tests:
- anchor_press_release_without_motion_does_not_push_history
(seeds moved=false; release leaves history unchanged + returns
!consumed)
- anchor_drag_with_motion_pushes_one_history_entry (seeds
moved=true; release pushes exactly one entry + returns consumed)
Codex CONCERN #2: PDF pages were sized to each page's own content
bounds, producing heterogeneous page sizes that caused viewer
zoom/scroll to jump between pages. Fix: export_pdf takes the union
of all page bounds (max width + max height + 16-pt margin) and
emits every page at that uniform size. Each page's content is
positioned inside the frame at its own (origin.x, origin.y) so the
visual layout is unchanged — only the page frame becomes
consistent.
Tests: 180 shell-core + 14 shell-native + 8 desktop/export.
wasm32 build clean.
Wires the `set_path_anchor_position` mutator (groundwork commit
`814d05ca`) into the canvas hit-test + drag dispatch.
Geometry helper `path_anchor_hit(x, y, vw, vh)` in
`widget_host/geometry.rs` returns `Some((node_id, anchor_index))`
when the press lands inside an 8-screen-pixel circle around an
existing anchor of the selected Path node, with the Pen tool
active. Radius scales with viewport zoom so the hit box stays a
constant screen size.
Press dispatch (`widget_host/press.rs::apply_press`) — Pen tool
branch checks `path_anchor_hit` BEFORE the existing
add-anchor / start-path code path. Hit → captures pre-drag history
snapshot, seeds `path_anchor_drag` state. Miss → falls through to
existing author-anchor behaviour.
Cursor move (`widget_host/input.rs::apply_cursor_move`) — slot
between node-drag and marquee-drag: snaps the picked anchor to the
cursor's document-space coords via the mutator.
Release (`widget_host/input.rs::apply_release_with_viewport`) —
slot between node-drag and marquee-drag: pushes the pre-drag
snapshot so the user gets a single Cmd+Z to revert the whole
drag.
State struct `PathAnchorDragState { node_id, anchor_index,
pre_drag_snapshot }` lives in `widget_host.rs` (debug + clone, not
copy because DocumentSnapshot owns its pages Vec).
Tests: all 12 native input tests still pass (existing coverage
exercises the press/move/release path on adjacent drag types so any
regression on those would surface). 180 shell-core tests pass.
Wasm32 build clean.
Follow-ups: visual handles (paint per-anchor dots on selected Path
when Pen tool is active so the user sees what to grab); web parity;
codex review of the chain.
Document::align_selected covers 6 align actions (left / center-h / right
/ top / center-v / bottom) and 2 distribute actions (horizontal /
vertical center-spacing). Reference frame is the union of selection
bounds for 2+ nodes, the parent container for a single selection
(top-level no-ops). Ancestor-in-set dedup mirrors translate_selected so
a frame + child selection only shifts the frame; descendants cascade.
History pushes only when at least one node actually moved.
Floating AlignToolbar widget appears when selection_count >= 2; centered
horizontally above the canvas with a 56-px reserve so it never overlaps
the vertical Toolbar column. Hidden entirely when the canvas can't host
both. Hover state lives on Document.ui.align_toolbar_hover and clears on
every selection-count drop. Hit-test sits before apply_click on both
native + web so visible buttons always win clicks. Hover sync runs AFTER
all drag branches in cursor_move so an active node-drag isn't stolen by
a hover update.
8 lucide d-strings (align-start/center/end-vertical/horizontal +
horizontal/vertical-distribute-center) added to icons_data.rs from
lucide-react@0.545.0. Codex stop-gate reviewed three times to BLOCK-free.
Tests: 25 align (mutator + widget) + 1 native drag-interception
regression. Closes the v0.8.0 align/distribute roadmap item.
Pivot the desktop's Open path to the canonical `jian-ops-schema`
parser and route layout through `jian-core::LayoutEngine` so files
saved by the TS editor, Jian apps, or any tool emitting the
canonical schema load through the shared parser + paragraph shaper.
Loader (pen_doc_adapter.rs + pen_doc_path_bounds.rs)
- All 12 PenNode variants → NodePayload, with each root's authored
(base.x, base.y) added to harvested rects so multi-design files
(e.g. pencil-demo.op's 14 mockups) spread across the canvas.
- Path anchors port `getPathBoundsFromAnchors` — endpoints + Bezier
handles + cubic-derivative extrema — so curved paths scale into
their (width, height) the way the canonical renderer paints.
- jian-skia's `SkiaMeasure` plugged in via
`LayoutEngine::with_backend(...)`, replacing the ~10% character-
count heuristic with real paragraph-shaper metrics. Wrap/layout
now agree with paint instead of cascading 10% errors.
- Numeric-string fontWeight (`"700"`, `"normal"`, ...) parsed in
both jian-core and the desktop adapter; expanded keyword table
covers black/heavy/extralight/extrabold/demibold/hairline/etc.
- Version-tolerant `load_canonical` retries with `version` rewritten
to `"1.0"` so legacy `version: "2.8"` files still load.
Text + icon rendering
- `Node.text_wrap` gated on `textGrowth: fixed-width` — single-line
by default so font-fallback overshoot doesn't break lines the TS
app shows on one line.
- CJK-aware `wrap_text` (canvas_viewport_overlay.rs) — per-char CJK
breaks, word breaks for Latin, blank-line preservation, explicit
`\n` splits. Takes a weight param.
- `RenderBackend::measure_text_weighted` added with NativeBackend +
WebBackend overrides so wrap measurement matches weighted paint.
- icons.rs + new icons_data.rs sibling cover ~75 lucide variants
for first-party `iconFontName` names from pen-core element-builders
(trending-up/down, compass, refresh-cw, layout-dashboard, users,
package, zap, sliders-horizontal, activity, loader, focus,
chart-line, settings-2, arrow-right, check-circle, alert-triangle,
alert-octagon, sticky-note, bar-chart-2, bold/italic/underline,
shopping-cart/bag, send, message-circle, rocket, menu, credit-card,
x-circle, mail, smartphone, chrome, apple, user, ...). Unknown
names stroke a dot fallback (FALLBACK_ICON_D) instead of a block.
All d-strings copied from lucide-react@0.545.0.
- `Icon::from_name(&str)` resolves kebab-case + common aliases.
- Synthetic bold via PaintStyle::StrokeAndFill for weights ≥600 on
both native and web (single-weight bundles can't serve a real
bold variant).
Chrome polish
- Hover state on file menu / locale picker / shape picker / layer
panel rows / AgentSettings nav + provider cards. Host's
apply_cursor_move updates each per its open state.
- File menu compacted (row 30, header 22, no `…` suffix on actions),
recent file names truncate with a CJK-aware helper.
- `rfd::MessageDialog` on every failed Open / OpenRecent / Save /
SaveAs / ExportImage with bilingual (EN/ZH) title + path + detail.
OpenRecent failures prune the stale entry.
- `figma_import.rs` modal honest-stub (Coming soon copy, brand glyph),
TopBar Folder+Chevron compound + Figma button.
- Settings sidebar nav + provider cards tinted on hover.
- Recent-files panel polished to single-line names with age column.
Tests
- pen_doc_adapter_tests.rs (sibling via #[path]) — 19 cases covering
multi-root canvas offsets, shape size fallbacks, path anchor
absolutize + Bezier extrema, fixed-width wrap, numeric-string
weights, login.op + pencil-demo.op fixture loads.
- canvas_viewport_overlay.rs wrap_tests — 7 cases: ASCII / CJK /
CJK+Latin / explicit-newline / blank-line / weighted advances.
- icons.rs first_party_icon_font_names_all_resolve guards 27+
authored names against placeholder regressions.
File-cap discipline
- pen_doc_adapter.rs split into mod + path-bounds sibling + tests
sibling.
- icons.rs split into mod + icons_data.rs sibling so the catalogue
can grow without busting the cap.
- canvas_viewport_overlay.rs absorbs wrap_text + UniformBackend /
WeightedBackend test stubs.
Sub-modules
- vendor/jian advanced for `resolve_weight` numeric-string parsing.
Closes the largest TS parity gap (#1 / #2 in the audit): the
document was completely non-persistent — every restart lost the
canvas tree. Wire up native Save / Save As / Open through rfd
dialogs so the user picks the file path themselves (per the
audit conversation: "随意保存").
- new `crates/openpencil-desktop/src/persistence.rs`:
- `DocPayload` / `PagePayload` / `NodePayload` / `StrokePayload`
DTOs with serde derives (hand-rolled JSON shape so shell-core
stays serde-free; Color / Rect / Point2D come from external
crates that don't carry serde derives)
- `to_payload` / `apply_payload` + `kind_to_string` /
`str_to_kind` cover all 9 NodeKind variants including
NodeKind::Other(String)
- `save_as_dialog` / `open_dialog` use `rfd::FileDialog` with
a single combined "OpenPencil" filter covering both `.pen`
and `.op` extensions — both load via Open and save into
either at the user's choice
- `save_to_path` writes through a sibling `.tmp` + rename so a
mid-write crash never leaves a half-written document on disk
- `handle_save` / `handle_save_as` / `handle_open` package the
rfd + state flow + title refresh so the desktop key handler
stays a one-liner per shortcut
- new `WidgetHostNative::document()` / `document_mut()` accessors
— `pub(in crate::widget_host)` field stays internal otherwise
- `DesktopApp` gains `current_path: Option<PathBuf>`; window title
updates to `<filename> — OpenPencil` after Save / Open
- keyboard bindings: Cmd+S (save-in-place or fall through to Save
As when no path), Cmd+Shift+S (force Save As), Cmd+O (open
dialog). All three remain enabled when the settings modal is
focused; bypass the modal-focused editor-shortcut block because
they never type into the port field
- format spec: `{ version: 1, active_page_index: N, pages: [...] }`
— bump `CURRENT_VERSION` + add a migration branch in
`apply_payload` when the schema grows
apply_text already swallows non-digits while a settings input is
focused, but the desktop key handler dispatches editor shortcuts
(Cmd+D, Cmd+G, Cmd+Z, Cmd+A, arrow nudges, Delete, [ / ]) on
SEPARATE paths that bypass apply_text — so typing a `d` while
editing the port still duplicated the selected node, arrow keys
nudged it, etc.
- new `WidgetHostNative::settings_focus_active()` public helper
- `input_active()` already-private check also picks up the modal
focus so single-letter tool switches gate cleanly
- desktop key handler reads `settings_focus_active()` once per
event and stamps `&& !settings_focused` on every editor branch
(Delete, arrows, Cmd-letter combos, Cmd-Shift-letter combos,
bracket reorder). Cmd+, stays unguarded so the user can always
toggle the modal itself.
While the MCP port field was focused, only digits routed into the
draft and any other character fell through to the next handler —
so typing a letter would land in chat / rename / text-edit
(whichever happened to be active). Tighten both native + web
`apply_text`: while `agent_settings.focus.is_some()` swallow ALL
keys, accepting digits into the draft and returning false for
everything else.
The port field on the MCP tab was display-only — clicking did
nothing and the user couldn't pick a different port. Wire it to a
proper focus / draft / commit cycle:
- new `SettingsFocus` enum (currently just `McpPort`; OAuth client
id/secret will follow) + `AgentSettings.focus: Option<SettingsFocus>`
- `UiState.settings_input_draft: String` holds the in-progress text
(lives on UiState because `AgentSettings` is `Copy`)
- McpHit::FocusPort hit on the port-field rect, dispatched from
the native press handler — seeds draft from current port value
- keyboard routing: digits-only via `apply_text` (cap 5 chars),
`apply_backspace` pops, `apply_send` commits, `apply_escape`
cancels (one Escape clears focus + draft, second closes modal)
- commit parses u16 and clamps to ≥1024 so the user can't pick a
root-only port
- modal Close / Outside / tab-switch press all commit any pending
draft before transitioning so a typed value isn't silently lost
- focused field gets a primary-tinted border + static caret bar
past the digits
restores the 3 reset commits (drag-into-container, layer right-click,
page menu) plus this session's new editor features + performance pass.
layer panel
- cross-parent drag-into-container reparenting with floating ghost
- right-click context menu on layer rows (rename / duplicate / delete /
group / ungroup / lock / hide)
- right-click context menu on page tabs (rename / duplicate / delete)
- split layer_panel.rs into spine + walkers + paint + tests under
the 800-line cap
editor features
- pen tool: NodeKind::Path multi-anchor polylines with rubber-band
preview tracking cursor doc-coords
- HSV color picker overlay (Cmd-Shift-C or fill/stroke swatch click):
sat/value box + hue strip + hex input, anchored HSV across
RGB-rounding cycles
- Property panel: Design / Code tab toggle (Cmd-Shift-C) — new
property_panel_code module
- corner radius: Node.corner_radius field + PropertyFocus::PositionR
wired through snapshot/commit/seed; canvas Rect paint switches to
fill_round_rect/stroke_round_rect when radius > 0.5 doc-px
brand icons
- new RenderBackend::fill_svg_path on both native (Canvas::draw_path
Fill paint) and web backends
- widgets/brand_icons.rs with Claude / OpenAI / Gemini / Copilot
filled-path glyphs (verbatim from apps/web/src/components/icons/*-
logo.tsx) + paint_opencode_logo terminal-frame primitive
- agent provider cards swap Lucide approximations for real brand
logos
settings modal (Cmd+,)
- 880×640 modal with sidebar nav (Agents / MCP / Images / System)
+ scrollable right pane + dim scrim
- Agents tab: 5 provider cards with brand logos, hover-to-reveal
"断开连接" on connected cards, "+ 添加服务商 / + 添加 Agent" actions
aligned + inset to clear close X
- MCP tab: server status card (running/stopped indicator + port +
start/stop button) + 2×3 toggle grid for terminal CLI integrations
- Images tab: Image Search status + collapsible Advanced (Openverse
OAuth client id/secret + Register link + Test) + Image Generation
section with empty-state hint
- System tab: read-only Auto-update status card (no real updater
backend yet — surfacing a togglable switch would lie to the user)
- new widgets/agent_settings_i18n.rs hand-maintained EN/ZH key
table (~50 keys); generated i18n/{en,zh_cn,...}.rs untouched
- AgentProvider.subtitle_key() drops hard-coded Chinese subtitles
+ drops the fabricated "fini.yang@gmail.com" account line
- web shell paints + dispatches the modal alongside native
- cursor over modal stays on Default pointer (no Move on sidebar
nav rows)
performance
- history VecDeque (O(1) pop_front, capped at 100) replaces O(n)
Vec::remove(0)
- redraw scheduler: track dirty flag + skip paint when cursor-move
produces no visible state change (kills first-click chip flicker)
- cursor-move coalescing: drain pending_cursor_move on
RedrawRequested + press/release/right-press
- viewport culling: off-screen leaf nodes skip paint with 64px margin
for stroke/handle overflow
- font cache prewarm: NativeBackend::new walks ~50 chrome CJK
codepoints through FontMgr::match_family_style_character at startup
→ first cross-tab paint stops stuttering
- i18n returns &'static str (PropertyLabels Copy struct, ~19
String allocs/frame removed)
- TopBar chip 12px/char + 16px-pad CJK-safe hit area
polish
- close X smaller (16×16) + section-action text right-inset to clear it
- Advanced chevron → lucide ChevronDown/Right (was Unicode v/>)
- Register link arrow → lucide ArrowUpRight (was Unicode ↗)
- INPUT_RADIUS 6→8 for more visible rounded inputs
- dot-status alignment on Image Search header (centred to status
text optical centre, not title baseline)
testing
- 167 tests passing across shell-core (136) + shell-native (21) +
document (6) + walkers (4)
- 800-line cap holds on every file
- native + web targets both build clean
Codex caught a real bug: the drop indicator painted at one row,
but on release the source could land at a different row. When
dragging downward past other rows, the indicator was at row N
but the source ended up at row N-1, because `commit_layer_drag`
calls `extract_node` first (shifting other rows up by one) and
THEN inserts at the anchor — but `paint`'s drop_target_at was
computing the indicator y in the PRE-extract layout.
Fix: while a drag is active, build the LayerPanel with the
dragged source's entire subtree excluded. This mirrors the
post-commit layout, so the indicator y the user sees and the
y the source lands at are by construction the same.
- New `LayerPanel::from_document_with_drag_source(doc, source)`
constructor; uses a new `walk_excluding` walker that skips
the source's subtree.
- Native + web paint paths call the new constructor when
`layer_drag.active`, computing drop_target against the
trimmed layout AND painting the trimmed row stack (so the
user sees the visual collapse mid-drag too).
- Native + web `commit_layer_drag` also build the trimmed
panel for `drop_target_at` lookup, so preview and commit
read from the same layout.
- New regression test `drop_indicator_matches_post_commit_
layout_when_dragging_down`: drags A from top, drops before
D, asserts `indicator_y` matches A's new row top in the
rebuilt panel exactly.
136 tests pass (was 135); cargo fmt + boundary check clean;
zero files over 800.
Final codex re-review CONCERN: `commit_layer_drag` was relying on
`Document::reorder_before/after`'s own source-existence check to
no-op on a deleted source. Per codex: the commit path should bail
explicitly for symmetry with the existing cursor_move + paint
guards. Added the same `active_page().find(source).is_none()`
check on both native (`widget_host/input.rs`) and web
(`widget_host.rs`) at the top of `commit_layer_drag`, right after
the `!d.active` early-out.
No behavior change in the safe path (reorder still happens), but
short-circuits the drop_target_at + dispatch when the source is
gone.
Three codex broad-review findings:
BLOCK — `openpencil-shell-web/src/widget_host.rs` was 886 lines
(over the 800 cap). Extracted `apply_press` + `apply_click`
(~301 lines combined) into a new sibling
`crates/openpencil-shell-web/src/widget_host/press.rs`,
mirroring the native split pattern. Spine drops to 590 lines.
Explicit `use super::{ChatDragState, DragState, LayerDragState,
MarqueeDragState, rect_contains, WidgetHost}` so the type
references inside the moved methods stay readable (Rust resolves
them via descendant module privacy, but the imports document
the dependency).
CONCERN — Stale `layer_drag` could outlive its dragged node if
the document mutated mid-drag (delete / cut / page switch).
Added a source-validity guard at three sites per host (native
+ web parity):
- `apply_cursor_move`: clears `layer_drag` if
`active_page().find(d.source)` returns None.
- Both paint passes: suppress the drop-indicator when the
source is no longer in the active page.
- `commit_layer_drag` already silently no-ops on missing
source via the existing `reorder_before/after` guards.
CONCERN — Missing host-level end-to-end test for the drag-to-
reorder gesture. Added two tests in
`openpencil-shell-native/src/widget_host/input_tests.rs`:
- `layer_drag_to_reorder_commits_on_release_with_threshold_move`
— full press → 4-px-threshold move → release; asserts the
tree was reordered and `layer_drag` is cleared.
- `layer_drag_below_activation_threshold_is_a_click_not_a_reorder`
— sub-threshold move; asserts click semantics (selection
set, tree unchanged).
Verification:
- cargo test --workspace: all green (184+ tests).
- cargo fmt --all --check: clean.
- bash tools/check-widget-boundary.sh: PASS.
- find / awk file-size scan: zero files over 800 lines.
Two codex stop-gate CONCERNs from the drag-to-reorder commit:
- `LayerPanel::drop_target_at` now returns `After(last_layer)` for
cursor positions in the empty area below all rows (still inside
the panel rect). Was returning None, creating a dead zone where
the user couldn't drop a row at the very end of the list. New
test `drop_target_at_in_empty_area_below_rows_drops_at_end`.
- Activation threshold for layer-drag is intentionally vertical-
only (4 px). Added an explanatory comment on both native and
web hosts so the asymmetry vs the marquee's 2D threshold is
legible at the call site (the row-stack reorder axis is y;
horizontal wiggle would steal click-feel from selection +
eye/lock/chevron toggles on the same row).
Doc drift on `drop_target_at` reverted likewise — the comment now
documents all four return cases (over-row Before / After, below-
rows After-last, outside / above-rows None).
130 shell-core tests pass; cargo fmt + boundary check clean.
Drag a layer row in the LayerPanel; a 2 px primary-tint drop
indicator paints between rows; release commits the move. Supports
both same-parent reordering and cross-parent reparenting.
Core (`document/`):
- `Document::reorder_before(source, anchor)` / `reorder_after`
with editability + cycle + existence pre-checks. The mutation
phase calls `extract_node` + `insert_before/after_in_children`
via shared walkers — guarded by the pre-check pass so the
Err(node) arm of the insert helpers stays unreachable.
- New walkers: `extract_node`, `insert_before/after_in_children`
(`Result<(), Node>` so the node bounces back on miss),
`children_contain_descendant`. All `pub(in crate::document)`
so sibling submodules (`mutators`) can call them via
`super::walkers::*`.
Widget (`widgets/layer_panel.rs`):
- `DropPosition::{Before, After}` + `DropTarget { anchor,
position, indicator_y }` re-exported from `widgets::`.
- `LayerPanel::drop_target_at(rect, point) -> Option<DropTarget>`
walks the layer rows; upper half → Before, lower half → After.
- `LayerPanel.drop_target` field + `from_document_with_drop`
ctor so the host can thread the active drop target through
to paint without touching the existing API. Drop-indicator
paints last so it sits above row chrome.
- Layout constants promoted to `pub(crate)` and tests moved to
a sibling `widgets/layer_panel_tests.rs` to honor the 800-line
cap (mirrors the `document/` split pattern).
Host wiring (native + web parity):
- `LayerDragState { source, start_y, current_x/y, active }` on
both `WidgetHostNative` and web `WidgetHost`.
- `apply_press` peeks for a Layer row hit and seeds the drag
candidate (selection still happens immediately on press).
- `apply_cursor_move` promotes candidate → active once the
cursor moves past a 4 px screen-space threshold (mirrors the
marquee activation heuristic).
- `apply_release_with_viewport` calls `commit_layer_drag`,
which runs `drop_target_at` against the live cursor pos and
dispatches to `reorder_before` / `reorder_after`. Viewport-
less `apply_release` drops the candidate silently (no rect
to compute against).
- Paint paths build the panel via `from_document_with_drop`
when `layer_drag.active` is true.
Tests (8 new, 137 total in shell-core):
- 6 mutator tests in `document/tests_mutators.rs`:
same-parent before/after move, cross-parent reparenting,
cycle rejection, locked/hidden source rejection,
same-id/missing-id rejection.
- 1 drop-target hit-test in `widgets/layer_panel_tests.rs`
covering both halves of a row (Before / After) and the
`indicator_y` contract.
All checks clean: cargo fmt + cargo test + cargo check
--workspace + tools/check-widget-boundary.sh. Every file
under the 800-line ceiling.
The canvas was pan-only; nodes could only be selected from the
LayerPanel and never moved without editing X/Y in the property
panel. Now:
* Document::node_at_doc_point walks the active page top-most-first
and returns the topmost node whose aggregate bounds contain the
document-space point. Children are tested before parents so a
click on a button-rect inside a Frame selects the rect, not the
Frame.
* Document::translate_selected moves the selected node by (dx, dy)
document px. Leaf nodes update bounds.origin directly; container
nodes (Group / unbounded Frame) translate every descendant that
carries bounds, so dragging a Group moves the whole subtree.
* WidgetHostNative tracks a NodeDragState. Press over a node ⇒
select + start node-drag. Cursor-move converts the screen-space
delta to document space via the live zoom (no canvas_region
offset needed because deltas are translation-invariant) and
calls translate_selected. Release clears the drag.
* The Hand tool keeps its pure-pan behaviour. Empty-canvas press
with any other tool clears the selection + starts a pan-drag,
same as before.
- LocalePicker / ShapePicker no longer paint a soft black offset rect
underneath; popover background + border hairline are enough to
read as floating, and the shadow was bleeding into the canvas.
- ShapePicker anchors 8 px to the right of the toolbar PANEL edge
(not just the slot button), so the dropdown reads as a separate
surface instead of butting flush against the toolbar's right border.
The vertical toolbar's shape button is now a compound slot driven by
`Document.ui.shape_tool` (defaults to Rect). Click it to open a
`ShapePicker` dropdown anchored immediately to the right of the
slot — seven rows mirror the TS app's shape-tool-dropdown:
· Rectangle (Square icon)
· Ellipse (Circle)
· Polygon (Triangle)
· Line (Minus)
· Icon (Sparkles, opens icon picker — host follow-up)
· Import Image or SVG… (ImagePlus, opens file dialog — host follow-up)
· Pen (PenTool)
Picking a shape updates ui.shape_tool (so the toolbar slot's icon
flips), sets doc.tool to that variant, and closes the panel. Click
anywhere else closes silently.
* New Tool variants: Ellipse / Polygon / Line / Pen. Tool::is_shape()
reports membership in the slot's group so the slot highlights when
any of them is active.
* New icons: Circle, Triangle, PenTool, ImagePlus (lucide d-strings).
* New widget shape_picker.rs (≤ 280 lines) with hit-test + Widget
impl + 3 unit tests; ShapeChoice variant for the host to dispatch
on (Tool / OpenIconPicker / ImportImageOrSvg).
* PropertyLabels-style locale lookup falls back to English literals
for the row labels (shapes.rectangle / ellipse / polygon / line /
icon / importImageSvg / pen) — already present in zh.ts.
* Native host wires the open/close/dispatch loop alongside the
existing locale picker; paint slot z-priority sits below the
locale picker so a stack of overlays still does the right thing.
The right-rail inspector picks up locale-aware labels and accepts
keyboard edits on the four most-used number inputs.
* New `PropertyLabels` struct in property_panel_sections; resolved
once per panel build via `Document::t`. All hardcoded chinese
section titles (位置/弹性布局/尺寸/图层/填充/描边/效果/导出),
the 设计/代码 tab strip, the 创建组件 button label, and the five
尺寸 checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) now flip with
the TopBar Globe locale picker. Falls back to English when the TS
locale tables don't carry a key.
* PropertyPanel now carries `focus / draft / caret_anchor_ms /
now_ms` so the focused input renders the live edit buffer with a
primary-color border + blinking caret. `for_selection_at(doc,
now_ms)` is the new entry point; `for_selection` keeps a
zero-clock variant for static contexts (tests, etc.).
* New `editable_input_rects` in sections — single source of truth
for the X / Y / W / H rect layout, shared by paint and
`PropertyPanel::hit_test`.
* WidgetHostNative wires the full edit cycle: clicking a row
focuses + seeds the draft from the snapshot, `apply_text`
filters digits/decimal/leading-minus into the draft, `apply_send`
parses + commits via `Document::commit_property_edit`, and
`apply_escape` discards. Click-outside-the-panel auto-commits.
`next_animation_deadline_ms` now wakes for property focus too so
the caret blinks at the same 500 ms cadence as the chat input.
* `PropertyFocus` already existed; `Document::commit_property_edit`
+ helper walk now mutate Node.bounds for the X/Y/W/H cases.
Rotation/opacity/hex inputs accept focus + clear cleanly but are
no-ops at the node level until the schema grows those fields.
The native runner outgrew the `examples/` slot — it owns DPI tracking,
caret-blink animation timer, panel-resize cursor, the full Cmd+wheel /
PinchGesture / Pixel/LineDelta dispatch table, etc. None of that is a
sample, so it's been promoted to a real crate.
* New crate `crates/openpencil-desktop/` with a single `[[bin]]`
target. Depends on `openpencil-shell-native` (lib) + winit +
skia-safe (gl), gated to macOS / Linux / Windows.
* `examples/inspector_window.rs` removed; equivalent code lives at
`crates/openpencil-desktop/src/main.rs` with the structs renamed
(DesktopApp / paint) and the doc-block rewritten as a runner spec.
* Run command: `cargo run -p openpencil-desktop --release`. Old
command (`--example inspector_window`) is gone.
* Workspace glob `crates/*` already picks up the new crate, no
Cargo.toml workspace edit needed.
* Docs: crates/CLAUDE.md updated with the new crate row and runner
section retitled "Desktop binary". Top-bar layout test renamed +
uses the TOP_BAR_HEIGHT constant so future height tweaks stop
breaking it.
* Native fill_round_rect now sets anti_alias(true) — was the source of the
stair-stepped tool-button corners. Mirrors the AA flag we already had on
stroke_round_rect / stroke_line / stroke_svg_path.
* LayerPanel paints a right-edge hairline (so the rail reads as a distinct
surface from the canvas) plus an inset hairline between the Pages and
Layers sections (matches the TS LayerPanel border-t).
* Layer + Property panel widths are now first-class Document.ui state
(`layer_panel_width` / `property_panel_width`, defaults 240/280).
Native host detects ±4 px gutter clicks on the panel edges, drags the
width inside [180, 480], and the inspector_window runner flips the
cursor to EwResize while hovering or actively resizing.
* Web host expressions threaded onto the same UiState fields for parity;
drag wiring on web is a follow-up.
* TopBar trimmed: 48 → 40 px height, 32 → 28 icon button, 18 → 16 icon —
the chrome reads less heavy at default zoom.
* Drops the now-unused PropertyPanel `Copy` derive (UiState carries a
String draft) and lowers the toolbar (44×32) and topbar (40 px) so the
rails feel tighter overall.
TopBar Globe button is now a wider compound (44 px) carrying both
the globe glyph AND a small chevron-down — visually signals the
dropdown affordance the way the TS i18n switcher does.
Click-while-open behaviour fixed: any click outside the dropdown
(including a second click on the Globe itself) closes the picker
and swallows the press, instead of close→re-toggle-open which left
the picker stuck open.
Native font path now resolves a typeface PER CODEPOINT and renders
each contiguous-typeface segment with its own `Font`. Korean
한국어 / Devanagari हिन्दी / Thai ไทย / Vietnamese precomposed
`Tiếng Việt` now render against the right system font instead
of dropping through the Han-only fallback. Per-codepoint cache
keyed on `char as i32` keeps repeat lookups free.
Adds a LocalePicker widget that paints a vertical list of all 15
native-script locale names (English / 简体中文 / 繁體中文 / 日本語 /
한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी
/ Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia) with a Check icon
and primary tint on the active row.
Globe click toggles `Document.ui.locale_picker_open` instead of
silently cycling. Row click sets the locale + closes; clicking
outside the panel closes silently. Picker paints on top of every
other layer (chat / status / canvas) so it never gets covered.
Native + web hosts share the implementation via
shell-core::widgets::LocalePicker; `TopBar::globe_rect` exposes
the icon-button anchor so the panel stays glued under the icon
even after a viewport resize.
Stop-hook fix: codex flagged Rust files as not rustfmt-clean.
Run cargo fmt --all across openpencil-shell-{core,native,web}
+ wasm-libc-shim. 67 lib tests still pass, native + web cargo
check clean.
Stop-hook fix: 'caret reset can use a stale clock'. set_now_ms was
only called inside RedrawRequested, so apply_text / apply_backspace /
apply_press routed mid-frame stamped caret_anchor_ms with the
previous frame's now_ms. The result: caret reset visually appeared
delayed by up to one redraw interval (rare but inconsistent).
Refresh self.clock_start.elapsed() at the top of every WindowEvent
so any apply_* called inside the match arm sees the current
timestamp. Drop the redundant inside-RedrawRequested refresh.
Sinks the blink phase logic into vendor/jian (jian-core::anim) so any
host can wire the same square-wave timing instead of reimplementing
per-product. Both OpenPencil chrome and Zode TUI consume the same
helpers.
- vendor/jian bumped to head with new `jian_core::anim` module
(blink_visible / next_blink_flip_ms, 9 unit tests)
- ChatState: `caret_anchor_ms` resets on focus / keystroke /
example fill so the caret reappears solid right after the user
acts, not mid-fade
- AIChatPlaceholder.now_ms threaded from host; paint computes
caret visibility = focused && jian_core::anim::blink_visible
- AIChatPlaceholder caret X uses RenderBackend::measure_text for
pixel-accurate trailing edge (replaces the 7px / 13px guess
per char that drifted on Roboto + Noto-CJK)
- WidgetHostNative.set_now_ms / chat_focused / next_animation_
deadline_ms surface; runner refreshes from a single Instant
anchor + sets ControlFlow::WaitUntil at the next blink flip
- inspector_window: new_events handles ResumeTimeReached → request
redraw so winit actually wakes for the next frame
TS LayerPanel renders the selected row with bg-blue-500/15 + primary
text color + primary icon color (apps/web/src/components/panels/
layer-item.tsx). My panel was using theme.row_selected (gray #262626)
+ foreground text, which read as 'darker gray on dark gray' — not
the clear 'this is selected' affordance the TS app gives.
- Add Theme.row_selected_primary (rgba(0x3B82F6, 0.18) — blue 15%)
- LayerPanel: selected layer row uses row_selected_primary bg,
primary text + primary icon
- Page rows still use the neutral row_selected (matches TS where
the active page tab is also subdued gray)
Stop-hook fix: native over_canvas + apply_wheel + apply_click
LayerPanel hit-test all hardcoded LAYER_PANEL_WIDTH for the canvas
left edge. When the sidebar was collapsed, paint moved the canvas
left to x=0 but input still treated x∈[0,240) as 'over the LayerPanel'
— so clicks in that strip resolved to LayerPanel hits (against
nothing), wheel zoom anchored off-screen to the left of the cursor,
and pan-drag refused to start in that strip.
over_canvas now derives both x and y bounds from canvas_region;
apply_wheel uses canvas_region for the cursor offset; apply_click
short-circuits when sidebar is closed (LayerPanel isn't painted)
and lets the empty-canvas branch clear selection + start pan-drag.
Stop-hook fix: toolbar hit-test rects in apply_press / apply_click /
toolbar_rect were hardcoded to LAYER_PANEL_WIDTH + TOOLBAR_INSET_X,
but paint uses canvas_region's dynamic canvas_left (which is 0 when
sidebar is collapsed). When the user collapsed the sidebar, the
toolbar visibly slid left to x=12 but clicks still tried to hit it
at x=252, leaving the toolbar effectively unclickable.
Now both apply_press / apply_click in native + the toolbar_rect helper
in web compute the anchor from canvas_region, so hit-test always
matches paint. Wheel zoom in web also uses canvas_region's cx0/cy0
instead of the hardcoded LAYER_PANEL_WIDTH so cursor-centered zoom
keeps the right document point fixed when the sidebar is closed.
Codex Step 3 R1 BLOCK: the prior fix `10cae1e5` exposed
canvas_height() on WebBackend but only used it for the white-
background clear, NOT for `WidgetHost::paint`. The host's
canvas viewport rect still derived its height from a hardcoded
`640.0` (web) / `600.0` (native), so any window/canvas at a
non-default height got the wrong bottom edge.
Fix: extend both `paint` signatures to accept
`viewport_height: f32` and replace the hardcoded
`640.0 - rail_top_y` / `600.0 - rail_top_y` expressions with
`(viewport_height - rail_top_y).max(0.0)`.
Web side:
- `widget_host.rs::WidgetHost::paint(backend, viewport_width,
viewport_height)` — `// glue:` marker preserved on the
signature line.
- `lib.rs::paint_inspector` reads BOTH `viewport_w` and
`viewport_h` from the backend and forwards them to
`host.paint`.
Native side:
- `widget_host.rs::WidgetHostNative::paint(frame,
viewport_width, viewport_height)` — `// glue:` marker
preserved.
- `examples/inspector_window.rs::paint_inspector(...,
viewport_width, viewport_height)` — both axes plumbed
through.
- `InspectorApp` gains `viewport_height: f32` cached field
refreshed in the `Resized` arm so window-drag responsively
updates the canvas viewport rect.
Stale comment that said "Window height isn't passed through
this signature; assume 600 px" updated to cite the codex
finding.
Verification:
- `cargo build -p openpencil-shell-native --example
inspector_window` — green
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 092
bytes gzip = 86% of 1 MiB ceiling
- `grep "640.0\|600.0" crates/openpencil-shell-web/src/widget_
host.rs crates/openpencil-shell-native/src/widget_host.rs`
— only one match, inside a comment citing the prior bug
Codex Step 3 R1 BLOCK: `MIN_RAIL_WIDTH: f32 = 80.0` was defined
twice — once in `crates/openpencil-shell-web/src/widget_host.rs`
and once in `crates/openpencil-shell-native/src/widget_host.rs`.
Each had a comment claiming "mirrors the other"; nothing
enforced agreement. A future drift on one side would silently
break cross-platform layout parity.
Move to a single canonical `pub const MIN_RAIL_WIDTH: f32 = 80.0`
in `crates/openpencil-shell-core/src/widgets/mod.rs`. Both hosts
import it via the existing `widgets::*` use list.
Verification:
- `cargo build -p openpencil-shell-native --example
inspector_window` — green
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `cargo test -p openpencil-shell-core --lib` — 39 tests passing
- grep confirms one definition + two imports + 4 use sites
Node grows bounds + fill + stroke + text fields; new
`widgets::CanvasViewport` recursively renders document nodes as
visual primitives; both hosts (web + native) now lay out
Toolbar-top + LayerPanel-left + CanvasViewport-center +
PropertyPanel-right. The `inspector_window` example launches a
1100×700 window showing a real document mock instead of just an
inspector slice. Direct run command:
cargo run -p openpencil-shell-native --example inspector_window
What's added:
shell-core:
- `Rect::ZERO` const + `Rect::xywh(x,y,w,h)` builder — used
pervasively by Step 3 fixtures.
- `Color` derives `PartialEq` so `Option<Color>` field comparisons
work in tests.
- `document::Stroke { color, width }` for outlines.
- `document::Node` gains: `bounds: Rect` (origin + size), `fill:
Option<Color>`, `stroke: Option<Stroke>`, `text: Option<String>`.
Existing `Node::leaf` / `Node::with_children` keep working with
defaults (Rect::ZERO, all None). Builder mutators
`with_bounds` / `with_fill` / `with_stroke(color, width)` /
`with_text(s)` chain off them.
- `Document::sample()` now configures concrete geometry for the
demo: a 360×240 white-with-black-stroke Frame containing a
"Hello OpenPencil" Title and a blue Button (rect + "Click me"
text).
shell-core/widgets/canvas_viewport.rs (new, 5 unit tests):
- `CanvasViewport<'a>` borrows a `&Document` and impls `Widget`.
- `paint()` clears canvas to light-grey background, then walks
the active page's nodes recursively:
* Frame: fill + stroke + recurse
* Group / Other(_): no own paint, just recurse
* Rect: fill + stroke
* Text: draw `text` string at bounds.origin via TextLayout
- Selected node gets a 2px blue stroke OVER its normal paint so
the user can see the picked node across kinds.
- `accesskit::Role::Canvas` + label "Canvas".
- `from_document(&doc)` reserves WidgetId 4000 (matches the
per-component id range convention: 1000s = LayerPanel, 2000s
= PropertyPanel, 3000s = Toolbar, 4000s = canvas).
shell-web (`widget_host.rs`):
- Aux Dropdown + TextInput retired. Layout: rails take ~1/4
width each; canvas takes the middle ~1/2 (640px tall band
below the toolbar). Below MIN_RAIL_WIDTH the host paints the
toolbar only and skips rails+canvas.
- `apply_ime` / `apply_key` are now no-op stubs (Step 4+ wires
per-widget focus before they can route back to the document).
shell-native (`widget_host.rs`):
- Mirror of shell-web's layout. Canvas band 600px tall (matches
default `inspector_window` window height).
shell-native (`examples/inspector_window.rs`):
- Window upgraded to 1100×700 (was 800×600) so all three rails
+ center canvas have room.
- `viewport_width` cached on `InspectorApp`, refreshed on
`Resized` so dragging the window resizes the layout live.
- `paint_inspector` takes the current viewport_width.
Verification:
- `cargo test -p openpencil-shell-core --lib` — 39 tests passing
(was 34; +5 canvas_viewport unit tests)
- `cargo build -p openpencil-shell-native --example
inspector_window` — green (desktop launch ready)
- `cargo build -p openpencil-shell-web --target
wasm32-unknown-unknown --features skia --release` — green
- `cargo check -p openpencil-shell-native --target
aarch64-apple-ios` — green (mobile widget stack inherits
CanvasViewport unchanged)
- `cargo check -p openpencil-shell-native --target
aarch64-linux-android` — green
- `cargo check -p openpencil-shell-core --target
wasm32-unknown-unknown` — green (shell-core stays
wasm32-clean per spec §1.2)
- `bash tools/check-wasm-bundle.sh` — PASS:
- 0 env.* imports
- 624 474 bytes gzip = 59% of 1 MiB ceiling (negligible
growth — canvas_viewport adds ~50 LOC of paint logic)
Codex Step 2 R1 returned NO-GO with 1 BLOCK + 4 CONCERNs. All
addressed:
# BLOCK — NodeId(0) constructible in release builds
`NodeId` had a `pub u64` tuple field, so any caller could write
`NodeId(0)` directly and shadow `NodeId::NONE`. The `NodeId::new`
constructor only `debug_assert`ed against 0; release builds
silently let `Node::leaf(0, ...)` produce a zero-id Node that
collided with the NONE sentinel and confused
`Document::selected_node`.
Fix:
- Inner `u64` is now private (`pub struct NodeId(u64)`).
- `NodeId::new` hard-panics in BOTH debug and release if
id == 0 (was `debug_assert`).
- New `NodeId::raw(self) -> u64` accessor for read paths
(to_widget_id, serde Step 4+, tests).
- New `#[should_panic]` test runs in both build modes.
# CONCERN-1 — selection / LayerPanel page mismatch
`Document::selected_node` walked all pages while
`LayerPanel::from_document` rendered only `pages[0]`. A
selection on page 2 drove PropertyPanel while the LayerPanel
showed page 1 with no highlight.
Fix:
- New `Document::active_page_index: usize` field (defaults to 0).
- New `Document::active_page() -> Option<&Page>` accessor.
- `Document::selected_node` now ONLY searches the active page.
A selection on a non-active page returns `None`.
- `LayerPanel::from_document` now walks `active_page()`.
- New tests:
- `from_document_scopes_to_active_page_only`
- `document_selected_node_scopes_to_active_page`
- `document_active_page_returns_indexed_page`
- `document_active_page_returns_none_when_index_out_of_range`
# CONCERN-2 — duplicate node ids unenforced
`Node::leaf` / `Node::with_children` / `Page::new` accepted
arbitrary id assignment with no uniqueness check; dup ids would
make `selected_node` return the first hit while LayerPanel might
mark several rows selected.
Fix:
- New `Document::find_duplicate_id() -> Option<NodeId>` walker
(HashSet over page ids + recursive node ids; first dup wins).
- New `Document::validate() -> Result<(), String>` runs the
duplicate scan + `active_page_index` range check.
- `Document::sample()` now `debug_assert`s self-validation so
any fixture-time regression is caught in tests.
- New tests:
- `document_sample_passes_validate`
- `document_validate_catches_duplicate_node_id`
- `document_validate_catches_active_page_index_out_of_range`
# CONCERN-3 — rail_w can go negative on tiny viewports
WidgetHost (web) + WidgetHostNative (native) computed
`rail_w = 240.0_f32.min(viewport_width / 2.0 - 8.0)`. When
viewport_width < 16 the expression went negative, producing
negative-size Rects.
Fix:
- New `MIN_RAIL_WIDTH: f32 = 80.0` const in both hosts.
- `rail_w_raw = (viewport_width / 2.0 - 8.0).min(240.0)` then
`rail_w = rail_w_raw.max(0.0)` clamps to non-negative.
- If `rail_w < MIN_RAIL_WIDTH` the host paints the Toolbar only
and skips both rails — there's no usable space for a
meaningful LayerPanel + PropertyPanel split.
# CONCERN-4 — toolbar overflow silently drops buttons
`Toolbar::paint` early-returns from the per-button loop when a
button would overflow the rect, leaving later tools unreachable
on narrow viewports.
Fix (Step 2 scope = doc only):
- Inline comment in `Toolbar::paint` documents the limitation +
enumerates the Step 3+ resolutions (horizontal scroll inside
the toolbar rect, "More tools" overflow dropdown, icon-only
mode at narrow widths). Phase D pointer/wheel routing has to
land before any of those is wirable.
Test count: 24 → 33 lib tests (+9 new). All 64 shell-core tests
green; web + native + iOS + Android all compile; bundle gate
PASS at 624 466 bytes gzip (59% of 1 MiB ceiling).