Commit graph

1002 commits

Author SHA1 Message Date
Kayshen-X e9960e6944 refactor(editor): make EditorState the web host's single source of truth
Mirror the native host migration for `openpencil-shell-web`. The web
`WidgetHost` now holds an `op_editor_core::EditorState` authoritatively
instead of a shell-core `Document`. The ~30 shared widgets stay
`&Document`-bound and read-only — they are fed a derived `paint_doc`
snapshot rebuilt lazily by `refresh_paint_doc()` whenever
`editor_state_dirty` is set.

- Every mutation routes through an `op-editor-core` mutator + flags the
  dirty bit; the snapshot re-derives once before paint / before any
  hit-testing input event.
- Hit-test-then-mutate handlers refresh, hit-test on `&paint_doc`,
  extract owned results, then mutate `editor_state`; shell-core hit
  enums translate via `op_pen_loader::rev::*`.
- `paint` takes `&mut self` to drain the cache at the top of the pass.
- `op-editor-core` / `op-pen-loader` are optional deps behind the
  `skia` feature (matching `skia-safe`) so the skia-free wasm32 CI stub
  baseline stays clean — `op-pen-loader` pulls `jian-skia` transitively.
- Fix two pre-existing `--features skia` build blockers in `lib.rs`
  (missing `Performance` web-sys feature, `canvas` moved before reuse).

No web-editor behaviour change — feature parity preserved.
2026-05-16 20:13:42 +08:00
Kayshen-X 1b310c714a fix(editor): address host-migration review concerns (stale-doc hit-tests, dirty-mark, legacy load, view-state persist, fill payload)
- CursorMoved refreshes the derived paint Document before cursor_hint
  so a post-mutation / pre-paint cursor move can't read stale geometry.
- apply_cursor_move refreshes the paint doc up front so every hit-test
  inside it (align toolbar, color picker, layer menu, panel resize)
  sees a fresh document.
- commit_variable_row_focus_if_any routes every exit through one
  mark_dirty so an invalid variable edit can't leave the cache stale.
- Legacy private DocPayload files are detected and rejected with an
  explicit "saved by an older version, must be re-saved" message
  instead of a silent / opaque schema parse failure.
- active_page_index is persisted in a minimal .opmeta sidecar so it
  survives a save / load round-trip; the .op file stays canonical.
- set_primary_fill_type carries the existing fill body across a
  fill-type change (gradient stops / opacity / blend mode) instead of
  overwriting it with a default body, matching shell-core behaviour.
- collapsed_layers documented as intentionally view-only at the
  EditorSnapshot and editor_ui_state sites.
2026-05-16 19:53:08 +08:00
Kayshen-X f0bb56b6cd refactor(editor): make EditorState the native host's single source of truth
Flip WidgetHostNative off shell-core's Document onto
op_editor_core::EditorState. The host now owns one authoritative
state; every paint pass + hit-test reads a lazily-derived,
read-only Document snapshot (`paint_doc`), rebuilt via
op-pen-loader's pen_document_to_document + apply_editor_state_ui
whenever an `editor_state_dirty` flag is set.

Why: removes the dual-state strangler scaffold so the canonical
PenDocument model is the only editable state — input handlers
mutate EditorState, widgets stay read-only over the derived
Document. .op save now serializes editor_state.doc; load seeds
EditorState::from_document. shell-core's Document becomes a
paint-only target.

- op-editor-core: add host_support (EditorState::sample,
  create_node_for_tool, replace_paths_with_polyline) + re-export
  VariableScalar/VariableKind.
- shell-native: rewrite all 8 widget_host submodules onto
  editor_state; split press.rs -> press_helpers.rs and keyboard
  click routing -> click.rs to stay under the 800-line cap;
  boolean_ops becomes a pure compute_boolean_op committed back
  through EditorState.
- desktop: persistence / settings_io / chat_session /
  model_discovery / main / frame move onto editor_state accessors.
2026-05-16 19:29:34 +08:00
Kayshen-X 58346c2a9c feat(op-editor-core,op-pen-loader): host-migration prerequisites (fill-type, chat-model, layer-collapse, panel predicates, reverse enum bridge) 2026-05-16 18:54:04 +08:00
Kayshen-X 302c9750d6 feat(op-pen-loader): bridge EditorState UI/chat/components into a paint Document 2026-05-16 18:34:27 +08:00
Kayshen-X a79dbaf0e3 feat(op-editor-core): close variable/theme gaps for the host migration
Three additive variable-handling fixes so the editor host can migrate
onto EditorState as a mechanical port:

