openpencil/crates/openpencil-shell-native/examples/inspector_window.rs
Kayshen-X 987ce82a24 feat(shell-native): WidgetHostNative + inspector_window — cross-platform proof
Lands the shell-native consumer of shell-core's Step 1b widget
module so spec §1.4 is concrete: same widget code, same paint
output on macOS / Linux / Windows desktop AND
wasm32-unknown-unknown browsers. User priority for this commit
("主要是native 端") + the parallel Phase D web work.

What's added:
- `crates/openpencil-shell-native/src/widget_host.rs` (~155 LOC):
  * `NativeFrameBackend<'a>` — frame-scoped wrapper holding
    `(&mut NativeBackend, &skia_safe::Canvas)`, impls
    shell-core's `RenderBackend` by forwarding to the existing
    `NativeBackend::{fill_rect, stroke_rect, draw_text,
    clip_rect, save, restore, translate}` methods (each takes
    the canvas as a separate arg in the existing API).
    `begin_frame`/`end_frame` no-op because `SharedSkiaContext::
    with_frame` owns those bracket points; `resize` no-op because
    surface resize lives on `SharedSkiaContext::resize`. Spec
    §5.2.1 explicitly deferred this RenderBackend impl to Step
    1c+ widget tree work — this is that landing site.
  * `WidgetHostNative` — owns one of each B1/B2 widget
    (TreeWidget::sample, PropertyRow::new(200, "Width", "960"),
    Dropdown::sample, TextInput::sample). `paint(&self, frame,
    available_width)` mirrors shell-web's `WidgetHost::paint`
    exactly (16/12 px gaps, 280 px column) so the visual layout
    is identical between platforms — Phase E manual smoke
    acceptance criterion.
  * `// glue:` markers for the (future) cross-crate widget-
    boundary gate.

- `crates/openpencil-shell-native/examples/inspector_window.rs`
  (~150 LOC) — winit + SharedSkiaContext + NativeBackend +
  WidgetHostNative end-to-end. Same shape as `basic_window.rs`
  but the per-frame paint dispatches to `WidgetHostNative`
  instead of hard-coded chrome. cfg-gated to desktop OS; CI
  verifies `cargo build --examples` only.

- `crates/openpencil-shell-native/src/lib.rs` — adds `pub mod
  widget_host;` cfg-gated to desktop OS (matches the existing
  `backend` / `canvas_view_stub` gating per spec §11). Re-exports
  `NativeFrameBackend` + `WidgetHostNative` at the crate root.

Mobile (iOS / Android) considered (per 2026-05-10 user directive
"安卓和ios 不需要 ipc / 本地 cli — 只需要 custom provider"):
- The widget glue is platform-agnostic in shape — no winit /
  glutin / EGL / desktop-only types leak in. `NativeFrameBackend`
  only borrows `NativeBackend` + `&skia_safe::Canvas`;
  `WidgetHostNative` only consumes shell-core widgets + the
  `RenderBackend` trait. Both compile on any target where
  `NativeBackend` compiles.
- Today the desktop-only cfg on `widget_host` mirrors the
  desktop-only cfg on `backend` (per spec §11 invariants 1 & 3:
  mobile widget rendering lands in Step 1f). When Step 1f ships
  real `EaglProvider` (iOS) / `AndroidEglProvider` (Android)
  impls and lifts the desktop cfg, `WidgetHostNative` follows
  automatically — no rewrite, no IPC / CLI infrastructure.
- Doc comment in `widget_host.rs` + `inspector_window.rs`
  explicitly documents this Step 1f path.
- Verified both iOS (`aarch64-apple-ios`) and Android
  (`aarch64-linux-android`) cargo check still green with
  shell-native's mobile compile guard in place.

Verification:
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green (desktop)
- `cargo check -p openpencil-shell-native` — green (no
  regression on Step 1a basic_window)
- `cargo check -p openpencil-shell-native --target
  aarch64-apple-ios` — green (mobile compile guard intact)
- `cargo check -p openpencil-shell-native --target
  aarch64-linux-android` — green (mobile compile guard intact)
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays
  wasm32-clean per spec §1.2)
- `cargo test -p openpencil-shell-core --test widgets_static` —
  21/21 (no widget changes)
- `bash tools/check-wasm-bundle.sh` — PASS (web bundle still 0
  env.* / 622 KiB gzip / 59% ceiling — no regression)
- `bash tools/check-widget-boundary.sh` — PASS
- `bash tools/check-jian-boundaries.sh` — 4/4 invariants PASS

Phase D (web DOM mirror + native accesskit_winit integration)
follows.
2026-05-10 10:11:01 +08:00

199 lines
7 KiB
Rust

//! 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<Window>,
ctx: Option<SharedSkiaContext>,
backend: Option<NativeBackend>,
host: WidgetHostNative,
error: Option<SharedSkiaError>,
}
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);
}
}