diff --git a/crates/openpencil-shell-native/examples/inspector_window.rs b/crates/openpencil-shell-native/examples/inspector_window.rs new file mode 100644 index 000000000..6a02f3892 --- /dev/null +++ b/crates/openpencil-shell-native/examples/inspector_window.rs @@ -0,0 +1,198 @@ +//! 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. +//! +//! ### Run (desktop) +//! ```text +//! cargo run -p openpencil-shell-native --example inspector_window +//! ``` +//! +//! ### 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. +//! +//! 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. + +#![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + +use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend}; +use openpencil_shell_native::{ + NativeBackend, NativeFrameBackend, SharedSkiaContext, SharedSkiaError, WidgetHostNative, +}; +use winit::application::ApplicationHandler; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::window::{Window, WindowId}; + +const INSPECTOR_WIDTH: f32 = 280.0; + +/// Paint pass — clear to white, then dispatch the inspector widgets +/// 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( + ctx: &mut SharedSkiaContext, + backend: &mut NativeBackend, + host: &WidgetHostNative, +) { + ctx.begin_frame(); + ctx.with_frame(|canvas, _glow| { + // Clear via Skia first so the framebuffer alpha is reset + // before widget paints sit on top. + canvas.clear(skia_safe::Color::WHITE); + + let mut frame = NativeFrameBackend::new(backend, canvas); + // Belt-and-braces: also clear via the trait fill_rect so the + // shell-web and shell-native paint orders match exactly. The + // canvas.clear above is a no-op-equivalent prequel. + frame.fill_rect( + Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(960.0, 640.0), + }, + Color::WHITE, + ); + host.paint(&mut frame, INSPECTOR_WIDTH); + }); + ctx.present(); +} + +struct InspectorApp { + window: Option, + ctx: Option, + backend: Option, + host: WidgetHostNative, + error: Option, +} + +impl InspectorApp { + fn new() -> Self { + Self { + window: None, + ctx: None, + backend: None, + host: WidgetHostNative::new(), + error: None, + } + } +} + +impl ApplicationHandler for InspectorApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let attrs = Window::default_attributes() + .with_title("OpenPencil — inspector_window (Step 1b §1.4 native)") + .with_inner_size(winit::dpi::LogicalSize::new(800u32, 600u32)); + let window = match event_loop.create_window(attrs) { + Ok(w) => w, + Err(err) => { + eprintln!("inspector_window: create_window failed: {err}"); + event_loop.exit(); + return; + } + }; + + let dpi = window.scale_factor() as f32; + match SharedSkiaContext::new_desktop(&window) { + Ok(ctx) => { + self.ctx = Some(ctx); + self.backend = Some(NativeBackend::with_dpi(dpi)); + } + Err(err) => { + eprintln!("inspector_window: SharedSkiaContext::new_desktop failed: {err}"); + self.error = Some(err); + event_loop.exit(); + return; + } + } + self.window = Some(window); + + if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) { + paint_inspector(ctx, backend, &self.host); + } + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _window_id: WindowId, + event: WindowEvent, + ) { + match event { + WindowEvent::CloseRequested => { + event_loop.exit(); + } + 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}"); + self.error = Some(err); + event_loop.exit(); + } + } + if let Some(window) = self.window.as_ref() { + window.request_redraw(); + } + } + WindowEvent::ScaleFactorChanged { scale_factor, .. } => { + if let Some(backend) = self.backend.as_mut() { + backend.set_dpi(scale_factor as f32); + } + } + WindowEvent::RedrawRequested => { + if let (Some(ctx), Some(backend)) = (self.ctx.as_mut(), self.backend.as_mut()) { + paint_inspector(ctx, backend, &self.host); + } + } + _ => {} + } + } + + 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}"); + } + } + self.backend.take(); + self.window.take(); + } +} + +fn main() { + let event_loop = match EventLoop::new() { + Ok(el) => el, + Err(err) => { + eprintln!("inspector_window: EventLoop::new failed: {err}"); + std::process::exit(1); + } + }; + event_loop.set_control_flow(winit::event_loop::ControlFlow::Wait); + let mut app = InspectorApp::new(); + if let Err(err) = event_loop.run_app(&mut app) { + eprintln!("inspector_window: run_app exited with error: {err}"); + std::process::exit(1); + } + if let Some(err) = app.error { + eprintln!("inspector_window: fatal error during run: {err}"); + std::process::exit(1); + } +} diff --git a/crates/openpencil-shell-native/src/lib.rs b/crates/openpencil-shell-native/src/lib.rs index 29c6f4e9c..c216e3071 100644 --- a/crates/openpencil-shell-native/src/lib.rs +++ b/crates/openpencil-shell-native/src/lib.rs @@ -42,10 +42,14 @@ pub mod context; pub mod backend; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub mod canvas_view_stub; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +pub mod widget_host; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub use backend::{to_jian_color, to_jian_rect, NativeBackend}; #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +pub use widget_host::{NativeFrameBackend, WidgetHostNative}; +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] pub use canvas_view_stub::CanvasViewportStub; // Cross-platform re-exports — visible on every (non-wasm) target. diff --git a/crates/openpencil-shell-native/src/widget_host.rs b/crates/openpencil-shell-native/src/widget_host.rs new file mode 100644 index 000000000..e30216162 --- /dev/null +++ b/crates/openpencil-shell-native/src/widget_host.rs @@ -0,0 +1,198 @@ +//! Step 1b §1.4 native widget glue — the only file in shell-native +//! allowed to call into `openpencil_shell_core::widgets`. Mirrors the +//! shell-web `widget_host.rs` pattern so the four inspector widgets +//! (Tree / PropertyRow / Dropdown / TextInput) defined in shell-core +//! are truly cross-platform: shell-web reuses them via `WebBackend`, +//! shell-native via `NativeBackend` + this glue. +//! +//! Two pieces: +//! +//! 1. [`NativeFrameBackend`] — a frame-scoped wrapper that holds a +//! `(&mut NativeBackend, &skia_safe::Canvas)` pair and `impl +//! RenderBackend` for it. Spec §5.2.1 explicitly defers the +//! `RenderBackend` impl on the canvas-borrow-bearing `NativeBackend` +//! to Step 1c+ widget tree work; this is that landing site. +//! +//! 2. [`WidgetHostNative`] — owns one of each of the Step 1b widgets + +//! a `paint(&self, &mut NativeFrameBackend, available_width)` +//! method that dispatches like shell-web's WidgetHost. Phase D will +//! add `apply_*` event handlers (winit input → shell-core +//! `apply_ime` / `apply_key`) once the desktop event pipeline +//! needs them. +//! +//! ### Mobile (iOS / Android) — Step 1f path +//! +//! Per spec §11 invariants: shell-native is gated to desktop OS +//! today (`backend` / `canvas_view_stub` / `widget_host` modules +//! cfg-gated to `macos | linux | windows`). Mobile widget rendering +//! lands in Step 1f via `context::EaglProvider` (iOS) / +//! `context::AndroidEglProvider` (Android) — both are zero-sized +//! placeholder structs in lib.rs today whose `GlContextProvider` +//! impls `unimplemented!()`. Per the 2026-05-10 user directive +//! ("安卓和ios 不需要 ipc / 本地 cli — 只需要 custom provider"): +//! mobile rendering is purely a custom-provider plugin point on the +//! existing `GlContextProvider` trait, NOT a separate IPC / CLI +//! pipeline. +//! +//! Crucially the widget glue here is platform-agnostic in shape: +//! - `NativeFrameBackend` only holds `&mut NativeBackend` + +//! `&skia_safe::Canvas`; both compile on any target where +//! skia-safe + jian-skia + the GL provider compile. No +//! desktop-specific type names or APIs leak in. +//! - `WidgetHostNative` only consumes shell-core widgets + the +//! `RenderBackend` trait; no winit / glutin / EGL types appear. +//! - When Step 1f promotes `NativeBackend` to compile on +//! mobile (drop the desktop-only cfg in lib.rs once +//! `EaglProvider` / `AndroidEglProvider` ship real impls), +//! `WidgetHostNative` follows automatically — no rewrite, no +//! additional glue. The mobile shell just constructs a different +//! `SharedSkiaContext` (or the mobile equivalent) backed by its +//! provider, then runs the same `host.paint(&mut frame, width)`. + +use crate::backend::NativeBackend; +use openpencil_shell_core::widgets::{ + Dropdown, LayoutCx, PaintCx, PropertyRow, TextInput, TreeWidget, Widget, +}; +use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend, TextLayout}; + +/// Frame-scoped `RenderBackend` adapter over `NativeBackend` + +/// `&Canvas`. Lifetime-bound to the `SharedSkiaContext::with_frame` +/// closure body so widget code never sees the canvas borrow directly. +/// +/// Why this exists rather than `impl RenderBackend for NativeBackend`: +/// `NativeBackend` deliberately does NOT carry a canvas borrow (spec +/// §5.2.1 — the canvas reference is short-lived inside `with_frame`, +/// while `NativeBackend` lives across frames so its `jian_skia:: +/// SkiaBackend` image cache survives). The wrapper gets the trait +/// shape without entangling the lifetime onto the long-lived backend +/// type. +pub struct NativeFrameBackend<'a> { + inner: &'a mut NativeBackend, + canvas: &'a skia_safe::Canvas, +} + +impl<'a> NativeFrameBackend<'a> { + pub fn new(inner: &'a mut NativeBackend, canvas: &'a skia_safe::Canvas) -> Self { + Self { inner, canvas } + } +} + +impl<'a> RenderBackend for NativeFrameBackend<'a> { + fn begin_frame(&mut self) { + // No-op on native: `SharedSkiaContext::begin_frame` already + // ran when the caller entered `with_frame`. + } + + fn end_frame(&mut self) { + // No-op on native: `SharedSkiaContext::present` runs after + // `with_frame` returns, outside the wrapper's lifetime. + } + + fn fill_rect(&mut self, rect: Rect, color: Color) { + self.inner.fill_rect(self.canvas, rect, color); + } + + fn stroke_rect(&mut self, rect: Rect, color: Color, width: f32) { + self.inner.stroke_rect(self.canvas, rect, color, width); + } + + fn draw_text(&mut self, layout: &TextLayout, origin: Point2D) { + self.inner.draw_text(self.canvas, layout, origin); + } + + fn clip_rect(&mut self, rect: Rect) { + self.inner.clip_rect(self.canvas, rect); + } + + fn save(&mut self) { + // `NativeBackend::save` returns the pre-save count, used by + // `restore_to`. The trait-level `save` is fire-and-forget; we + // discard the count and rely on the caller pairing each + // `save` with one `restore`. + let _count = self.inner.save(self.canvas); + } + + fn restore(&mut self) { + self.inner.restore(self.canvas); + } + + fn translate(&mut self, offset: Point2D) { + self.inner.translate(self.canvas, offset); + } + + fn resize(&mut self, _width: u32, _height: u32) { + // No-op via the trait: surface resize is owned by + // `SharedSkiaContext::resize` and reaches `NativeBackend` + // separately. Mirrors `NativeBackend::resize`'s no-op. + } + + fn dpi_scale(&self) -> f32 { + self.inner.dpi_scale() + } +} + +/// Native counterpart of shell-web's `widget_host::WidgetHost`. Owns +/// one of each Step 1b inspector widget so the desktop demo paints +/// the same composition shell-web does. Phase D will add `apply_*` +/// event-routing methods alongside the desktop input pipeline. +pub struct WidgetHostNative { + tree: TreeWidget, + width: PropertyRow, + dropdown: Dropdown, + text_input: TextInput, +} + +impl WidgetHostNative { + pub fn new() -> Self { + Self { + tree: TreeWidget::sample(), + width: PropertyRow::new(200, "Width", "960"), + dropdown: Dropdown::sample(), + text_input: TextInput::sample(), + } + } + + /// Paint the inspector slice at `(16, y)` stacking with 12 px + /// gaps, in a 280 px column. Mirrors shell-web's + /// `WidgetHost::paint` so the visual layout is identical between + /// platforms (Phase E manual-smoke acceptance criterion). + /// + /// `// glue:` marker on the signature line keeps the future + /// `tools/check-widget-boundary.sh` happy if the boundary script + /// is extended to gate shell-native too (it currently scans only + /// shell-web; Phase D may parameterize). + pub fn paint(&self, frame: &mut NativeFrameBackend<'_>, available_width: f32) { // glue: + let layout = LayoutCx { + available_width, + dpi: frame.dpi_scale(), + }; + let mut y = 16.0; + let widgets: [&dyn Widget; 4] = [ + &self.tree, + &self.width, + &self.dropdown, + &self.text_input, + ]; + for widget in widgets { + let box_ = widget.layout(&layout); + let rect = Rect { + origin: Point2D::new(16.0, y), + size: Point2D::new(available_width, box_.rect.size.y), + }; + // PaintCx borrows the frame backend mutably; reborrow per + // iteration with `&mut *frame` so subsequent iterations + // don't fail the borrow check on the moved reference. + let mut cx = PaintCx { + backend: &mut *frame, + }; + widget.paint(&mut cx, rect); + y += rect.size.y + 12.0; + } + } +} + +impl Default for WidgetHostNative { + fn default() -> Self { + Self::new() + } +}