From db69fc5fc727810d70eea33f0159e21bd43cd7b7 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 10 May 2026 19:50:10 +0800 Subject: [PATCH] refactor(shell): promote inspector_window to openpencil-desktop binary crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 10 ++ crates/CLAUDE.md | 13 +- crates/openpencil-desktop/Cargo.toml | 30 ++++ .../src/main.rs} | 164 ++++++++---------- .../src/widgets/top_bar.rs | 4 +- 5 files changed, 123 insertions(+), 98 deletions(-) create mode 100644 crates/openpencil-desktop/Cargo.toml rename crates/{openpencil-shell-native/examples/inspector_window.rs => openpencil-desktop/src/main.rs} (71%) diff --git a/Cargo.lock b/Cargo.lock index 24c7878c3..dd96f5d3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index cb7201390..d3f97a585 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -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/`) diff --git a/crates/openpencil-desktop/Cargo.toml b/crates/openpencil-desktop/Cargo.toml new file mode 100644 index 000000000..fa0876034 --- /dev/null +++ b/crates/openpencil-desktop/Cargo.toml @@ -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", +] } diff --git a/crates/openpencil-shell-native/examples/inspector_window.rs b/crates/openpencil-desktop/src/main.rs similarity index 71% rename from crates/openpencil-shell-native/examples/inspector_window.rs rename to crates/openpencil-desktop/src/main.rs index e351cacc9..3d845d5c9 100644 --- a/crates/openpencil-shell-native/examples/inspector_window.rs +++ b/crates/openpencil-desktop/src/main.rs @@ -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, ctx: Option, backend: Option, 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, } -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); } } diff --git a/crates/openpencil-shell-core/src/widgets/top_bar.rs b/crates/openpencil-shell-core/src/widgets/top_bar.rs index 556c0df91..ef8f16bd7 100644 --- a/crates/openpencil-shell-core/src/widgets/top_bar.rs +++ b/crates/openpencil-shell-core/src/widgets/top_bar.rs @@ -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]