- Gap 1: add `variable: Option<String>` to ColorPickerState, port
  `open_color_picker_for_variable`, and route `color_picker_set_hsv` /
  `close_color_picker` through `set_variable_color` when set.
- Gap 2: add `op_pen_loader::editor_state_var_table(&EditorState)`,
  folding persisted variables/themes plus the transient active-theme
  selection + ref caches into a shell-core VariableTable. Lives in
  op-pen-loader (not op-editor-core) to keep op-editor-core wasm-clean
  and free of any shell-core dependency.
- Gap 3: confirmed undo/redo of variable create/delete/rename round-
  trips for free (EditorSnapshot clones the whole PenDocument); added
  tests to lock it in.
2026-05-16 18:19:35 +08:00
Kayshen-X 77f89b6ef8 refactor: extract op-pen-loader (canonical .op → Document) into a shared crate 2026-05-16 18:04:46 +08:00
Kayshen-X 88e155b324 refactor(op-editor-core): rename UiChrome → EditorUiState (drop the 'chrome' term) 2026-05-16 17:51:39 +08:00
Kayshen-X ec5ac70118 feat(op-editor-core): extend EditorState into a full state superset of Document 2026-05-16 17:47:53 +08:00
Kayshen-X b9d7032474 refactor(host): scoped EditorState bridge on WidgetHostNative
Phase 6 strangler scaffolding: WidgetHostNative now holds an
op_editor_core::EditorState alongside the legacy shell-core Document
so the ~30 widgets can migrate onto the canonical model one group at
a time while the workspace stays build-green.

The bridge is intentionally minimal — Document remains the source of
truth (every un-migrated widget paints from it and every apply_*
mutates it), and editor_state is not yet wired into paint or input.
There is no Document -> PenDocument converter, and the desktop
pen_doc_adapter only goes the other way (baking flex layout into AABB
rects, which is irreversible), so a per-frame round-trip would be
unsound. Each later 6.x task adds its own per-group sync point as it
switches a widget group's paint/input onto editor_state.

Field, accessors and dependency are all marked TEMPORARY — deleted in
Phase 7 when Document dies and editor_state becomes the host's only
state.
2026-05-16 17:30:44 +08:00
Kayshen-X 8078a70d8c refactor(figma): port figma import onto PenNode
FigmaClipboardNode::to_node now produces canonical
jian_ops_schema::PenNode instead of shell-core's flat Node. Maps the
8 common Figma kinds onto Frame/Group/Rectangle/Ellipse/Line/Polygon/
Path/Text variants; unmapped kinds fall back to Frame with the
original kind preserved in base.role since PenNode has no Other
escape hatch. Tests ported to assert on PenNode variants + base
accessors. The import stays in shell-core (Option B, in-place port).
2026-05-16 17:21:35 +08:00
Kayshen-X fa6fe21ed5 refactor(codegen): port codegen onto PenDocument 2026-05-16 17:15:44 +08:00
Kayshen-X 39162ad08e refactor(mcp): port MCP server onto op-editor-core EditorCommand
Port the in-process MCP server off shell-core's legacy `Document` /
`McpCommand` onto `op_editor_core::EditorState` / `EditorCommand`. The
module physically stays inside `openpencil-shell-core` (an `op-mcp`
crate extraction is a later Phase-7 task).

- Read tools snapshot `EditorState` (canonical `PenDocument`); write
  tools emit `op_editor_core::EditorCommand` applied via
  `EditorState::apply`. Node ids are now canonical `.op` schema
  strings, not the old `u64`.
- Component commands surface a clean `ToolFailed` "known gap" error —
  `op-editor-core` has no component registry yet. Same for
  `set_node_collapsed` (`NodeFlag::Collapsed` has no schema field).
- `mcp_serve.rs` loads the `.op` file straight into an `EditorState`
  (plain `jian-ops-schema` deserialization) and saves the
  `PenDocument` back on every successful write.
- Delete the orphaned legacy apply path (`document/mcp_apply*.rs` +
  `Document`/`VariableTable::apply_mcp_command`); the widgets that use
  `Document` are untouched.
- Split `tools.rs` → `tools.rs` + `read_tools.rs` and
  `component_tools.rs` → `component_tools.rs` + `page_tools.rs` to
  hold the 800-line cap.
