| author | |
| committer | |
| log | ce6f0270afea1cee267fdbdc5e772e598ee773fc |
| tree | 8534e3feb70967010a02b0c53d36a45f40cf7deb |
| parent | 4a299856128bcf7cbc8c36eefe3f9fdf31a588a6 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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.54 files changed, 411 insertions(+), 102 deletions(-)
crates/canvas/src/interaction/mod.rs+16-14| ... | ... | @@ -1100,6 +1100,15 @@ impl PageView { |
| 1100 | 1100 | } |
| 1101 | 1101 | |
| 1102 | 1102 | 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])> { | |
| 1103 | 1112 | self.editor |
| 1104 | 1113 | .visible_outlines() |
| 1105 | 1114 | .chain( |
| ... | ... | @@ -1107,6 +1116,7 @@ impl PageView { |
| 1107 | 1116 | .caret_outline() |
| 1108 | 1117 | .filter(|_| self.column.is_some()), |
| 1109 | 1118 | ) |
| 1119 | .filter(|source| keep(source)) | |
| 1110 | 1120 | .filter_map(|source| { |
| 1111 | 1121 | let (geometry, origin) = self.text_geometry(source.id).ok()?; |
| 1112 | 1122 | let rect = if self.column.is_some() { |
| ... | ... | @@ -1122,10 +1132,10 @@ impl PageView { |
| 1122 | 1132 | }; |
| 1123 | 1133 | let dx = (rect[0] - point[0]).max(point[0] - rect[2]).max(0.0); |
| 1124 | 1134 | 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)) | |
| 1126 | 1136 | }) |
| 1127 | 1137 | .min_by(|a, b| a.0.total_cmp(&b.0)) |
| 1128 | .map(|(_, rect)| rect) | |
| 1138 | .map(|(_, id, rect)| (id, rect)) | |
| 1129 | 1139 | } |
| 1130 | 1140 | |
| 1131 | 1141 | fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<()> { |
| ... | ... | @@ -1611,18 +1621,10 @@ impl PageView { |
| 1611 | 1621 | |
| 1612 | 1622 | /// A fresh click places text 7 px above the pointer, on the grid. |
| 1613 | 1623 | 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); | |
| 1626 | 1628 | if let Some(id) = id { |
| 1627 | 1629 | self.editor.focus_outline(id)?; |
| 1628 | 1630 | self.reconcile_reading(); |
crates/canvas/src/interaction/tests.rs+172-1| ... | ... | @@ -3747,6 +3747,174 @@ fn reader_view(page: Page) -> PageView { |
| 3747 | 3747 | view |
| 3748 | 3748 | } |
| 3749 | 3749 | |
| 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. | |
| 3752 | fn 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] | |
| 3791 | fn 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 | ||
| 3750 | 3918 | #[test] |
| 3751 | 3919 | fn reader_consumers_share_title_header_date_and_body_geometry() { |
| 3752 | 3920 | let mut view = reader_view(reader_page()); |
| ... | ... | @@ -4244,7 +4412,10 @@ fn reader_empty_title_only_and_remote_unsupported_content_follow_current_source( |
| 4244 | 4412 | &mut engine, |
| 4245 | 4413 | TextDocument::new(vec![Paragraph::new( |
| 4246 | 4414 | "Remote annotation".into(), |
| 4247 | Default::default(), | |
| 4415 | Format { | |
| 4416 | math: Some(true), | |
| 4417 | ..Default::default() | |
| 4418 | }, | |
| 4248 | 4419 | )]) |
| 4249 | 4420 | .unwrap(), |
| 4250 | 4421 | 120.0, |
crates/canvas/src/reading.rs+121-87| ... | ... | @@ -51,26 +51,32 @@ pub(crate) fn geometry<'a>( |
| 51 | 51 | |
| 52 | 52 | #[cfg(feature = "interaction")] |
| 53 | 53 | impl 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. | |
| 54 | 56 | pub(crate) fn build( |
| 55 | 57 | editor: &CanvasEditor, |
| 56 | 58 | scene: Option<&crate::gpu::page::PageScene>, |
| 57 | 59 | width: f32, |
| 58 | 60 | engine: &mut TextEngine, |
| 59 | 61 | ) -> Result<Option<Self>, crate::layout::LayoutError> { |
| 60 | use crate::outline::{OutlineLayout, ParagraphLayout}; | |
| 61 | 62 | if !width.is_finite() || width <= 0.0 { |
| 62 | 63 | return Err(crate::layout::LayoutError::InvalidWidth); |
| 63 | 64 | } |
| 64 | 65 | if editor.rtl() { |
| 65 | 66 | return Ok(None); |
| 66 | 67 | } |
| 67 | let title = editor | |
| 68 | let (titles, body): (Vec<_>, Vec<_>) = editor | |
| 68 | 69 | .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 | }; | |
| 71 | 77 | if editor.objects.iter().any(|object| { |
| 72 | 78 | 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)) | |
| 74 | 80 | { |
| 75 | 81 | return true; |
| 76 | 82 | } |
| ... | ... | @@ -102,100 +108,120 @@ impl Column { |
| 102 | 108 | { |
| 103 | 109 | return Ok(None); |
| 104 | 110 | } |
| 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))) | |
| 108 | 115 | .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 | }); | |
| 115 | 142 | let mut column = Self { |
| 116 | 143 | width, |
| 117 | 144 | outlines: Vec::new(), |
| 118 | 145 | }; |
| 119 | 146 | 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 { | |
| 134 | 149 | 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)); | |
| 173 | 153 | } |
| 174 | 154 | 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); | |
| 181 | 155 | for (rect, _) in scene.content_bounds(editor, Some(&column)) { |
| 182 | 156 | if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 { |
| 183 | 157 | return Ok(None); |
| 184 | 158 | } |
| 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); | |
| 193 | 160 | } |
| 194 | 161 | } |
| 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 | } | |
| 195 | 170 | Ok(Some(column)) |
| 196 | 171 | } |
| 197 | 172 | } |
| 198 | 173 | |
| 174 | /// `source` wrapped to a column `width` points wide, or None where it would not fit. | |
| 175 | #[cfg(feature = "interaction")] | |
| 176 | fn 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 | ||
| 199 | 225 | /// Points between blocks in the column: OneNote's grid row. |
| 200 | 226 | const GAP: f32 = 18.0; |
| 201 | 227 | /// Strokes this near one another are one drawing, as handwriting is many strokes. |
| ... | ... | @@ -285,7 +311,13 @@ impl Verdict { |
| 285 | 311 | /// `page` read into a column `column` points wide. |
| 286 | 312 | pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading, EditorError> { |
| 287 | 313 | 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 | ); | |
| 289 | 321 | let refused = |refusal, blocks| Reading { |
| 290 | 322 | verdict: Verdict::Refused(refusal), |
| 291 | 323 | blocks, |
| ... | ... | @@ -425,12 +457,14 @@ fn root_tops(outline: &TextOutline) -> Vec<f32> { |
| 425 | 457 | /// lie within them. |
| 426 | 458 | type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>); |
| 427 | 459 | |
| 428 | /// The page's content as laid out, overlapping pieces and nearby strokes joined. | |
| 429 | fn 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. | |
| 462 | fn 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) | |
| 434 | 468 | .map(|outline| { |
| 435 | 469 | let b = outline.bounds(); |
| 436 | 470 | 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 |
| 3317 | 3317 | assert_eq!(std::fs::read(file).unwrap(), canonical); |
| 3318 | 3318 | } |
| 3319 | 3319 | |
| 3320 | #[test] | |
| 3321 | fn 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 | ||
| 3320 | 3422 | #[test] |
| 3321 | 3423 | fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() { |
| 3322 | 3424 | use std::sync::mpsc; |