authorclover caruso <git@paperclover.net> 2026-10-15 23:18:20-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 23:54:24-07:00
logce6f0270afea1cee267fdbdc5e772e598ee773fc
tree8534e3feb70967010a02b0c53d36a45f40cf7deb
parent4a299856128bcf7cbc8c36eefe3f9fdf31a588a6
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(mobile): flow every text box on a page into the Reading View

The Reading View was offered only for a page with a title and one text box. A page with several now reads them top to bottom in the column, left column first where boxes sit side by side, each wrapped to the screen's width and a grid row apart. Every box stays editable where it is: tapping one puts the caret there, and typing, marked text, undo and redo reach the box on the page without moving it. Pages whose text holds right-to-left writing, tables or equations still show only as laid out. Refs #128 Assisted-by: claude-opus-5.5

4 files changed, 411 insertions(+), 102 deletions(-)

crates/canvas/src/interaction/mod.rs+16-14
......@@ -1100,6 +1100,15 @@ impl PageView {
11001100 }
11011101
11021102 pub fn text_block(&self, point: [f32; 2]) -> Option<[f32; 4]> {
1103 self.nearest_text(point, |_| true).map(|(_, rect)| rect)
1104 }
1105
1106 /// The outline `keep` accepts nearest `point`, with its frame as shown.
1107 fn nearest_text(
1108 &self,
1109 point: [f32; 2],
1110 keep: impl Fn(&TextOutline) -> bool,
1111 ) -> Option<(onestore::ExGuid, [f32; 4])> {
11031112 self.editor
11041113 .visible_outlines()
11051114 .chain(
......@@ -1107,6 +1116,7 @@ impl PageView {
11071116 .caret_outline()
11081117 .filter(|_| self.column.is_some()),
11091118 )
1119 .filter(|source| keep(source))
11101120 .filter_map(|source| {
11111121 let (geometry, origin) = self.text_geometry(source.id).ok()?;
11121122 let rect = if self.column.is_some() {
......@@ -1122,10 +1132,10 @@ impl PageView {
11221132 };
11231133 let dx = (rect[0] - point[0]).max(point[0] - rect[2]).max(0.0);
11241134 let dy = (rect[1] - point[1]).max(point[1] - rect[3]).max(0.0);
1125 Some((dx.hypot(dy), rect))
1135 Some((dx.hypot(dy), source.id, rect))
11261136 })
11271137 .min_by(|a, b| a.0.total_cmp(&b.0))
1128 .map(|(_, rect)| rect)
1138 .map(|(_, id, rect)| (id, rect))
11291139 }
11301140
11311141 fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<()> {
......@@ -1611,18 +1621,10 @@ impl PageView {
16111621
16121622 /// A fresh click places text 7 px above the pointer, on the grid.
16131623 fn place_caret(&mut self, point: [f32; 2]) -> Result<()> {
1614 if let Some(column) = &self.column {
1615 let id = column
1616 .outlines
1617 .iter()
1618 .rev()
1619 .find(|(id, _, _)| {
1620 self.editor
1621 .visible_outlines()
1622 .chain(self.editor.caret_outline())
1623 .any(|outline| outline.id == *id && !outline.title)
1624 })
1625 .map(|(id, _, _)| *id);
1624 if self.column.is_some() {
1625 let id = self
1626 .nearest_text(point, |outline| !outline.title)
1627 .map(|(id, _)| id);
16261628 if let Some(id) = id {
16271629 self.editor.focus_outline(id)?;
16281630 self.reconcile_reading();
crates/canvas/src/interaction/tests.rs+172-1
......@@ -3747,6 +3747,174 @@ fn reader_view(page: Page) -> PageView {
37473747 view
37483748}
37493749
3750/// `reader_page` with its body as two columns side by side, the right one a little higher,
3751/// and a closing outline below both, stored in an order other than reading order.
3752fn reader_columns_page() -> (Page, [onestore::ExGuid; 3]) {
3753 let mut page = reader_page();
3754 let mut engine = TextEngine::default();
3755 let mut outline = |text: &[&str], width, origin| {
3756 TextOutline::new(
3757 &mut engine,
3758 TextDocument::new(
3759 text.iter()
3760 .map(|text| Paragraph::new((*text).into(), Default::default()))
3761 .collect(),
3762 )
3763 .unwrap(),
3764 width,
3765 origin,
3766 )
3767 .unwrap()
3768 .snapshot()
3769 };
3770 let left = outline(
3771 &[
3772 "Left column first, with words enough to wrap",
3773 "Its second paragraph",
3774 ],
3775 200.0,
3776 [36.0, 240.0],
3777 );
3778 let right = outline(&["Right column note"], 180.0, [300.0, 230.0]);
3779 let below = outline(&["Closing outline below both"], 468.0, [36.0, 520.0]);
3780 let ids = [left.id, right.id, below.id];
3781 page.objects.truncate(1);
3782 page.objects.extend(
3783 [below, right, left]
3784 .into_iter()
3785 .map(onestore::page::PageObject::Outline),
3786 );
3787 (page, ids)
3788}
3789
3790#[test]
3791fn reader_stacks_several_outlines_in_reading_order_each_editable_where_it_is() {
3792 let (page, [left, right, below]) = reader_columns_page();
3793 let mut view = reader_view(page);
3794 let original = view.editor.page().unwrap();
3795 let title = view
3796 .editor
3797 .outlines()
3798 .iter()
3799 .find(|outline| outline.title)
3800 .unwrap()
3801 .id;
3802 assert!(view.reading_status(200.0).0);
3803 assert!(view.set_reading(Some(200.0)));
3804 let column = |view: &PageView| {
3805 view.column
3806 .as_ref()
3807 .unwrap()
3808 .outlines
3809 .iter()
3810 .map(|(id, origin, shaped)| (*id, *origin, shaped.size[1]))
3811 .collect::<Vec<_>>()
3812 };
3813 let stacked = column(&view);
3814 assert_eq!(
3815 stacked.iter().map(|(id, ..)| *id).collect::<Vec<_>>(),
3816 [title, left, right, below]
3817 );
3818 for pair in stacked.windows(2) {
3819 assert_eq!(pair[1].1[0], 36.0);
3820 assert!(pair[1].1[1] >= pair[0].1[1] + pair[0].2 + 18.0);
3821 }
3822 let offset = view.scene.as_ref().unwrap().1;
3823 let tap = |view: &mut PageView, id| {
3824 let (_, origin, _) = column(view).into_iter().find(|(o, ..)| *o == id).unwrap();
3825 let point = [origin[0] + offset[0] + 4.0, origin[1] + offset[1] + 6.0];
3826 assert!(matches!(view.hit(point), Some(Hit::Text { id: hit, .. }) if hit == id));
3827 click(view, point, Instant::now());
3828 assert_eq!(view.editor.active_outline().id, id);
3829 };
3830 tap(&mut view, right);
3831 view.move_selection(Movement::DocumentEnd, false).unwrap();
3832 assert!(view.commit_text(" typed ๐Ÿ˜€".into()).unwrap().changed);
3833 assert!(view.compose("ใ‹ใช".into(), None).unwrap().changed);
3834 assert!(view.editor.marked_range().is_some());
3835 assert!(view.commit_text("ไปฎๅ".into()).unwrap().changed);
3836 assert!(view.editor.marked_range().is_none());
3837 assert!(!view.editor.take_ops().unwrap().is_empty());
3838 let edited = view.editor.page().unwrap();
3839 let text = |page: &Page, id| {
3840 page.objects
3841 .iter()
3842 .find_map(|object| match object {
3843 onestore::page::PageObject::Outline(outline) if outline.id == id => Some(
3844 outline
3845 .paragraphs
3846 .iter()
3847 .filter_map(|paragraph| paragraph.text())
3848 .map(|text| text.text.text().to_owned())
3849 .collect::<Vec<_>>()
3850 .join("\n"),
3851 ),
3852 _ => None,
3853 })
3854 .unwrap()
3855 };
3856 assert_eq!(text(&edited, right), "Right column note typed ๐Ÿ˜€ไปฎๅ");
3857 for id in [left, below] {
3858 assert_eq!(text(&edited, id), text(&original, id));
3859 }
3860 let layout = |page: &Page, id| {
3861 page.objects
3862 .iter()
3863 .find(|object| object.id() == id)
3864 .unwrap()
3865 .layout()
3866 .clone()
3867 };
3868 for id in [left, right, below] {
3869 assert_eq!(
3870 [layout(&edited, id).x, layout(&edited, id).y],
3871 [layout(&original, id).x, layout(&original, id).y]
3872 );
3873 }
3874 let restacked = column(&view);
3875 assert_eq!(
3876 restacked.iter().map(|(id, ..)| *id).collect::<Vec<_>>(),
3877 [title, left, right, below]
3878 );
3879 tap(&mut view, below);
3880 tap(&mut view, left);
3881 let history = view.editor.history_depth();
3882 while view.editor.history_depth()[0] > 0 {
3883 assert!(view.undo(false).unwrap().changed);
3884 }
3885 assert_eq!(view.editor.page().unwrap(), original);
3886 assert_eq!(view.editor.active_outline().id, right);
3887 while view.editor.history_depth()[1] > 0 {
3888 assert!(view.undo(true).unwrap().changed);
3889 }
3890 assert_eq!(view.editor.page().unwrap(), edited);
3891 assert_eq!(view.editor.history_depth(), history);
3892 assert!(view.column.is_some());
3893 view.primitives(COLORS).unwrap();
3894 // A tap between boxes goes to the nearer one.
3895 let place = |view: &PageView, id| {
3896 let (_, origin, height) = column(view).into_iter().find(|(o, ..)| *o == id).unwrap();
3897 [origin[1] + offset[1], origin[1] + offset[1] + height]
3898 };
3899 for (id, y) in [
3900 (left, place(&view, left)[1] + 3.0),
3901 (below, place(&view, below)[0] - 3.0),
3902 ] {
3903 click(&mut view, [offset[0] + 60.0, y], Instant::now());
3904 assert_eq!(view.editor.active_outline().id, id);
3905 }
3906 // An emptied box keeps its place.
3907 tap(&mut view, right);
3908 view.move_selection(Movement::DocumentStart, false).unwrap();
3909 view.move_selection(Movement::DocumentEnd, true).unwrap();
3910 let _ = view.key(&Key::Named(NamedKey::Backspace), None).unwrap();
3911 assert!(view.editor.active_outline().shown_text().is_empty());
3912 assert_eq!(
3913 column(&view).iter().map(|(id, ..)| *id).collect::<Vec<_>>(),
3914 [title, left, right, below]
3915 );
3916}
3917
37503918#[test]
37513919fn reader_consumers_share_title_header_date_and_body_geometry() {
37523920 let mut view = reader_view(reader_page());
......@@ -4244,7 +4412,10 @@ fn reader_empty_title_only_and_remote_unsupported_content_follow_current_source(
42444412 &mut engine,
42454413 TextDocument::new(vec![Paragraph::new(
42464414 "Remote annotation".into(),
4247 Default::default(),
4415 Format {
4416 math: Some(true),
4417 ..Default::default()
4418 },
42484419 )])
42494420 .unwrap(),
42504421 120.0,
crates/canvas/src/reading.rs+121-87
......@@ -51,26 +51,32 @@ pub(crate) fn geometry<'a>(
5151
5252#[cfg(feature = "interaction")]
5353impl Column {
54 /// The page's editable outlines stacked in reading order, title and its header first,
55 /// or None where something on the page would not reflow.
5456 pub(crate) fn build(
5557 editor: &CanvasEditor,
5658 scene: Option<&crate::gpu::page::PageScene>,
5759 width: f32,
5860 engine: &mut TextEngine,
5961 ) -> Result<Option<Self>, crate::layout::LayoutError> {
60 use crate::outline::{OutlineLayout, ParagraphLayout};
6162 if !width.is_finite() || width <= 0.0 {
6263 return Err(crate::layout::LayoutError::InvalidWidth);
6364 }
6465 if editor.rtl() {
6566 return Ok(None);
6667 }
67 let title = editor
68 let (titles, body): (Vec<_>, Vec<_>) = editor
6869 .visible_outlines()
69 .find(|outline| outline.title)
70 .map(|outline| outline.id);
70 .chain(editor.caret_outline())
71 .partition(|outline| outline.title);
72 let title = match titles[..] {
73 [] => None,
74 [title] => Some(title),
75 _ => return Ok(None),
76 };
7177 if editor.objects.iter().any(|object| {
7278 if let Content::Date { below_title } | Content::Outline { below_title, .. } = object
73 && (below_title.is_none() || *below_title != title)
79 && (below_title.is_none() || *below_title != title.map(|title| title.id))
7480 {
7581 return true;
7682 }
......@@ -102,100 +108,120 @@ impl Column {
102108 {
103109 return Ok(None);
104110 }
105 let mut sources: Vec<_> = editor
106 .visible_outlines()
107 .chain(editor.caret_outline())
111 let tops = editor
112 .outlines()
113 .iter()
114 .map(|outline| (outline.id, root_tops(outline)))
108115 .collect();
109 if sources.iter().filter(|outline| !outline.title).count() > 1
110 || sources.iter().filter(|outline| outline.title).count() > 1
111 {
112 return Ok(None);
113 }
114 sources.sort_by_key(|outline| !outline.title);
116 // Emptied outlines, and one being started, keep their place while typing.
117 let ordered = order(
118 blocks(
119 editor,
120 editor.visible_outlines().chain(editor.caret_outline()),
121 ),
122 &tops,
123 &mut Vec::new(),
124 );
125 let rank = |id: ExGuid| {
126 ordered
127 .iter()
128 .position(|block| block.members.contains(&id))
129 .unwrap_or(ordered.len())
130 };
131 let mut body: Vec<_> = body
132 .into_iter()
133 .map(|outline| (rank(outline.id), outline))
134 .collect();
135 body.sort_by(|(a_rank, a), (b_rank, b)| {
136 let ([ax, ay], [bx, by]) = (a.origin(), b.origin());
137 a_rank
138 .cmp(b_rank)
139 .then(ay.total_cmp(&by))
140 .then(ax.total_cmp(&bx))
141 });
115142 let mut column = Self {
116143 width,
117144 outlines: Vec::new(),
118145 };
119146 let mut top = 14.4;
120 for source in sources {
121 if source
122 .document()
123 .nodes()
124 .iter()
125 .any(|node| !matches!(node.content, ParagraphContent::Text(_)))
126 {
127 return Ok(None);
128 }
129 if source.document().paragraphs().any(|text| {
130 text.spans()
131 .iter()
132 .any(|span| span.format.math == Some(true) || span.format.math_object.is_some())
133 }) {
147 if let Some(title) = title {
148 let Some(layout) = column_layout(title, editor, width, engine)? else {
134149 return Ok(None);
135 }
136 let shaped = OutlineLayout::flow(
137 source.document().nodes().iter(),
138 &source.indents,
139 width,
140 false,
141 0,
142 None,
143 &mut |node, previous, width, indents| {
144 ParagraphLayout::shape(
145 engine,
146 node,
147 previous,
148 width,
149 indents,
150 &editor.definitions,
151 )
152 },
153 )?;
154 if shaped.size[0] > width + 1.0
155 || shaped.paragraphs.iter().any(|paragraph| {
156 paragraph.origin[0] < 0.0
157 || paragraph.origin[0]
158 + paragraph
159 .text
160 .lines()
161 .map(|(line, _)| {
162 line.metrics().advance - line.metrics().trailing_whitespace
163 })
164 .fold(0.0_f32, f32::max)
165 > width + 1.0
166 })
167 {
168 return Ok(None);
169 }
170 let origin = [36.0, top];
171 top += shaped.size[1] + GAP;
172 column.outlines.push((source.id, origin, shaped));
150 };
151 top += layout.size[1] + GAP;
152 column.outlines.push((title.id, [36.0, 14.4], layout));
173153 }
174154 if let Some(scene) = scene {
175 let mut bottom = column
176 .outlines
177 .iter()
178 .find(|(id, _, _)| Some(*id) == title)
179 .map(|(_, origin, shaped)| origin[1] + shaped.size[1] + GAP)
180 .unwrap_or(14.4);
181155 for (rect, _) in scene.content_bounds(editor, Some(&column)) {
182156 if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 {
183157 return Ok(None);
184158 }
185 bottom = bottom.max(rect[3] + GAP);
186 }
187 if let Some((_, origin, _)) = column
188 .outlines
189 .iter_mut()
190 .find(|(id, _, _)| Some(*id) != title)
191 {
192 origin[1] = bottom;
159 top = top.max(rect[3] + GAP);
193160 }
194161 }
162 for (_, source) in body {
163 let Some(layout) = column_layout(source, editor, width, engine)? else {
164 return Ok(None);
165 };
166 let height = layout.size[1];
167 column.outlines.push((source.id, [36.0, top], layout));
168 top += height + GAP;
169 }
195170 Ok(Some(column))
196171 }
197172}
198173
174/// `source` wrapped to a column `width` points wide, or None where it would not fit.
175#[cfg(feature = "interaction")]
176fn column_layout(
177 source: &TextOutline,
178 editor: &CanvasEditor,
179 width: f32,
180 engine: &mut TextEngine,
181) -> Result<Option<crate::outline::OutlineLayout>, crate::layout::LayoutError> {
182 use crate::outline::{OutlineLayout, ParagraphLayout};
183 if source
184 .document()
185 .nodes()
186 .iter()
187 .any(|node| !matches!(node.content, ParagraphContent::Text(_)))
188 {
189 return Ok(None);
190 }
191 if source.document().paragraphs().any(|text| {
192 text.spans()
193 .iter()
194 .any(|span| span.format.math == Some(true) || span.format.math_object.is_some())
195 }) {
196 return Ok(None);
197 }
198 let layout = OutlineLayout::flow(
199 source.document().nodes().iter(),
200 &source.indents,
201 width,
202 false,
203 0,
204 None,
205 &mut |node, previous, width, indents| {
206 ParagraphLayout::shape(engine, node, previous, width, indents, &editor.definitions)
207 },
208 )?;
209 let fits = layout.size[0] <= width + 1.0
210 && layout.paragraphs.iter().all(|paragraph| {
211 paragraph.origin[0] >= 0.0
212 && paragraph.origin[0]
213 + paragraph
214 .text
215 .lines()
216 .map(|(line, _)| {
217 line.metrics().advance - line.metrics().trailing_whitespace
218 })
219 .fold(0.0_f32, f32::max)
220 <= width + 1.0
221 });
222 Ok(fits.then_some(layout))
223}
224
199225/// Points between blocks in the column: OneNote's grid row.
200226const GAP: f32 = 18.0;
201227/// Strokes this near one another are one drawing, as handwriting is many strokes.
......@@ -285,7 +311,13 @@ impl Verdict {
285311/// `page` read into a column `column` points wide.
286312pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading, EditorError> {
287313 let editor = CanvasEditor::from_page(page.clone(), engine)?;
288 let blocks = blocks(&editor);
314 let blocks = blocks(
315 &editor,
316 editor
317 .outlines()
318 .iter()
319 .filter(|outline| !outline.is_empty()),
320 );
289321 let refused = |refusal, blocks| Reading {
290322 verdict: Verdict::Refused(refusal),
291323 blocks,
......@@ -425,12 +457,14 @@ fn root_tops(outline: &TextOutline) -> Vec<f32> {
425457/// lie within them.
426458type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>);
427459
428/// The page's content as laid out, overlapping pieces and nearby strokes joined.
429fn blocks(editor: &CanvasEditor) -> Vec<Block> {
430 let mut items: Vec<Item> = editor
431 .outlines()
432 .iter()
433 .filter(|outline| !outline.title && !outline.is_empty())
460/// The page's content as laid out, `outlines` among it, overlapping pieces and nearby
461/// strokes joined.
462fn blocks<'a>(
463 editor: &CanvasEditor,
464 outlines: impl Iterator<Item = &'a TextOutline>,
465) -> Vec<Block> {
466 let mut items: Vec<Item> = outlines
467 .filter(|outline| !outline.title)
434468 .map(|outline| {
435469 let b = outline.bounds();
436470 let bounds = [b.x0, b.y0, b.x1, b.y1].map(|v| v as f32);
crates/mobile/src/tests.rs+102
......@@ -3317,6 +3317,108 @@ fn reader_native_selection_commits_marked_ops_and_keeps_the_original_offline_edi
33173317 assert_eq!(std::fs::read(file).unwrap(), canonical);
33183318}
33193319
3320#[test]
3321fn reader_stacks_text_boxes_and_stores_an_edit_in_the_second_one() {
3322 let (directory, section) = features();
3323 let file = directory.path().join("Features.one");
3324 let space = section.new_page(None, "Mon", "6:14 AM").unwrap();
3325 let (page, read_only) = section.shared.page(space).unwrap();
3326 let mut view = View {
3327 canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()),
3328 presentation: None,
3329 frame: Arc::default(),
3330 section: Arc::clone(&section.shared),
3331 read_only,
3332 reload_pending: false,
3333 local_edits: Arc::new(Mutex::new(LocalEdits::default())),
3334 };
3335 view.canvas.set_transform(1.0, [0.0; 2]);
3336 assert!(sb_view_focus_title(&mut view));
3337 assert!(unsafe { sb_insert(&mut view, c"Two boxes".as_ptr()) });
3338 let mut boxes = Vec::new();
3339 for (text, at) in [(c"Lower box", [72.0, 360.0]), (c"Upper box", [72.0, 200.0])] {
3340 assert!(sb_view_press(&mut view, at[0] * POINT, at[1] * POINT));
3341 assert!(sb_view_release(&mut view));
3342 assert!(unsafe { sb_insert(&mut view, text.as_ptr()) });
3343 boxes.push(view.canvas.active().id);
3344 }
3345 let [lower, upper] = boxes[..] else {
3346 unreachable!()
3347 };
3348 assert_ne!(lower, upper);
3349 let mut anchor = [0.0; 2];
3350 assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor));
3351 let start = |view: &View, id| {
3352 view.canvas
3353 .page
3354 .caret_at(
3355 id,
3356 TextPosition {
3357 paragraph: 0,
3358 offset: 0,
3359 },
3360 Affinity::Downstream,
3361 0.0,
3362 )
3363 .unwrap()
3364 };
3365 let [first, second] = [start(&view, upper), start(&view, lower)];
3366 assert_eq!(first.x0, second.x0);
3367 assert!(first.y1 < second.y0);
3368 assert!(sb_view_press(
3369 &mut view,
3370 second.x0 as f32 * POINT + 1.0,
3371 (second.y0 + second.y1) as f32 / 2.0 * POINT
3372 ));
3373 assert!(sb_view_release(&mut view));
3374 assert_eq!(view.canvas.active().id, lower);
3375 let end = view.canvas.length();
3376 assert!(sb_select(&mut view, end, end));
3377 assert!(unsafe { sb_insert(&mut view, c", edited in reading ๐Ÿ˜€".as_ptr()) });
3378 assert_eq!(
3379 view.canvas.active().shown_text(),
3380 "Lower box, edited in reading ๐Ÿ˜€"
3381 );
3382 let mut status = sb_view_reading(&view, 240.0);
3383 let json = serde_json::from_str::<serde_json::Value>(&string(status)).unwrap();
3384 unsafe { sb_string_free(status) };
3385 assert_eq!(json["shown"], true);
3386 assert!(section.flush(Duration::from_secs(20)));
3387 let stored = stored_text(&file, space);
3388 assert!(stored.contains("Upper box"));
3389 assert!(stored.contains("Lower box, edited in reading ๐Ÿ˜€"));
3390 assert!(sb_view_undo(&mut view, false));
3391 assert_eq!(view.canvas.active().shown_text(), "Lower box");
3392 assert!(sb_view_undo(&mut view, true));
3393 assert!(!sb_view_set_reading(&mut view, 0.0, &mut anchor));
3394 status = sb_view_reading(&view, 240.0);
3395 let json = serde_json::from_str::<serde_json::Value>(&string(status)).unwrap();
3396 unsafe { sb_string_free(status) };
3397 assert_eq!(
3398 json,
3399 serde_json::json!({ "offered": true, "shown": false, "verdict": "Reflows" })
3400 );
3401 assert!(section.flush(Duration::from_secs(20)));
3402 let shared = Arc::clone(&view.section);
3403 drop(view);
3404 drop(section);
3405 Arc::into_inner(shared).unwrap().section.close().unwrap();
3406 let arena = onestore::Arena::default();
3407 let cold = onestore::Section::open(&arena, onestore::read_file(&file).unwrap()).unwrap();
3408 let mut engine = pooled_engine();
3409 let cold =
3410 canvas::editor::CanvasEditor::from_page(cold.page(space).unwrap(), &mut engine).unwrap();
3411 pool_engine(engine);
3412 let texts: Vec<_> = cold
3413 .outlines()
3414 .iter()
3415 .filter(|outline| !outline.title)
3416 .map(|outline| outline.shown_text())
3417 .collect();
3418 assert!(texts.contains(&"Upper box".to_string()));
3419 assert!(texts.contains(&"Lower box, edited in reading ๐Ÿ˜€".to_string()));
3420}
3421
33203422#[test]
33213423fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() {
33223424 use std::sync::mpsc;