2026-05-16 17:07:42 +08:00
Kayshen-X 6cd2b5cd8c feat(op-editor-core): EditorCommand DTO + EditorState::apply 2026-05-16 16:32:44 +08:00
Kayshen-X 3e04f9a261 feat(op-editor-core): port color/align/variables/rename mutators 2026-05-16 16:21:12 +08:00
Kayshen-X 9d9862a099 feat(op-editor-core): port editing mutators onto EditorState 2026-05-16 16:10:36 +08:00
Kayshen-X 58a4ee58e0 feat(op-editor-core): EditorState on jian PenDocument 2026-05-16 15:54:32 +08:00
Kayshen-X 8f8b36b76f docs: fix stale i18n + renderer paths flagged by Codex review 2026-05-16 15:45:54 +08:00
Kayshen-X 01731d6af8 refactor(document): migrate test code to string NodeId 2026-05-16 15:42:46 +08:00
Kayshen-X 0174aba93a refactor(render): move RenderBackend trait into op-editor-core 2026-05-16 14:36:09 +08:00
Kayshen-X b67fe5fbcc chore: scaffold op-editor-core crate 2026-05-16 14:32:53 +08:00
Kayshen-X 0ab38e1ca0 fix(i18n): point convert-locales.py output at the op-i18n crate 2026-05-16 14:25:58 +08:00
Kayshen-X 65e2dd9ebb chore: bump vendor/jian to the clippy-clean commit 2026-05-16 14:17:31 +08:00
Kayshen-X 08686c3b95 refactor(i18n): extract op-i18n crate (with Locale) from openpencil-shell-core 2026-05-16 14:13:38 +08:00
Kayshen-X 1ef513d5a6 chore: bump vendor/jian to the relocated-renderer commit 2026-05-16 13:57:08 +08:00
Kayshen-X 182ec78416 docs: fix stale anthropic-agent-sdk path in chat_claude.rs comment 2026-05-16 13:20:02 +08:00
Kayshen-X 5b6cf39775 chore: vendor anthropic-agent-sdk out of crates/ 2026-05-16 13:14:20 +08:00
Kayshen-X 764435b500 fix(ci): bump CI toolchain pins to Rust 1.94, correct README crate table 2026-05-16 13:05:40 +08:00
Kayshen-X e3a163dc2d fix(ci): scrub remaining refs to the deleted stub crates
Codex review of Task 1.2 found four dangling references to the deleted
stub crates (pen-types/core/engine/codegen/figma + openpencil-app):

- package.json: drop the five -p <crate> args from cargo:wasm-check
- rust-release.yml: restore the build job, re-pointed at the real
  openpencil-desktop crate so release-draft has artifacts to publish
