authorclover caruso <git@paperclover.net> 2026-10-11 12:24:47-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 12:53:51-07:00
log96eb01ef499be1bd5ba4792a1ae2191ad42bdf2d
tree39c5533ec7697ef60b986b384b746a31bde04b67
parent2b4c678b0cf76c937249714ed7a461c613d34dc3
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

3 files changed, 282 insertions(+), 44 deletions(-)

crates/mobile/include/snowbound.h+12
......@@ -71,8 +71,20 @@ void sb_section_wake(const Section *section);
7171void sb_section_set_author(const Section *section, const char *author);
7272void sb_section_free(Section *section);
7373
74#if defined(__ANDROID__)
75struct ANativeWindow;
76// Acquires its own window reference; caller retains ownership of its reference.
77View *sb_view_new_android(struct ANativeWindow *window, const Section *section, const char *id, float width, float height, float scale);
78// Detach before SurfaceDestroyed returns; editor, selection and Undo remain alive.
79void sb_view_detach_android(View *view);
80// Attach at the last size/scale, then resize on SurfaceChanged; detach an attached view first.
81bool sb_view_attach_android(View *view, struct ANativeWindow *window);
82#else
7483View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale);
84#endif
85// Serialize all view calls; free only when the editor is no longer needed.
7586void sb_view_free(View *view);
87// Dimensions are host points; scale is pixels per point. Invalid sizes leave the view unchanged.
7688void sb_view_resize(View *view, float width, float height, float scale);
7789bool sb_view_render(View *view);
7890bool sb_view_frame_pending(const View *view);
crates/mobile/src/lib.rs+184-44
......@@ -1,5 +1,5 @@
11//! A canvas page behind a C surface for native mobile shells: notebooks and their sections
2//! (`library`), one page drawn into a `CAMetalLayer`, and the active outline's text as the
2//! (`library`), one page drawn into a native window, and the active outline's text as the
33//! flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). Every
44//! edit a view takes is stored at once through its section.
55//!
......@@ -616,9 +616,10 @@ struct Gpu {
616616 instance: wgpu::Instance,
617617 adapter: wgpu::Adapter,
618618 renderer: draw::Renderer,
619 format: wgpu::TextureFormat,
619620}
620621
621/// Made with the first view's surface, whose adapter every later layer can use.
622/// Made with the first view's surface and reused when the host recreates its window.
622623static GPU: Mutex<Option<Gpu>> = Mutex::new(None);
623624
624625fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {
......@@ -677,37 +678,74 @@ pub extern "C" fn sb_set_markdown_shortcuts(on: bool) {
677678 options().2 = on;
678679}
679680
680pub struct View {
681 /// Taken apart on drop, which returns its text engine to `ENGINES`.
682 canvas: ManuallyDrop<Canvas>,
681#[cfg(target_os = "android")]
682#[link(name = "android")]
683unsafe extern "C" {
684 fn ANativeWindow_acquire(window: *mut c_void);
685 fn ANativeWindow_release(window: *mut c_void);
686}
687
688#[cfg(target_os = "android")]
689struct NativeWindow(std::ptr::NonNull<c_void>);
690
691#[cfg(target_os = "android")]
692impl Drop for NativeWindow {
693 fn drop(&mut self) {
694 // SAFETY: this guard owns one reference acquired by `Presentation::new`.
695 unsafe { ANativeWindow_release(self.0.as_ptr()) };
696 }
697}
698
699fn view_pixels(size: [f32; 2], scale: f32, limit: u32) -> Result<[u32; 2]> {
700 if !scale.is_finite()
701 || scale <= 0.0
702 || size.iter().any(|side| !side.is_finite() || *side <= 0.0)
703 {
704 return Err("Canvas dimensions and scale must be finite and positive".into());
705 }
706 let pixels = size.map(|side| (side * scale).round().max(1.0));
707 if pixels
708 .iter()
709 .any(|side| f64::from(*side) > f64::from(limit))
710 {
711 return Err("Canvas dimensions exceed the GPU texture limit".into());
712 }
713 Ok(pixels.map(|side| side as u32))
714}
715
716struct Presentation {
683717 surface: wgpu::Surface<'static>,
718 /// Field order keeps the acquired window alive until the surface has dropped.
719 #[cfg(target_os = "android")]
720 _window: NativeWindow,
684721 config: wgpu::SurfaceConfiguration,
685 frame: Arc<Frame>,
686 section: Arc<Shared>,
687 /// A conflict page, which shows what is stored and takes no edits.
688 read_only: bool,
689 /// The column, in page points, a reading view reflows the page into; it takes no edits.
690 reading: Option<f32>,
691722}
692723
693impl View {
694 fn new(
695 layer: *mut c_void,
696 section: &Section,
697 space: ExGuid,
698 size: [f32; 2],
699 scale: f32,
700 ) -> Result<Self> {
701 let pixels = size.map(|side| (side * scale).round().max(1.0) as u32);
724impl Presentation {
725 /// # Safety
726 /// `window` is null or a live platform window; on Apple it outlives the presentation.
727 unsafe fn new(window: *mut c_void, pixels: [u32; 2]) -> Result<Self> {
728 let window = std::ptr::NonNull::new(window).ok_or("No native window")?;
729 #[cfg(target_os = "android")]
730 let window = {
731 // SAFETY: the caller lends a live ANativeWindow for this call.
732 unsafe { ANativeWindow_acquire(window.as_ptr()) };
733 NativeWindow(window)
734 };
735 #[cfg(target_os = "android")]
736 let target = wgpu::SurfaceTargetUnsafe::RawHandle {
737 raw_display_handle: Some(wgpu::rwh::AndroidDisplayHandle::new().into()),
738 raw_window_handle: wgpu::rwh::AndroidNdkWindowHandle::new(window.0).into(),
739 };
740 #[cfg(not(target_os = "android"))]
741 let target = wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(window.as_ptr());
702742 let mut gpu = gpu();
703743 let instance = match &*gpu {
704744 Some(gpu) => gpu.instance.clone(),
705745 None => wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()),
706746 };
707 // SAFETY: the host passes a live CAMetalLayer that outlives the view.
708 let surface = unsafe {
709 instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer))
710 }?;
747 // SAFETY: Android retains its window; the Apple caller keeps its layer alive.
748 let surface = unsafe { instance.create_surface_unsafe(target) }?;
711749 if gpu.is_none() {
712750 let adapter =
713751 pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
......@@ -725,19 +763,71 @@ impl View {
725763 .ok_or("No supported surface configuration")?
726764 .format;
727765 if let Ok(mut renderer) = crash_report::RENDERER.lock() {
728 *renderer = format!("Metal ({})", adapter.get_info().name);
766 let info = adapter.get_info();
767 *renderer = format!("{:?} ({})", info.backend, info.name);
729768 }
730769 *gpu = Some(Gpu {
731770 instance,
732771 renderer: draw::Renderer::new(device, queue, format),
733772 adapter,
773 format,
734774 });
735775 }
736776 let gpu = gpu.as_ref().ok_or("No GPU")?;
737 let config = surface
777 if pixels
778 .iter()
779 .any(|side| *side > gpu.renderer.device().limits().max_texture_dimension_2d)
780 {
781 return Err("Canvas dimensions exceed the GPU texture limit".into());
782 }
783 if !surface
784 .get_capabilities(&gpu.adapter)
785 .formats
786 .contains(&gpu.format)
787 {
788 return Err(
789 "The native window does not support the shared renderer's texture format".into(),
790 );
791 }
792 let mut config = surface
738793 .get_default_config(&gpu.adapter, pixels[0], pixels[1])
739794 .ok_or("No supported surface configuration")?;
795 config.format = gpu.format;
740796 surface.configure(gpu.renderer.device(), &config);
797 Ok(Self {
798 surface,
799 #[cfg(target_os = "android")]
800 _window: window,
801 config,
802 })
803 }
804}
805
806pub struct View {
807 /// Taken apart on drop, which returns its text engine to `ENGINES`.
808 canvas: ManuallyDrop<Canvas>,
809 presentation: Option<Presentation>,
810 frame: Arc<Frame>,
811 section: Arc<Shared>,
812 /// A conflict page, which shows what is stored and takes no edits.
813 read_only: bool,
814 /// The column, in page points, a reading view reflows the page into; it takes no edits.
815 reading: Option<f32>,
816}
817
818impl View {
819 /// # Safety
820 /// `window` is null or a live platform window; on Apple it outlives the returned view.
821 unsafe fn new(
822 window: *mut c_void,
823 section: &Section,
824 space: ExGuid,
825 size: [f32; 2],
826 scale: f32,
827 ) -> Result<Self> {
828 let pixels = view_pixels(size, scale, u32::MAX)?;
829 // SAFETY: the caller supplies the platform window's lifetime contract.
830 let presentation = unsafe { Presentation::new(window, pixels) }?;
741831 let (mut page, read_only) = section.shared.page(space)?;
742832 // The page wears its theme: style objects it gives otherwise are restyled first.
743833 let sheet = section.sheet(page.identity);
......@@ -758,8 +848,7 @@ impl View {
758848 canvas.page.editor.styles = sheet;
759849 Ok(Self {
760850 canvas: ManuallyDrop::new(canvas),
761 surface,
762 config,
851 presentation: Some(presentation),
763852 frame,
764853 section: Arc::clone(&section.shared),
765854 read_only,
......@@ -768,13 +857,18 @@ impl View {
768857 }
769858
770859 fn render(&mut self) -> Result<()> {
860 let Some(presentation) = &mut self.presentation else {
861 return Ok(());
862 };
771863 let mut gpu = gpu();
772864 let renderer = &mut gpu.as_mut().ok_or("No GPU")?.renderer;
773 let frame = match self.surface.get_current_texture() {
865 let frame = match presentation.surface.get_current_texture() {
774866 wgpu::CurrentSurfaceTexture::Success(frame)
775867 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
776868 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
777 self.surface.configure(renderer.device(), &self.config);
869 presentation
870 .surface
871 .configure(renderer.device(), &presentation.config);
778872 return Ok(());
779873 }
780874 _ => return Ok(()),
......@@ -785,10 +879,13 @@ impl View {
785879 .page
786880 .update_pictures(paper, &Waker::from(self.frame.clone()));
787881 let primitives = canvas.page.primitives(canvas.colors())?;
882 let target = frame.texture.create_view(&Default::default());
883 #[cfg(target_os = "macos")]
884 let target = target.into();
788885 renderer
789886 .draw(
790 &frame.texture.create_view(&Default::default()).into(),
791 [self.config.width, self.config.height],
887 &target,
888 [presentation.config.width, presentation.config.height],
792889 paper.color,
793890 &[canvas.page.viewport.layer(&primitives)],
794891 )
......@@ -853,7 +950,7 @@ impl View {
853950 };
854951 pool_engine(engine);
855952 self.reading = column.filter(|_| reflowed.is_some());
856 let pixels = [self.config.width, self.config.height];
953 let pixels = self.canvas.page.viewport.size;
857954 let mut canvas = Canvas::new(
858955 self.canvas.space,
859956 reflowed.unwrap_or(page),
......@@ -899,14 +996,18 @@ fn owned(text: String) -> *mut c_char {
899996 CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw)
900997}
901998
902/// A view drawing page `id` of `section` into `layer`, a `CAMetalLayer`, `size` points at
903/// `scale` pixels per point; null if it cannot.
999/// A view drawing page `id` of `section`, `width` by `height` host points at `scale`
1000/// pixels per point; null if it cannot. Android exports this as `sb_view_new_android`.
9041001///
9051002/// # Safety
906/// `layer` is a live `CAMetalLayer` that outlives the view; `id` is NUL-terminated UTF-8.
907#[unsafe(no_mangle)]
1003/// `window` is null or a live `ANativeWindow` on Android, or a `CAMetalLayer` that outlives the
1004/// view on Apple. Android acquires its own reference without consuming the caller's.
1005/// The host serializes view calls and detaches Android views before destroying their surfaces.
1006/// `id` is NUL-terminated UTF-8.
1007#[cfg_attr(target_os = "android", unsafe(export_name = "sb_view_new_android"))]
1008#[cfg_attr(not(target_os = "android"), unsafe(no_mangle))]
9081009pub unsafe extern "C" fn sb_view_new(
909 layer: *mut c_void,
1010 window: *mut c_void,
9101011 section: &Section,
9111012 id: *const c_char,
9121013 width: f32,
......@@ -917,11 +1018,40 @@ pub unsafe extern "C" fn sb_view_new(
9171018 string(id)
9181019 .parse()
9191020 .map_err(Into::into)
920 .and_then(|space| View::new(layer, section, space, [width, height], scale)),
1021 .and_then(|space| unsafe { View::new(window, section, space, [width, height], scale) }),
9211022 )
9221023 .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view)))
9231024}
9241025
1026/// Stops presentation without changing the editor, selection or Undo history.
1027#[cfg(any(target_os = "android", test))]
1028#[cfg_attr(target_os = "android", unsafe(no_mangle))]
1029pub extern "C" fn sb_view_detach_android(view: &mut View) {
1030 view.presentation = None;
1031}
1032
1033/// Attaches a replacement window at the last size and scale; resize after SurfaceChanged.
1034/// Failure leaves a detached editor unchanged; an attached view must be detached first.
1035///
1036/// # Safety
1037/// `window` is null or a live ANativeWindow. The view acquires its own reference.
1038/// The host serializes view calls and detaches before SurfaceDestroyed returns.
1039#[cfg(any(target_os = "android", test))]
1040#[cfg_attr(target_os = "android", unsafe(no_mangle))]
1041pub unsafe extern "C" fn sb_view_attach_android(view: &mut View, window: *mut c_void) -> bool {
1042 report(if view.presentation.is_some() {
1043 Err("Detach the current surface before attaching another".into())
1044 } else {
1045 // SAFETY: the caller supplies the platform window's lifetime contract.
1046 unsafe { Presentation::new(window, view.canvas.page.viewport.size) }
1047 })
1048 .map(|presentation| {
1049 view.presentation = Some(presentation);
1050 view.frame.0.store(true, Ordering::Release);
1051 })
1052 .is_some()
1053}
1054
9251055/// # Safety
9261056/// `view` came from `sb_view_new` and is not used again.
9271057#[unsafe(no_mangle)]
......@@ -931,17 +1061,27 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) {
9311061
9321062#[unsafe(no_mangle)]
9331063pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) {
934 let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32);
1064 let gpu = gpu();
1065 let Some(gpu) = gpu.as_ref() else { return };
1066 let Some(pixels) = report(view_pixels(
1067 [width, height],
1068 scale,
1069 gpu.renderer.device().limits().max_texture_dimension_2d,
1070 )) else {
1071 return;
1072 };
9351073 let canvas = &mut view.canvas;
9361074 if scale != canvas.scale {
9371075 canvas.scale = scale;
9381076 let _ = report(canvas.page.scale_factor_changed(scale));
9391077 }
9401078 let _ = report(canvas.page.resized(pixels));
941 view.config.width = pixels[0];
942 view.config.height = pixels[1];
943 if let Some(gpu) = &*gpu() {
944 view.surface.configure(gpu.renderer.device(), &view.config);
1079 if let Some(presentation) = &mut view.presentation {
1080 presentation.config.width = pixels[0];
1081 presentation.config.height = pixels[1];
1082 presentation
1083 .surface
1084 .configure(gpu.renderer.device(), &presentation.config);
9451085 }
9461086}
9471087
crates/mobile/src/tests.rs+86
......@@ -1522,3 +1522,89 @@ fn sections_reorder_and_move_between_groups_and_back() {
15221522 assert!(root.join("Empty.one").exists());
15231523 open(&library, "Empty.one");
15241524}
1525
1526#[test]
1527fn dimensions_round_in_pixels_and_keep_subpixel_windows_nonempty() {
1528 assert_eq!(
1529 view_pixels([320.25, 480.75], 2.0, 8192).unwrap(),
1530 [641, 962]
1531 );
1532 assert_eq!(view_pixels([0.1, 0.2], 0.5, 8192).unwrap(), [1, 1]);
1533 assert_eq!(view_pixels([8192.0, 1.0], 1.0, 8192).unwrap(), [8192, 1]);
1534}
1535
1536#[test]
1537fn dimensions_reject_nonpositive_and_nonfinite_inputs() {
1538 for bad in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1539 assert!(view_pixels([bad, 1.0], 1.0, 8192).is_err());
1540 assert!(view_pixels([1.0, bad], 1.0, 8192).is_err());
1541 assert!(view_pixels([1.0, 1.0], bad, 8192).is_err());
1542 }
1543}
1544
1545#[test]
1546fn dimensions_reject_overflow_and_the_device_texture_limit() {
1547 assert!(view_pixels([f32::MAX, 1.0], f32::MAX, u32::MAX).is_err());
1548 assert!(view_pixels([u32::MAX as f32, 1.0], 1.0, u32::MAX).is_err());
1549 assert!(view_pixels([8193.0, 1.0], 1.0, 8192).is_err());
1550 assert!(view_pixels([1.0, 4097.0], 2.0, 8192).is_err());
1551}
1552
1553#[test]
1554fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() {
1555 let (_directory, section) = features();
1556 let space = section.shared.section.pages().unwrap()[0].0;
1557 let id = CString::new(space.to_string()).unwrap();
1558 assert!(
1559 unsafe {
1560 sb_view_new(
1561 std::ptr::null_mut(),
1562 &section,
1563 id.as_ptr(),
1564 320.0,
1565 480.0,
1566 2.0,
1567 )
1568 }
1569 .is_null()
1570 );
1571 let (page, read_only) = section.shared.page(space).unwrap();
1572 let mut view = View {
1573 canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()),
1574 presentation: None,
1575 frame: Arc::default(),
1576 section: Arc::clone(&section.shared),
1577 read_only,
1578 reading: None,
1579 };
1580 assert!(sb_view_focus_title(&mut view));
1581 assert!(unsafe { sb_insert(&mut view, c" retained after surface loss".as_ptr()) });
1582 let text = view.canvas.active().shown_text();
1583 let history = view.canvas.page.editor.history_depth();
1584 let mut selection = [0; 2];
1585 sb_selection(&view, &mut selection);
1586 assert!(sb_view_can_undo(&view, false));
1587
1588 sb_view_detach_android(&mut view);
1589 sb_view_detach_android(&mut view);
1590 assert!(view.render().is_ok());
1591 assert!(!unsafe { sb_view_attach_android(&mut view, std::ptr::null_mut()) });
1592 let mut after = [0; 2];
1593 sb_selection(&view, &mut after);
1594 assert!(view.presentation.is_none());
1595 assert_eq!(view.canvas.page.viewport.size, [640, 960]);
1596 assert_eq!(view.canvas.scale, 2.0);
1597 assert_eq!(after, selection);
1598 assert_eq!(view.canvas.active().shown_text(), text);
1599 assert_eq!(view.canvas.page.editor.history_depth(), history);
1600 assert!(sb_view_undo(&mut view, false));
1601 assert!(
1602 !view
1603 .canvas
1604 .active()
1605 .shown_text()
1606 .contains("retained after surface loss")
1607 );
1608 assert!(sb_view_undo(&mut view, true));
1609 assert_eq!(view.canvas.active().shown_text(), text);
1610}