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); +}