- check-jian-boundaries.sh: 4 -> 3 invariants in the success message
- README.md: remove the deleted crate rows from the crate-list table
2026-05-16 12:56:39 +08:00
Kayshen-X 0a90f13aae chore: delete dead pen-* stub crates + openpencil-app 2026-05-16 12:45:07 +08:00
Kayshen-X 76c598a23d feat(ai): adopt the official github-copilot-sdk for the Copilot bridge
Replace the in-workspace copilot-sdk fork with the official
`github-copilot-sdk` crate (crates.io 0.1). Rewrite chat_copilot.rs
against its API: Client::start + a streaming SessionConfig whose
SessionHandler forwards `assistant.message_delta` / `session.error`
events into the ChatProvider channel, one client+session per turn.
Delete the crates/copilot-sdk fork directory. Build + 72 desktop
tests pass on the Rust 1.94 toolchain.
2026-05-16 10:26:44 +08:00
Kayshen-X 4a604bfe16 chore: bump workspace toolchain to Rust 1.94
The official github-copilot-sdk requires rust-version 1.94 +
edition 2024; the workspace was pinned to 1.85. Bump the toolchain
so the SDK can be adopted for the Copilot connection. Verified:
full native build, wasm32 web check, and shell-core tests all pass
on 1.94.1 with no new errors.
2026-05-16 10:05:27 +08:00
Kayshen-X 542ec113d1 feat(panels): wire the Effects section "+" to add a drop shadow
The property panel's 效果 header drew a "+" icon that did nothing.
Add a PropertyPanelAction::AddEffect, emit its hit rect over the
icon in the layout walker (no paint/layout change — the icon was
already painted), and dispatch it in both hosts to
Document::add_drop_shadow_to_selected. Clicking "+" now appends a
drop shadow that renders + persists.
2026-05-16 10:00:50 +08:00
Kayshen-X c0f503e99a feat(document): add_drop_shadow_to_selected mutator
Append a default drop-shadow effect (CSS-style 0 4px 8px
rgba(0,0,0,0.25)) to the selected node — the building block the
property panel's 效果 "+" action will call. Guarded on a real,
editable selection like the sibling set_selected_* mutators.
2026-05-16 03:27:52 +08:00
Kayshen-X 6e3404dcab feat(canvas): gaussian-blur drop-shadow on the web backend
Mirror the native fill_drop_shadow override on the wasm web
backend with skia MaskFilter::blur so drop-shadow effects paint as
a real soft shadow on both native and web — keeping the two
shells visually consistent.
2026-05-16 02:47:53 +08:00
Kayshen-X 6292556ccb feat(canvas): real gaussian blur for native drop-shadow paint
The fill_drop_shadow RenderBackend primitive only had the default
un-blurred approximation. Override it on the native backend with
skia's MaskFilter::blur (CSS blur-radius → sigma = radius/2) so
drop-shadow effects paint as a real soft shadow on desktop.
2026-05-16 02:46:45 +08:00
Kayshen-X 7a90583073 fix(ai): parse CLI JSON-RPC responses robustly across stdio framing
The Copilot / Codex response parsers ran serde on the whole stdout
line, so a `Content-Length:` header prefix or a glued next-frame
header suffix would fail the parse. Add brace-counted (string- and
escape-aware) JSON-object extraction so discovery works whether the
CLI replies newline-delimited or Content-Length-framed.
2026-05-16 02:39:06 +08:00
Kayshen-X e87d69cec4 fix(ai): Content-Length-frame Copilot stdio JSON-RPC requests
The Copilot discovery wrote newline-delimited requests to
`copilot --stdio`; the official copilot-sdk frames requests with
`Content-Length` headers. The CLI happens to accept both, but
relying on that leniency is fragile. Write proper LSP-style
Content-Length frames so we speak the protocol the SDK defines.
Verified live against the installed `copilot` CLI.
2026-05-16 02:28:07 +08:00
Kayshen-X 3eca00a099 feat(ai): query Copilot models via the official CLI JSON-RPC protocol
Copilot discovery now drives `copilot --stdio` over JSON-RPC
(connect → models.list) — the same wire protocol (version 3) the
official github-copilot-sdk uses. Speaking the protocol directly
rather than linking the SDK crate keeps the workspace on Rust 1.85
(the SDK crate requires 1.94). Documented model names stay as the
fallback when the CLI is installed but the query doesn't answer.
2026-05-16 02:07:35 +08:00
Kayshen-X 3c78d7d68f feat(ai): query Codex models via the official app-server protocol
Codex discovery now drives `codex app-server` over JSON-RPC stdio
(initialize → initialized → model/list), the official App Server
API — more accurate and stable than reading models_cache.json,
which may be stale or absent. The cache stays as a fallback, then
a minimal placeholder when codex is installed but neither source
answers. A reader thread + deadline keeps a hung server from
blocking discovery.
2026-05-15 23:40:54 +08:00
Kayshen-X ee40986d97 feat(ai): model-picker dropdown in the chat panel
Replace the chat panel's agent-cycling chip with a real model
picker: the chip shows the selected model's provider brand logo +
display name, and clicking it opens an upward dropdown listing
chat.available_models grouped by provider with a selected check
(mirrors the TS ai-chat-model-selector). Selecting a model also
re-syncs chat_selected_agent to its provider so the chat transport
targets the matching CLI. Outside clicks dismiss the picker.
2026-05-15 23:34:08 +08:00
Kayshen-X 5bdd74d624 feat(ai): discover chat models from installed CLIs
The chat panel never had a model list — shell-core is transport-free
and cannot query a CLI. Add desktop-side cross-platform discovery:
Codex's models_cache.json (real, file-based), `opencode models`
(real, subprocess), and the documented CLI model names for Claude /
Gemini / Copilot which expose no listing interface — each gated on
the CLI actually being installed (PATH walk with platform exe
extensions). Discovery runs on a worker thread and drains into
chat.available_models. shell-core keeps only the ModelEntry type.

