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);...@@ -71,8 +71,20 @@ void sb_section_wake(const Section *section);
71void sb_section_set_author(const Section *section, const char *author);71void sb_section_set_author(const Section *section, const char *author);
72void sb_section_free(Section *section);72void 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
74View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale);83View *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.
75void sb_view_free(View *view);86void sb_view_free(View *view);
87// Dimensions are host points; scale is pixels per point. Invalid sizes leave the view unchanged.
76void sb_view_resize(View *view, float width, float height, float scale);88void sb_view_resize(View *view, float width, float height, float scale);
77bool sb_view_render(View *view);89bool sb_view_render(View *view);
78bool sb_view_frame_pending(const View *view);90bool sb_view_frame_pending(const View *view);
crates/mobile/src/lib.rs+184-44
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1//! A canvas page behind a C surface for native mobile shells: notebooks and their sections1//! 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 the2//! (`library`), one page drawn into a native window, and the active outline's text as the
3//! flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). Every3//! flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). Every
4//! edit a view takes is stored at once through its section.4//! edit a view takes is stored at once through its section.
5//!5//!
...@@ -616,9 +616,10 @@ struct Gpu {...@@ -616,9 +616,10 @@ struct Gpu {
616 instance: wgpu::Instance,616 instance: wgpu::Instance,
617 adapter: wgpu::Adapter,617 adapter: wgpu::Adapter,
618 renderer: draw::Renderer,618 renderer: draw::Renderer,
619 format: wgpu::TextureFormat,
619}620}
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.
622static GPU: Mutex<Option<Gpu>> = Mutex::new(None);623static GPU: Mutex<Option<Gpu>> = Mutex::new(None);
623624
624fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {625fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> {
...@@ -677,37 +678,74 @@ pub extern "C" fn sb_set_markdown_shortcuts(on: bool) {...@@ -677,37 +678,74 @@ pub extern "C" fn sb_set_markdown_shortcuts(on: bool) {
677 options().2 = on;678 options().2 = on;
678}679}
679680
680pub struct View {681#[cfg(target_os = "android")]
681 /// Taken apart on drop, which returns its text engine to `ENGINES`.682#[link(name = "android")]
682 canvas: ManuallyDrop<Canvas>,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 {
683 surface: wgpu::Surface<'static>,717 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,
684 config: wgpu::SurfaceConfiguration,721 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>,
691}722}
692723
693impl View {724impl Presentation {
694 fn new(725 /// # Safety
695 layer: *mut c_void,726 /// `window` is null or a live platform window; on Apple it outlives the presentation.
696 section: &Section,727 unsafe fn new(window: *mut c_void, pixels: [u32; 2]) -> Result<Self> {
697 space: ExGuid,728 let window = std::ptr::NonNull::new(window).ok_or("No native window")?;
698 size: [f32; 2],729 #[cfg(target_os = "android")]
699 scale: f32,730 let window = {
700 ) -> Result<Self> {731 // SAFETY: the caller lends a live ANativeWindow for this call.
701 let pixels = size.map(|side| (side * scale).round().max(1.0) as u32);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());
702 let mut gpu = gpu();742 let mut gpu = gpu();
703 let instance = match &*gpu {743 let instance = match &*gpu {
704 Some(gpu) => gpu.instance.clone(),744 Some(gpu) => gpu.instance.clone(),
705 None => wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()),745 None => wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()),
706 };746 };
707 // SAFETY: the host passes a live CAMetalLayer that outlives the view.747 // SAFETY: Android retains its window; the Apple caller keeps its layer alive.
708 let surface = unsafe {748 let surface = unsafe { instance.create_surface_unsafe(target) }?;
709 instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer))
710 }?;
711 if gpu.is_none() {749 if gpu.is_none() {
712 let adapter =750 let adapter =
713 pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {751 pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
...@@ -725,19 +763,71 @@ impl View {...@@ -725,19 +763,71 @@ impl View {
725 .ok_or("No supported surface configuration")?763 .ok_or("No supported surface configuration")?
726 .format;764 .format;
727 if let Ok(mut renderer) = crash_report::RENDERER.lock() {765 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);
729 }768 }
730 *gpu = Some(Gpu {769 *gpu = Some(Gpu {
731 instance,770 instance,
732 renderer: draw::Renderer::new(device, queue, format),771 renderer: draw::Renderer::new(device, queue, format),
733 adapter,772 adapter,
773 format,
734 });774 });
735 }775 }
736 let gpu = gpu.as_ref().ok_or("No GPU")?;776 let gpu = gpu.as_ref().ok_or("No GPU")?;
737 let config = surface777 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
738 .get_default_config(&gpu.adapter, pixels[0], pixels[1])793 .get_default_config(&gpu.adapter, pixels[0], pixels[1])
739 .ok_or("No supported surface configuration")?;794 .ok_or("No supported surface configuration")?;
795 config.format = gpu.format;
740 surface.configure(gpu.renderer.device(), &config);796 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) }?;
741 let (mut page, read_only) = section.shared.page(space)?;831 let (mut page, read_only) = section.shared.page(space)?;
742 // The page wears its theme: style objects it gives otherwise are restyled first.832 // The page wears its theme: style objects it gives otherwise are restyled first.
743 let sheet = section.sheet(page.identity);833 let sheet = section.sheet(page.identity);
...@@ -758,8 +848,7 @@ impl View {...@@ -758,8 +848,7 @@ impl View {
758 canvas.page.editor.styles = sheet;848 canvas.page.editor.styles = sheet;
759 Ok(Self {849 Ok(Self {
760 canvas: ManuallyDrop::new(canvas),850 canvas: ManuallyDrop::new(canvas),
761 surface,851 presentation: Some(presentation),
762 config,
763 frame,852 frame,
764 section: Arc::clone(&section.shared),853 section: Arc::clone(&section.shared),
765 read_only,854 read_only,
...@@ -768,13 +857,18 @@ impl View {...@@ -768,13 +857,18 @@ impl View {
768 }857 }
769858
770 fn render(&mut self) -> Result<()> {859 fn render(&mut self) -> Result<()> {
860 let Some(presentation) = &mut self.presentation else {
861 return Ok(());
862 };
771 let mut gpu = gpu();863 let mut gpu = gpu();
772 let renderer = &mut gpu.as_mut().ok_or("No GPU")?.renderer;864 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() {
774 wgpu::CurrentSurfaceTexture::Success(frame)866 wgpu::CurrentSurfaceTexture::Success(frame)
775 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,867 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
776 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {868 wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
777 self.surface.configure(renderer.device(), &self.config);869 presentation
870 .surface
871 .configure(renderer.device(), &presentation.config);
778 return Ok(());872 return Ok(());
779 }873 }
780 _ => return Ok(()),874 _ => return Ok(()),
...@@ -785,10 +879,13 @@ impl View {...@@ -785,10 +879,13 @@ impl View {
785 .page879 .page
786 .update_pictures(paper, &Waker::from(self.frame.clone()));880 .update_pictures(paper, &Waker::from(self.frame.clone()));
787 let primitives = canvas.page.primitives(canvas.colors())?;881 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();
788 renderer885 renderer
789 .draw(886 .draw(
790 &frame.texture.create_view(&Default::default()).into(),887 &target,
791 [self.config.width, self.config.height],888 [presentation.config.width, presentation.config.height],
792 paper.color,889 paper.color,
793 &[canvas.page.viewport.layer(&primitives)],890 &[canvas.page.viewport.layer(&primitives)],
794 )891 )
...@@ -853,7 +950,7 @@ impl View {...@@ -853,7 +950,7 @@ impl View {
853 };950 };
854 pool_engine(engine);951 pool_engine(engine);
855 self.reading = column.filter(|_| reflowed.is_some());952 self.reading = column.filter(|_| reflowed.is_some());
856 let pixels = [self.config.width, self.config.height];953 let pixels = self.canvas.page.viewport.size;
857 let mut canvas = Canvas::new(954 let mut canvas = Canvas::new(
858 self.canvas.space,955 self.canvas.space,
859 reflowed.unwrap_or(page),956 reflowed.unwrap_or(page),
...@@ -899,14 +996,18 @@ fn owned(text: String) -> *mut c_char {...@@ -899,14 +996,18 @@ fn owned(text: String) -> *mut c_char {
899 CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw)996 CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw)
900}997}
901998
902/// A view drawing page `id` of `section` into `layer`, a `CAMetalLayer`, `size` points at999/// A view drawing page `id` of `section`, `width` by `height` host points at `scale`
903/// `scale` pixels per point; null if it cannot.1000/// pixels per point; null if it cannot. Android exports this as `sb_view_new_android`.
904///1001///
905/// # Safety1002/// # Safety
906/// `layer` is a live `CAMetalLayer` that outlives the view; `id` is NUL-terminated UTF-8.1003/// `window` is null or a live `ANativeWindow` on Android, or a `CAMetalLayer` that outlives the
907#[unsafe(no_mangle)]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))]
908pub unsafe extern "C" fn sb_view_new(1009pub unsafe extern "C" fn sb_view_new(
909 layer: *mut c_void,1010 window: *mut c_void,
910 section: &Section,1011 section: &Section,
911 id: *const c_char,1012 id: *const c_char,
912 width: f32,1013 width: f32,
...@@ -917,11 +1018,40 @@ pub unsafe extern "C" fn sb_view_new(...@@ -917,11 +1018,40 @@ pub unsafe extern "C" fn sb_view_new(
917 string(id)1018 string(id)
918 .parse()1019 .parse()
919 .map_err(Into::into)1020 .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) }),
921 )1022 )
922 .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view)))1023 .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view)))
923}1024}
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
925/// # Safety1055/// # Safety
926/// `view` came from `sb_view_new` and is not used again.1056/// `view` came from `sb_view_new` and is not used again.
927#[unsafe(no_mangle)]1057#[unsafe(no_mangle)]
...@@ -931,17 +1061,27 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) {...@@ -931,17 +1061,27 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) {
9311061
932#[unsafe(no_mangle)]1062#[unsafe(no_mangle)]
933pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) {1063pub 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 };
935 let canvas = &mut view.canvas;1073 let canvas = &mut view.canvas;
936 if scale != canvas.scale {1074 if scale != canvas.scale {
937 canvas.scale = scale;1075 canvas.scale = scale;
938 let _ = report(canvas.page.scale_factor_changed(scale));1076 let _ = report(canvas.page.scale_factor_changed(scale));
939 }1077 }
940 let _ = report(canvas.page.resized(pixels));1078 let _ = report(canvas.page.resized(pixels));
941 view.config.width = pixels[0];1079 if let Some(presentation) = &mut view.presentation {
942 view.config.height = pixels[1];1080 presentation.config.width = pixels[0];
943 if let Some(gpu) = &*gpu() {1081 presentation.config.height = pixels[1];
944 view.surface.configure(gpu.renderer.device(), &view.config);1082 presentation
1083 .surface
1084 .configure(gpu.renderer.device(), &presentation.config);
945 }1085 }
946}1086}
9471087
crates/mobile/src/tests.rs+86
...@@ -1522,3 +1522,89 @@ fn sections_reorder_and_move_between_groups_and_back() {...@@ -1522,3 +1522,89 @@ fn sections_reorder_and_move_between_groups_and_back() {
1522 assert!(root.join("Empty.one").exists());1522 assert!(root.join("Empty.one").exists());
1523 open(&library, "Empty.one");1523 open(&library, "Empty.one");
1524}1524}
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}