From 96eb01ef499be1bd5ba4792a1ae2191ad42bdf2d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sun, 11 Oct 2026 12:24:47 -0700 Subject: [PATCH] feat: preserve editor state across Android canvas surfaces Expose ANativeWindow creation, detach and reattach without replacing the page editor. Retain native references through surface destruction and validate surface sizes and renderer format on reuse. Refs #24 Assisted-by: gpt-6.1-sol --- crates/mobile/include/snowbound.h | 12 ++ crates/mobile/src/lib.rs | 228 ++++++++++++++++++++++++------ crates/mobile/src/tests.rs | 86 +++++++++++ 3 files changed, 282 insertions(+), 44 deletions(-) diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 93d74073019d0304ac0aabbfd7c9f63677313dde..8e1267e697c4aa4816b6dcacb6ec13547907e68d 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -71,8 +71,20 @@ void sb_section_wake(const Section *section); void sb_section_set_author(const Section *section, const char *author); void sb_section_free(Section *section); +#if defined(__ANDROID__) +struct ANativeWindow; +// Acquires its own window reference; caller retains ownership of its reference. +View *sb_view_new_android(struct ANativeWindow *window, const Section *section, const char *id, float width, float height, float scale); +// Detach before SurfaceDestroyed returns; editor, selection and Undo remain alive. +void sb_view_detach_android(View *view); +// Attach at the last size/scale, then resize on SurfaceChanged; detach an attached view first. +bool sb_view_attach_android(View *view, struct ANativeWindow *window); +#else View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale); +#endif +// Serialize all view calls; free only when the editor is no longer needed. void sb_view_free(View *view); +// Dimensions are host points; scale is pixels per point. Invalid sizes leave the view unchanged. void sb_view_resize(View *view, float width, float height, float scale); bool sb_view_render(View *view); bool sb_view_frame_pending(const View *view); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index d440cc27bdb41c01e27b8e7813f709c89bd45e7d..344f4195d1d832d81f63a6008f01e6c45b808d2a 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -1,5 +1,5 @@ //! A canvas page behind a C surface for native mobile shells: notebooks and their sections -//! (`library`), one page drawn into a `CAMetalLayer`, and the active outline's text as the +//! (`library`), one page drawn into a native window, and the active outline's text as the //! flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). Every //! edit a view takes is stored at once through its section. //! @@ -616,9 +616,10 @@ struct Gpu { instance: wgpu::Instance, adapter: wgpu::Adapter, renderer: draw::Renderer, + format: wgpu::TextureFormat, } -/// Made with the first view's surface, whose adapter every later layer can use. +/// Made with the first view's surface and reused when the host recreates its window. static GPU: Mutex> = Mutex::new(None); fn gpu() -> std::sync::MutexGuard<'static, Option> { @@ -677,37 +678,74 @@ pub extern "C" fn sb_set_markdown_shortcuts(on: bool) { options().2 = on; } -pub struct View { - /// Taken apart on drop, which returns its text engine to `ENGINES`. - canvas: ManuallyDrop, +#[cfg(target_os = "android")] +#[link(name = "android")] +unsafe extern "C" { + fn ANativeWindow_acquire(window: *mut c_void); + fn ANativeWindow_release(window: *mut c_void); +} + +#[cfg(target_os = "android")] +struct NativeWindow(std::ptr::NonNull); + +#[cfg(target_os = "android")] +impl Drop for NativeWindow { + fn drop(&mut self) { + // SAFETY: this guard owns one reference acquired by `Presentation::new`. + unsafe { ANativeWindow_release(self.0.as_ptr()) }; + } +} + +fn view_pixels(size: [f32; 2], scale: f32, limit: u32) -> Result<[u32; 2]> { + if !scale.is_finite() + || scale <= 0.0 + || size.iter().any(|side| !side.is_finite() || *side <= 0.0) + { + return Err("Canvas dimensions and scale must be finite and positive".into()); + } + let pixels = size.map(|side| (side * scale).round().max(1.0)); + if pixels + .iter() + .any(|side| f64::from(*side) > f64::from(limit)) + { + return Err("Canvas dimensions exceed the GPU texture limit".into()); + } + Ok(pixels.map(|side| side as u32)) +} + +struct Presentation { surface: wgpu::Surface<'static>, + /// Field order keeps the acquired window alive until the surface has dropped. + #[cfg(target_os = "android")] + _window: NativeWindow, config: wgpu::SurfaceConfiguration, - frame: Arc, - section: Arc, - /// A conflict page, which shows what is stored and takes no edits. - read_only: bool, - /// The column, in page points, a reading view reflows the page into; it takes no edits. - reading: Option, } -impl View { - fn new( - layer: *mut c_void, - section: &Section, - space: ExGuid, - size: [f32; 2], - scale: f32, - ) -> Result { - let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); +impl Presentation { + /// # Safety + /// `window` is null or a live platform window; on Apple it outlives the presentation. + unsafe fn new(window: *mut c_void, pixels: [u32; 2]) -> Result { + let window = std::ptr::NonNull::new(window).ok_or("No native window")?; + #[cfg(target_os = "android")] + let window = { + // SAFETY: the caller lends a live ANativeWindow for this call. + unsafe { ANativeWindow_acquire(window.as_ptr()) }; + NativeWindow(window) + }; + #[cfg(target_os = "android")] + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(wgpu::rwh::AndroidDisplayHandle::new().into()), + raw_window_handle: wgpu::rwh::AndroidNdkWindowHandle::new(window.0).into(), + }; + #[cfg(not(target_os = "android"))] + let target = wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(window.as_ptr()); let mut gpu = gpu(); let instance = match &*gpu { Some(gpu) => gpu.instance.clone(), None => wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()), }; - // SAFETY: the host passes a live CAMetalLayer that outlives the view. - let surface = unsafe { - instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) - }?; + // SAFETY: Android retains its window; the Apple caller keeps its layer alive. + let surface = unsafe { instance.create_surface_unsafe(target) }?; if gpu.is_none() { let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { @@ -725,19 +763,71 @@ impl View { .ok_or("No supported surface configuration")? .format; if let Ok(mut renderer) = crash_report::RENDERER.lock() { - *renderer = format!("Metal ({})", adapter.get_info().name); + let info = adapter.get_info(); + *renderer = format!("{:?} ({})", info.backend, info.name); } *gpu = Some(Gpu { instance, renderer: draw::Renderer::new(device, queue, format), adapter, + format, }); } let gpu = gpu.as_ref().ok_or("No GPU")?; - let config = surface + if pixels + .iter() + .any(|side| *side > gpu.renderer.device().limits().max_texture_dimension_2d) + { + return Err("Canvas dimensions exceed the GPU texture limit".into()); + } + if !surface + .get_capabilities(&gpu.adapter) + .formats + .contains(&gpu.format) + { + return Err( + "The native window does not support the shared renderer's texture format".into(), + ); + } + let mut config = surface .get_default_config(&gpu.adapter, pixels[0], pixels[1]) .ok_or("No supported surface configuration")?; + config.format = gpu.format; surface.configure(gpu.renderer.device(), &config); + Ok(Self { + surface, + #[cfg(target_os = "android")] + _window: window, + config, + }) + } +} + +pub struct View { + /// Taken apart on drop, which returns its text engine to `ENGINES`. + canvas: ManuallyDrop, + presentation: Option, + frame: Arc, + section: Arc, + /// A conflict page, which shows what is stored and takes no edits. + read_only: bool, + /// The column, in page points, a reading view reflows the page into; it takes no edits. + reading: Option, +} + +impl View { + /// # Safety + /// `window` is null or a live platform window; on Apple it outlives the returned view. + unsafe fn new( + window: *mut c_void, + section: &Section, + space: ExGuid, + size: [f32; 2], + scale: f32, + ) -> Result { + let pixels = view_pixels(size, scale, u32::MAX)?; + // SAFETY: the caller supplies the platform window's lifetime contract. + let presentation = unsafe { Presentation::new(window, pixels) }?; let (mut page, read_only) = section.shared.page(space)?; // The page wears its theme: style objects it gives otherwise are restyled first. let sheet = section.sheet(page.identity); @@ -758,8 +848,7 @@ impl View { canvas.page.editor.styles = sheet; Ok(Self { canvas: ManuallyDrop::new(canvas), - surface, - config, + presentation: Some(presentation), frame, section: Arc::clone(§ion.shared), read_only, @@ -768,13 +857,18 @@ impl View { } fn render(&mut self) -> Result<()> { + let Some(presentation) = &mut self.presentation else { + return Ok(()); + }; let mut gpu = gpu(); let renderer = &mut gpu.as_mut().ok_or("No GPU")?.renderer; - let frame = match self.surface.get_current_texture() { + let frame = match presentation.surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(frame) | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { - self.surface.configure(renderer.device(), &self.config); + presentation + .surface + .configure(renderer.device(), &presentation.config); return Ok(()); } _ => return Ok(()), @@ -785,10 +879,13 @@ impl View { .page .update_pictures(paper, &Waker::from(self.frame.clone())); let primitives = canvas.page.primitives(canvas.colors())?; + let target = frame.texture.create_view(&Default::default()); + #[cfg(target_os = "macos")] + let target = target.into(); renderer .draw( - &frame.texture.create_view(&Default::default()).into(), - [self.config.width, self.config.height], + &target, + [presentation.config.width, presentation.config.height], paper.color, &[canvas.page.viewport.layer(&primitives)], ) @@ -853,7 +950,7 @@ impl View { }; pool_engine(engine); self.reading = column.filter(|_| reflowed.is_some()); - let pixels = [self.config.width, self.config.height]; + let pixels = self.canvas.page.viewport.size; let mut canvas = Canvas::new( self.canvas.space, reflowed.unwrap_or(page), @@ -899,14 +996,18 @@ fn owned(text: String) -> *mut c_char { CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw) } -/// A view drawing page `id` of `section` into `layer`, a `CAMetalLayer`, `size` points at -/// `scale` pixels per point; null if it cannot. +/// A view drawing page `id` of `section`, `width` by `height` host points at `scale` +/// pixels per point; null if it cannot. Android exports this as `sb_view_new_android`. /// /// # Safety -/// `layer` is a live `CAMetalLayer` that outlives the view; `id` is NUL-terminated UTF-8. -#[unsafe(no_mangle)] +/// `window` is null or a live `ANativeWindow` on Android, or a `CAMetalLayer` that outlives the +/// view on Apple. Android acquires its own reference without consuming the caller's. +/// The host serializes view calls and detaches Android views before destroying their surfaces. +/// `id` is NUL-terminated UTF-8. +#[cfg_attr(target_os = "android", unsafe(export_name = "sb_view_new_android"))] +#[cfg_attr(not(target_os = "android"), unsafe(no_mangle))] pub unsafe extern "C" fn sb_view_new( - layer: *mut c_void, + window: *mut c_void, section: &Section, id: *const c_char, width: f32, @@ -917,11 +1018,40 @@ pub unsafe extern "C" fn sb_view_new( string(id) .parse() .map_err(Into::into) - .and_then(|space| View::new(layer, section, space, [width, height], scale)), + .and_then(|space| unsafe { View::new(window, section, space, [width, height], scale) }), ) .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view))) } +/// Stops presentation without changing the editor, selection or Undo history. +#[cfg(any(target_os = "android", test))] +#[cfg_attr(target_os = "android", unsafe(no_mangle))] +pub extern "C" fn sb_view_detach_android(view: &mut View) { + view.presentation = None; +} + +/// Attaches a replacement window at the last size and scale; resize after SurfaceChanged. +/// Failure leaves a detached editor unchanged; an attached view must be detached first. +/// +/// # Safety +/// `window` is null or a live ANativeWindow. The view acquires its own reference. +/// The host serializes view calls and detaches before SurfaceDestroyed returns. +#[cfg(any(target_os = "android", test))] +#[cfg_attr(target_os = "android", unsafe(no_mangle))] +pub unsafe extern "C" fn sb_view_attach_android(view: &mut View, window: *mut c_void) -> bool { + report(if view.presentation.is_some() { + Err("Detach the current surface before attaching another".into()) + } else { + // SAFETY: the caller supplies the platform window's lifetime contract. + unsafe { Presentation::new(window, view.canvas.page.viewport.size) } + }) + .map(|presentation| { + view.presentation = Some(presentation); + view.frame.0.store(true, Ordering::Release); + }) + .is_some() +} + /// # Safety /// `view` came from `sb_view_new` and is not used again. #[unsafe(no_mangle)] @@ -931,17 +1061,27 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) { #[unsafe(no_mangle)] pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { - let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); + let gpu = gpu(); + let Some(gpu) = gpu.as_ref() else { return }; + let Some(pixels) = report(view_pixels( + [width, height], + scale, + gpu.renderer.device().limits().max_texture_dimension_2d, + )) else { + return; + }; let canvas = &mut view.canvas; if scale != canvas.scale { canvas.scale = scale; let _ = report(canvas.page.scale_factor_changed(scale)); } let _ = report(canvas.page.resized(pixels)); - view.config.width = pixels[0]; - view.config.height = pixels[1]; - if let Some(gpu) = &*gpu() { - view.surface.configure(gpu.renderer.device(), &view.config); + if let Some(presentation) = &mut view.presentation { + presentation.config.width = pixels[0]; + presentation.config.height = pixels[1]; + presentation + .surface + .configure(gpu.renderer.device(), &presentation.config); } } diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index ee37d4e3ec083315af32f37e77da0f3d6334b60f..d61e8bc604131a64ab6833118d124fe0a03166d8 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -1522,3 +1522,89 @@ fn sections_reorder_and_move_between_groups_and_back() { assert!(root.join("Empty.one").exists()); open(&library, "Empty.one"); } + +#[test] +fn dimensions_round_in_pixels_and_keep_subpixel_windows_nonempty() { + assert_eq!( + view_pixels([320.25, 480.75], 2.0, 8192).unwrap(), + [641, 962] + ); + assert_eq!(view_pixels([0.1, 0.2], 0.5, 8192).unwrap(), [1, 1]); + assert_eq!(view_pixels([8192.0, 1.0], 1.0, 8192).unwrap(), [8192, 1]); +} + +#[test] +fn dimensions_reject_nonpositive_and_nonfinite_inputs() { + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert!(view_pixels([bad, 1.0], 1.0, 8192).is_err()); + assert!(view_pixels([1.0, bad], 1.0, 8192).is_err()); + assert!(view_pixels([1.0, 1.0], bad, 8192).is_err()); + } +} + +#[test] +fn dimensions_reject_overflow_and_the_device_texture_limit() { + assert!(view_pixels([f32::MAX, 1.0], f32::MAX, u32::MAX).is_err()); + assert!(view_pixels([u32::MAX as f32, 1.0], 1.0, u32::MAX).is_err()); + assert!(view_pixels([8193.0, 1.0], 1.0, 8192).is_err()); + assert!(view_pixels([1.0, 4097.0], 2.0, 8192).is_err()); +} + +#[test] +fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() { + let (_directory, section) = features(); + let space = section.shared.section.pages().unwrap()[0].0; + let id = CString::new(space.to_string()).unwrap(); + assert!( + unsafe { + sb_view_new( + std::ptr::null_mut(), + §ion, + id.as_ptr(), + 320.0, + 480.0, + 2.0, + ) + } + .is_null() + ); + let (page, read_only) = section.shared.page(space).unwrap(); + let mut view = View { + canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()), + presentation: None, + frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, + reading: None, + }; + assert!(sb_view_focus_title(&mut view)); + assert!(unsafe { sb_insert(&mut view, c" retained after surface loss".as_ptr()) }); + let text = view.canvas.active().shown_text(); + let history = view.canvas.page.editor.history_depth(); + let mut selection = [0; 2]; + sb_selection(&view, &mut selection); + assert!(sb_view_can_undo(&view, false)); + + sb_view_detach_android(&mut view); + sb_view_detach_android(&mut view); + assert!(view.render().is_ok()); + assert!(!unsafe { sb_view_attach_android(&mut view, std::ptr::null_mut()) }); + let mut after = [0; 2]; + sb_selection(&view, &mut after); + assert!(view.presentation.is_none()); + assert_eq!(view.canvas.page.viewport.size, [640, 960]); + assert_eq!(view.canvas.scale, 2.0); + assert_eq!(after, selection); + assert_eq!(view.canvas.active().shown_text(), text); + assert_eq!(view.canvas.page.editor.history_depth(), history); + assert!(sb_view_undo(&mut view, false)); + assert!( + !view + .canvas + .active() + .shown_text() + .contains("retained after surface loss") + ); + assert!(sb_view_undo(&mut view, true)); + assert_eq!(view.canvas.active().shown_text(), text); +} -- 2.54.0