Move the per-frame paint pass to frame.rs to keep main.rs under cap.
2026-05-15 23:24:55 +08:00
Kayshen-X 76ca5efb21 feat(canvas): paint node drop-shadow effects behind fills
Node.effects already round-trips through persistence and canonical
import, but the canvas never rendered it. Add a fill_drop_shadow
RenderBackend primitive (default impl approximates with a translucent
fill; native + web will override with skia MaskFilter blur) and paint
each DropShadow behind Frame / Rect / Ellipse fills, offset + blur
scaled by viewport zoom.
2026-05-15 23:09:42 +08:00
Kayshen-X a15f63f551 fix(import): parse canonical rgba() shadow colours instead of dropping them
Canonical .op shadow colours use functional rgba()/rgb() notation, which
the hex-only parser rejected — valid shadows were imported as opaque
black, then dropped entirely after the prior fix. Add parse_css_color
covering both hex and functional forms. Move the effect-conversion
helpers into persistence_effects.rs so pen_doc_adapter.rs stays under
the 800-line cap.
2026-05-15 23:03:11 +08:00
Kayshen-X 3ba7d6cd6b fix(import): drop shadow on unparseable colour, not opaque black
`shadows_from_canonical` fell back to `[0,0,0,1.0]` (opaque black)
when `parse_hex` rejected a shadow colour — so an `rgba()`,
named, or short-hex colour imported as a wrong solid-black
shadow (codex stop-gate). Switch the fallback to `filter_map`
`None`: an unparseable colour drops that shadow entirely, which
is honest (no shadow) rather than misleading (wrong shadow).
Matches `first_solid_color`'s existing `parse_hex(...)?` discipline.
2026-05-15 22:52:28 +08:00
Kayshen-X e8a5422578 fix(import): carry canonical drop-shadow effects on .op import
`pen_doc_adapter::node_to_payload` dropped every `PenEffect` —
a `.op` authored with shadows lost them on import (codex stop-gate;
the inverse of the save-path effects round-trip just added).

`shadows_from_canonical` reads each variant's effects (Frame /
Group / Rectangle carry them on `container`, leaf shapes directly;
IconFont / Ref have none) and maps `PenEffect::Shadow` →
`ShadowPayload`. Blur / background-blur are skipped — the shell's
`Effect` model is drop-shadow-only today.
2026-05-15 22:44:41 +08:00
Kayshen-X 1bed264d1f feat(document): add Effect (drop shadow) data model + persistence
First slice of the Effects gap — the property panel's 效果 section
was a header-only stub with no data behind it.

- `Effect` / `DropShadow` types in `document.rs`; `Node.effects:
  Vec<Effect>` (offset_x/y + blur + color, doc-px). Both `Node`
  builders + `deep_clone_with_new_ids` carry it.
- `persistence_effects.rs` — `ShadowPayload` + `effects_to_payload`
  / `effects_from_payload`; `NodePayload.effects` is `#[serde(default)]`
  so legacy `.op` files still load. Carved into its own module so
  `persistence.rs` stays under the 800-line cap.
- Round-trip tested through the real serde JSON path (2 tests).

858 workspace tests pass. Still pending (own slices): canvas
drop-shadow paint (needs a blur primitive on `RenderBackend`),
the property-panel 效果 editing rows, and a `set_node_shadow`
MCP tool. Shadows currently round-trip through save/load but do
not yet render.
2026-05-15 22:33:13 +08:00
Kayshen-X d9ada6cc11 docs(mcp): correct read-tool count 17→18 in catalog 2026-05-15 22:10:00 +08:00
Kayshen-X 5373b83b09 docs(mcp): refresh tool catalog 21→77 + add AI chat section
- crates/CLAUDE.md MCP catalog was stale at 21 tools; replace the
  outdated table with the current 77-tool categorized summary and
  point at mcp_serve.rs TOOL_SCHEMAS as the source of truth.
- Refresh the mcp/ file-layout list (node_attr_tools,
  selected_ops_tools, extra_read_tools, json_serializer, …).
- New "AI chat (real provider integration)" section documenting
  begin_send / ChatProvider / chat_session / the model chip and
  the Codex/OpenCode honest-error behavior.
- chat_session: gate the test-only `finished()` accessor behind
  `#[cfg(test)]` to drop a dead-code warning.
2026-05-15 22:06:42 +08:00
Kayshen-X 1f617269e6 fix(ai): drop in-flight chat session on unwired-agent error path
When a send for an unwired agent (Codex / OpenCode) landed while a
previous turn was still streaming, `launch_if_pending` wrote the
error bubble but left `current` pointing at the old session — so
the next `pump` streamed the prior agent's deltas straight into
the fresh error message. Clear `current` before writing the error.
2026-05-15 21:51:39 +08:00