refactor(shell): promote inspector_window to openpencil-desktop binary crate

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.
This commit is contained in:
Kayshen-X 2026-05-10 19:50:10 +08:00
parent 967201162a
commit db69fc5fc7
5 changed files with 123 additions and 98 deletions

10
Cargo.lock generated
View file

@ -1422,6 +1422,16 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
name = "openpencil-app"
version = "0.1.0"
[[package]]
name = "openpencil-desktop"
version = "0.1.0"
dependencies = [
"openpencil-shell-core",
"openpencil-shell-native",
"skia-safe",
"winit",
]
[[package]]
name = "openpencil-shell-core"
version = "0.1.0"

View file

@ -7,8 +7,9 @@ Native + web editor chrome implemented in Rust against jian-skia. Goal: TS-equiv
```
crates/
├── openpencil-shell-core/ Platform-free widgets + Document model + RenderBackend trait
├── openpencil-shell-native/ Desktop runner: winit + skia-safe + accesskit
├── openpencil-shell-native/ Native lib: WidgetHostNative + NativeBackend + SharedSkiaContext
├── openpencil-shell-web/ Browser runner: wasm32-unknown-unknown + skia-safe-op fork
├── openpencil-desktop/ Desktop binary: winit event loop + skia-safe GL surface
└── wasm-libc-shim/ ~95 env.* shims (libc / libm / libcxx) for the wasm32 build
```
@ -82,18 +83,20 @@ resize / dpi_scale
`stroke_svg_path` parses lucide d-strings via `skia_safe::utils::parse_path::from_svg`. PaintCap::Round + PaintJoin::Round to match lucide's stroke style.
## Native runner (`shell-native/`)
## Desktop binary (`openpencil-desktop/`)
Entry: `examples/inspector_window.rs` — winit ApplicationHandler, GL surface via `jian-skia`. Logs:
`crates/openpencil-desktop/src/main.rs` is the production desktop entry. It owns the winit `ApplicationHandler`, opens a GL window via `SharedSkiaContext::new_desktop`, and dispatches every `WindowEvent` onto `WidgetHostNative::apply_*`. Behaviour:
- DPI scale via `canvas.scale((dpi, dpi))` per frame (preceded by `reset_matrix()` so it doesn't compound)
- LOGICAL viewport sizes (physical / dpi)
- Cursor position cached on `CursorMoved`, dispatched on `MouseInput`
- `MouseScrollDelta::PixelDelta` → trackpad pan; `LineDelta` / `PinchGesture` → zoom; modifier (Cmd/Ctrl) promotes pixel-delta to zoom
- Cursor flips to `EwResize` when over a panel-resize gutter (`host.panel_resize_hover`)
- `WaitUntil(host.next_animation_deadline_ms())` pumps the caret-blink redraw
Native font path bypasses jian-skia's `textlayout` (which builds a fresh `FontCollection` per call → 605ms chrome frame): `NativeBackend` caches a Roboto Typeface + a system CJK Typeface (resolved via `FontMgr::match_family_style_character('一')`) and renders via `Canvas::draw_str`.
Native font path bypasses jian-skia's `textlayout` (which builds a fresh `FontCollection` per call → 605ms chrome frame): `NativeBackend` caches a Roboto Typeface + per-codepoint system fonts (resolved via `FontMgr::match_family_style_character`, cached per `i32`) so multi-script chrome (한국어 / हिन्दी / ไทย / Tiếng Việt) renders against the right font. `draw_text` segments each run by typeface and dispatches each segment via `Canvas::draw_str`.
Run: `cargo run -p openpencil-shell-native --example inspector_window --release`.
Run: `cargo run -p openpencil-desktop --release`.
## Web runner (`shell-web/`)

View file

@ -0,0 +1,30 @@
[package]
name = "openpencil-desktop"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "OpenPencil desktop runner — winit + skia-safe binary that drives the Rust shell on macOS / Linux / Windows"
[[bin]]
name = "openpencil-desktop"
path = "src/main.rs"
# Desktop-only binary. The shell-native lib is multi-platform (mobile
# stubs land in Step 1f) but the runner here pulls winit + glutin via
# shell-native's desktop dep set, so the whole crate is gated to
# macos / linux / windows.
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
openpencil-shell-core = { path = "../openpencil-shell-core", version = "0.1.0" }
openpencil-shell-native = { path = "../openpencil-shell-native", version = "0.1.0" }
skia-safe = { version = "0.97.0", default-features = false, features = [
"binary-cache",
"textlayout",
"gl",
] }
winit = { version = "0.30.13", default-features = false, features = [
"x11",
"wayland",
"wayland-csd-adwaita",
"rwh_06",
] }

View file

@ -1,33 +1,42 @@
//! Step 1b §1.4 native inspector demo — paints the four shell-core
//! widgets (Tree / PropertyRow / Dropdown / TextInput) through
//! `WidgetHostNative` + `NativeFrameBackend`. Visually mirrors the
//! shell-web `mount()` first frame so the cross-platform widget
//! claim from spec §1.4 is concrete: same widget code, same paint
//! output on macOS / Linux / Windows desktop and on
//! wasm32-unknown-unknown browsers.
//! OpenPencil desktop runner.
//!
//! winit + skia-safe binary that wires `WidgetHostNative` (from
//! `openpencil-shell-native`) into a real OS window. Owns the
//! event loop, GL surface, DPI tracking, animation timer, and the
//! cursor / input plumbing — everything that's specific to "OS
//! window with skia GL surface" and not shared with the wasm32
//! browser host or the (Step 1f) mobile shells.
//!
//! ### Run
//!
//! ### Run (desktop)
//! ```text
//! cargo run -p openpencil-shell-native --example inspector_window
//! cargo run -p openpencil-desktop --release
//! ```
//!
//! ### Mobile (iOS / Android) — Step 1f
//! This example is desktop-only (winit + `SharedSkiaContext::
//! new_desktop`). Mobile shells will land their own runners using
//! the platform `GlContextProvider` (`EaglProvider` on iOS,
//! `AndroidEglProvider` on Android — both zero-sized placeholders
//! in shell-native today; spec §11 + 2026-05-10 user directive
//! "安卓和ios 不需要 ipc / 本地 cli — 只需要 custom provider").
//! The widget glue (`WidgetHostNative` + `NativeFrameBackend`) is
//! platform-agnostic; mobile runners reuse the same
//! `host.paint(&mut frame, width)` once their provider ships real
//! `make_current` / `swap_buffers` impls.
//! ### Layout
//!
//! The window should display a 280 px column on the left containing
//! a 3-row tree (Frame / Title / Button — first row selected blue),
//! a "Width 960" property row, a "Normal" dropdown, and a "Frame 1"
//! text input. CI verifies `cargo build --examples` only — visual
//! verification is the Phase E manual smoke responsibility.
//! - `WidgetHostNative` (in `openpencil-shell-native`) owns the
//! `Document`, paints widgets, and routes input — platform-free
//! beyond the `RenderBackend` impl.
//! - `SharedSkiaContext` + `NativeBackend` (also in shell-native)
//! wrap jian-skia + glutin so the same widget code paints on
//! macOS / Linux / Windows.
//! - This binary glues winit's `ApplicationHandler` events
//! (Resumed / Resized / RedrawRequested / CursorMoved /
//! MouseInput / MouseWheel / PinchGesture / KeyboardInput /
//! ModifiersChanged / ScaleFactorChanged) onto the host's
//! `apply_*` methods + `paint`.
//!
//! ### Mobile (iOS / Android) — Step 1f
//!
//! The desktop crate is gated to macOS / Linux / Windows. Mobile
//! shells will live in their own crates with platform-specific
//! `GlContextProvider` impls (`EaglProvider` on iOS,
//! `AndroidEglProvider` on Android — both already stubbed in
//! shell-native; spec §11 + 2026-05-10 directive). The widget glue
//! `WidgetHostNative` is platform-agnostic; mobile runners reuse
//! `host.paint(&mut frame, w, h)` once their provider ships real
//! `make_current` / `swap_buffers`.
#![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
@ -40,24 +49,22 @@ use winit::application::ApplicationHandler;
use winit::event::{
ElementState, KeyEvent, MouseButton, MouseScrollDelta, StartCause, WindowEvent,
};
use winit::event_loop::ControlFlow;
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{Key, NamedKey};
use winit::window::{Window, WindowId};
/// Default starting viewport — matches `with_inner_size` below so
/// the first frame has the right layout. Resize is handled in the
/// `Resized` arm: we re-cache the host viewport via `set_viewport`
/// so the next paint matches the new physical size.
/// `Resized` arm: we cache the LOGICAL viewport so the next paint
/// matches the new size at DPI-independent coordinates.
const INITIAL_VIEWPORT_W: f32 = 1440.0;
const INITIAL_VIEWPORT_H: f32 = 900.0;
/// Paint pass — clear to white, then dispatch the editor-UI
/// composition through `WidgetHostNative`. Pulled into a free
/// function so the initial `Resumed` paint and `RedrawRequested`
/// redraws share the exact same draw list (same pattern as
/// `basic_window`).
fn paint_inspector(
/// Paint pass — clear, scale by DPI, dispatch to the widget host.
/// The matrix `reset_matrix` is required because skia's canvas
/// matrix is stateful across `with_frame` invocations; without it
/// `scale(dpi, dpi)` would compound per redraw.
fn paint(
ctx: &mut SharedSkiaContext,
backend: &mut NativeBackend,
host: &WidgetHostNative,
@ -68,12 +75,6 @@ fn paint_inspector(
ctx.begin_frame();
ctx.with_frame(|canvas, _glow| {
canvas.clear(skia_safe::Color::BLACK);
// Skia canvas matrix is stateful across `with_frame`
// invocations — without an explicit reset, every redraw
// applies `scale(dpi, dpi)` on top of the previous frame's
// matrix, doubling content size per click. Reset to
// identity so the per-frame scale is absolute, not
// cumulative. (Step 4 stop-hook fix: "click → 突然放大".)
canvas.reset_matrix();
canvas.scale((dpi, dpi));
let mut frame = NativeFrameBackend::new(backend, canvas);
@ -89,40 +90,38 @@ fn paint_inspector(
ctx.present();
}
struct InspectorApp {
struct DesktopApp {
window: Option<Window>,
ctx: Option<SharedSkiaContext>,
backend: Option<NativeBackend>,
host: WidgetHostNative,
/// Cached LOGICAL viewport size — refreshed on Resumed +
/// Resized so the host paints into the right rect at logical
/// (DPI-independent) coordinates. The canvas is scaled by DPI
/// inside `paint_inspector`, so widget paint coords are
/// logical px and the surface (set up by `SharedSkiaContext`)
/// is physical.
/// Resized so the host paints at DPI-independent coordinates.
/// The canvas is scaled by DPI inside `paint`; the underlying
/// skia surface (set up by `SharedSkiaContext`) is physical.
viewport_width: f32,
viewport_height: f32,
/// Last cursor position (logical, top-left origin). winit
/// Last cursor position (logical, top-left origin). winit's
/// `CursorMoved` reports a `PhysicalPosition`; we divide by
/// the cached DPI so `WidgetHostNative::apply_click` matches
/// the same logical coordinate space the widget paints used.
/// the cached DPI so `WidgetHostNative::apply_*` receives
/// the same logical coordinate space the widgets paint in.
cursor_x: f32,
cursor_y: f32,
/// Cached scale factor — refreshed on Resumed +
/// `ScaleFactorChanged`. Used to scale the canvas + convert
/// physical cursor positions to logical.
/// `ScaleFactorChanged`. Drives the DPI scale on the canvas
/// and the physical→logical cursor conversion.
dpi: f32,
/// Cmd / Ctrl held — promotes 2-finger swipe from pan to zoom.
zoom_modifier: bool,
/// Monotonic clock anchor — `Instant.elapsed().as_millis()`
/// from this is fed into `WidgetHostNative::set_now_ms` so
/// `jian_core::anim::blink_visible` can drive the caret
/// blink (and any future time-based UI animation).
/// `jian_core::anim::blink_visible` can drive the caret blink
/// (and any future time-based UI animation).
clock_start: Instant,
error: Option<SharedSkiaError>,
}
impl InspectorApp {
impl DesktopApp {
fn new() -> Self {
Self {
window: None,
@ -141,7 +140,7 @@ impl InspectorApp {
}
}
impl ApplicationHandler for InspectorApp {
impl ApplicationHandler for DesktopApp {
fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
// When the WaitUntil deadline fires, the next redraw paints
// the next caret-blink phase. winit doesn't auto-redraw on
@ -158,7 +157,7 @@ impl ApplicationHandler for InspectorApp {
return;
}
let attrs = Window::default_attributes()
.with_title("OpenPencil — inspector_window (Step 3 native)")
.with_title("OpenPencil")
.with_inner_size(winit::dpi::LogicalSize::new(
INITIAL_VIEWPORT_W as u32,
INITIAL_VIEWPORT_H as u32,
@ -166,7 +165,7 @@ impl ApplicationHandler for InspectorApp {
let window = match event_loop.create_window(attrs) {
Ok(w) => w,
Err(err) => {
eprintln!("inspector_window: create_window failed: {err}");
eprintln!("openpencil-desktop: create_window failed: {err}");
event_loop.exit();
return;
}
@ -180,7 +179,7 @@ impl ApplicationHandler for InspectorApp {
self.backend = Some(NativeBackend::with_dpi(dpi));
}
Err(err) => {
eprintln!("inspector_window: SharedSkiaContext::new_desktop failed: {err}");
eprintln!("openpencil-desktop: SharedSkiaContext::new_desktop failed: {err}");
self.error = Some(err);
event_loop.exit();
return;
@ -189,7 +188,7 @@ impl ApplicationHandler for InspectorApp {
self.window = Some(window);
if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) {
paint_inspector(
paint(
ctx,
backend,
&self.host,
@ -210,8 +209,7 @@ impl ApplicationHandler for InspectorApp {
// WindowEvent so `apply_press` / `apply_text` /
// `apply_backspace` etc. stamp `caret_anchor_ms` with the
// CURRENT timestamp, not the one captured at the previous
// RedrawRequested (codex Step 6 stop-hook fix: "caret
// reset can use a stale clock").
// RedrawRequested.
let now_ms = self.clock_start.elapsed().as_millis() as u64;
self.host.set_now_ms(now_ms);
match event {
@ -221,13 +219,11 @@ impl ApplicationHandler for InspectorApp {
WindowEvent::Resized(size) => {
if let Some(ctx) = self.ctx.as_mut() {
if let Err(err) = ctx.resize(size.width, size.height) {
eprintln!("inspector_window: resize failed: {err}");
eprintln!("openpencil-desktop: resize failed: {err}");
self.error = Some(err);
event_loop.exit();
}
}
// Cache LOGICAL size — divide physical by DPI so
// widget paint coords stay DPI-independent.
self.viewport_width = size.width as f32 / self.dpi;
self.viewport_height = size.height as f32 / self.dpi;
if let Some(window) = self.window.as_ref() {
@ -241,11 +237,8 @@ impl ApplicationHandler for InspectorApp {
}
}
WindowEvent::RedrawRequested => {
// now_ms was already pushed into the host at the
// top of `window_event` — paint reads the same
// value via `host.now_ms`.
if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) {
paint_inspector(
paint(
ctx,
backend,
&self.host,
@ -254,9 +247,6 @@ impl ApplicationHandler for InspectorApp {
self.dpi,
);
}
// Schedule next wake-up at the caret blink boundary
// (or back to event-driven `Wait` when no animation
// is pending).
if let Some(deadline_ms) = self.host.next_animation_deadline_ms() {
let deadline = self.clock_start + Duration::from_millis(deadline_ms);
event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
@ -265,9 +255,6 @@ impl ApplicationHandler for InspectorApp {
}
}
WindowEvent::CursorMoved { position, .. } => {
// winit `position` is PHYSICAL — convert to logical
// so widget hit-tests + drag deltas match the
// coordinate space widgets paint in.
self.cursor_x = position.x as f32 / self.dpi;
self.cursor_y = position.y as f32 / self.dpi;
if let Some(window) = self.window.as_ref() {
@ -322,11 +309,11 @@ impl ApplicationHandler for InspectorApp {
}
}
WindowEvent::MouseWheel { delta, .. } => {
// Figma-style routing: PixelDelta (trackpad
// 2-finger swipe) pans, LineDelta (mouse wheel)
// zooms. Cmd / Ctrl held promotes a pixel-delta
// gesture to zoom too, since trackpad-only laptops
// need a way to zoom without a pinch sensor.
// Figma-style routing: PixelDelta (trackpad 2-finger
// swipe) pans, LineDelta (mouse wheel) zooms. Cmd /
// Ctrl held promotes pixel-delta to zoom too, since
// trackpad-only laptops need a way to zoom without
// a pinch sensor.
let consumed = match delta {
MouseScrollDelta::LineDelta(_, y) => self.host.apply_wheel(
self.cursor_x,
@ -365,9 +352,6 @@ impl ApplicationHandler for InspectorApp {
}
}
WindowEvent::PinchGesture { delta, .. } => {
// macOS pinch — emit as zoom centered on cursor.
// `delta` is a unitless ratio per pinch tick; scale
// it up so it feels comparable to wheel zoom.
let consumed = self.host.apply_wheel(
self.cursor_x,
self.cursor_y,
@ -382,8 +366,6 @@ impl ApplicationHandler for InspectorApp {
}
}
WindowEvent::ModifiersChanged(mods) => {
// Track Cmd / Ctrl so 2-finger swipe can be
// promoted to zoom while held.
let state = mods.state();
self.zoom_modifier = state.super_key() || state.control_key();
}
@ -428,7 +410,7 @@ impl ApplicationHandler for InspectorApp {
fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
if let Some(mut ctx) = self.ctx.take() {
if let Err(err) = ctx.teardown() {
eprintln!("inspector_window: teardown failed: {err}");
eprintln!("openpencil-desktop: teardown failed: {err}");
}
}
self.backend.take();
@ -440,18 +422,18 @@ fn main() {
let event_loop = match EventLoop::new() {
Ok(el) => el,
Err(err) => {
eprintln!("inspector_window: EventLoop::new failed: {err}");
eprintln!("openpencil-desktop: EventLoop::new failed: {err}");
std::process::exit(1);
}
};
event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait);
let mut app = InspectorApp::new();
event_loop.set_control_flow(ControlFlow::Wait);
let mut app = DesktopApp::new();
if let Err(err) = event_loop.run_app(&mut app) {
eprintln!("inspector_window: run_app exited with error: {err}");
eprintln!("openpencil-desktop: run_app exited with error: {err}");
std::process::exit(1);
}
if let Some(err) = app.error {
eprintln!("inspector_window: fatal error during run: {err}");
eprintln!("openpencil-desktop: fatal error during run: {err}");
std::process::exit(1);
}
}

View file

@ -329,7 +329,7 @@ mod tests {
}
#[test]
fn layout_reports_full_width_and_48_height() {
fn layout_reports_full_width_and_top_bar_height() {
let bar = TopBar::untitled();
let cx = LayoutCx {
available_width: 1000.0,
@ -337,7 +337,7 @@ mod tests {
};
let lb = bar.layout(&cx);
assert_eq!(lb.rect.size.x, 1000.0);
assert_eq!(lb.rect.size.y, 48.0);
assert_eq!(lb.rect.size.y, TOP_BAR_HEIGHT);
}
#[test]