| 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,6 +1100,15 @@ impl PageView { |
| 1100 | } | 1100 | } |
| 1101 | 1101 | ||
| 1102 | pub fn text_block(&self, point: [f32; 2]) -> Option<[f32; 4]> { | 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 | self.editor | 1112 | self.editor |
| 1104 | .visible_outlines() | 1113 | .visible_outlines() |
| 1105 | .chain( | 1114 | .chain( |
| ... | @@ -1107,6 +1116,7 @@ impl PageView { | ... | @@ -1107,6 +1116,7 @@ impl PageView { |
| 1107 | .caret_outline() | 1116 | .caret_outline() |
| 1108 | .filter(|_| self.column.is_some()), | 1117 | .filter(|_| self.column.is_some()), |
| 1109 | ) | 1118 | ) |
| 1119 | .filter(|source| keep(source)) | ||
| 1110 | .filter_map(|source| { | 1120 | .filter_map(|source| { |
| 1111 | let (geometry, origin) = self.text_geometry(source.id).ok()?; | 1121 | let (geometry, origin) = self.text_geometry(source.id).ok()?; |
| 1112 | let rect = if self.column.is_some() { | 1122 | let rect = if self.column.is_some() { |
| ... | @@ -1122,10 +1132,10 @@ impl PageView { | ... | @@ -1122,10 +1132,10 @@ impl PageView { |
| 1122 | }; | 1132 | }; |
| 1123 | let dx = (rect[0] - point[0]).max(point[0] - rect[2]).max(0.0); | 1133 | let dx = (rect[0] - point[0]).max(point[0] - rect[2]).max(0.0); |
| 1124 | let dy = (rect[1] - point[1]).max(point[1] - rect[3]).max(0.0); | 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 | .min_by(|a, b| a.0.total_cmp(&b.0)) | 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 | fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<()> { | 1141 | fn move_selection(&mut self, movement: Movement, extend: bool) -> Result<()> { |
| ... | @@ -1611,18 +1621,10 @@ impl PageView { | ... | @@ -1611,18 +1621,10 @@ impl PageView { |
| 1611 | 1621 | ||
| 1612 | /// A fresh click places text 7 px above the pointer, on the grid. | 1622 | /// A fresh click places text 7 px above the pointer, on the grid. |
| 1613 | fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { | 1623 | fn place_caret(&mut self, point: [f32; 2]) -> Result<()> { |
| 1614 | if let Some(column) = &self.column { | 1624 | if self.column.is_some() { |
| 1615 | let id = column | 1625 | let id = self |
| 1616 | .outlines | 1626 | .nearest_text(point, |outline| !outline.title) |
| 1617 | .iter() | 1627 | .map(|(id, _)| id); |
| 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); | ||
| 1626 | if let Some(id) = id { | 1628 | if let Some(id) = id { |
| 1627 | self.editor.focus_outline(id)?; | 1629 | self.editor.focus_outline(id)?; |
| 1628 | self.reconcile_reading(); | 1630 | self.reconcile_reading(); |
crates/canvas/src/interaction/tests.rs+172-1| ... | @@ -3747,6 +3747,174 @@ fn reader_view(page: Page) -> PageView { | ... | @@ -3747,6 +3747,174 @@ fn reader_view(page: Page) -> PageView { |
| 3747 | view | 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 | #[test] | 3918 | #[test] |
| 3751 | fn reader_consumers_share_title_header_date_and_body_geometry() { | 3919 | fn reader_consumers_share_title_header_date_and_body_geometry() { |
| 3752 | let mut view = reader_view(reader_page()); | 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,7 +4412,10 @@ fn reader_empty_title_only_and_remote_unsupported_content_follow_current_source( |
| 4244 | &mut engine, | 4412 | &mut engine, |
| 4245 | TextDocument::new(vec![Paragraph::new( | 4413 | TextDocument::new(vec![Paragraph::new( |
| 4246 | "Remote annotation".into(), | 4414 | "Remote annotation".into(), |
| 4247 | Default::default(), | 4415 | Format { |
| 4416 | math: Some(true), | ||
| 4417 | ..Default::default() | ||
| 4418 | }, | ||
| 4248 | )]) | 4419 | )]) |
| 4249 | .unwrap(), | 4420 | .unwrap(), |
| 4250 | 120.0, | 4421 | 120.0, |
crates/canvas/src/reading.rs+121-87| ... | @@ -51,26 +51,32 @@ pub(crate) fn geometry<'a>( | ... | @@ -51,26 +51,32 @@ pub(crate) fn geometry<'a>( |
| 51 | 51 | ||
| 52 | #[cfg(feature = "interaction")] | 52 | #[cfg(feature = "interaction")] |
| 53 | impl Column { | 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 | pub(crate) fn build( | 56 | pub(crate) fn build( |
| 55 | editor: &CanvasEditor, | 57 | editor: &CanvasEditor, |
| 56 | scene: Option<&crate::gpu::page::PageScene>, | 58 | scene: Option<&crate::gpu::page::PageScene>, |
| 57 | width: f32, | 59 | width: f32, |
| 58 | engine: &mut TextEngine, | 60 | engine: &mut TextEngine, |
| 59 | ) -> Result<Option<Self>, crate::layout::LayoutError> { | 61 | ) -> Result<Option<Self>, crate::layout::LayoutError> { |
| 60 | use crate::outline::{OutlineLayout, ParagraphLayout}; | ||
| 61 | if !width.is_finite() || width <= 0.0 { | 62 | if !width.is_finite() || width <= 0.0 { |
| 62 | return Err(crate::layout::LayoutError::InvalidWidth); | 63 | return Err(crate::layout::LayoutError::InvalidWidth); |
| 63 | } | 64 | } |
| 64 | if editor.rtl() { | 65 | if editor.rtl() { |
| 65 | return Ok(None); | 66 | return Ok(None); |
| 66 | } | 67 | } |
| 67 | let title = editor | 68 | let (titles, body): (Vec<_>, Vec<_>) = editor |
| 68 | .visible_outlines() | 69 | .visible_outlines() |
| 69 | .find(|outline| outline.title) | 70 | .chain(editor.caret_outline()) |
| 70 | .map(|outline| outline.id); | 71 | .partition(|outline| outline.title); |
| 72 | let title = match titles[..] { | ||
| 73 | [] => None, | ||
| 74 | [title] => Some(title), | ||
| 75 | _ => return Ok(None), | ||
| 76 | }; | ||
| 71 | if editor.objects.iter().any(|object| { | 77 | if editor.objects.iter().any(|object| { |
| 72 | if let Content::Date { below_title } | Content::Outline { below_title, .. } = object | 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 | return true; | 81 | return true; |
| 76 | } | 82 | } |
| ... | @@ -102,100 +108,120 @@ impl Column { | ... | @@ -102,100 +108,120 @@ impl Column { |
| 102 | { | 108 | { |
| 103 | return Ok(None); | 109 | return Ok(None); |
| 104 | } | 110 | } |
| 105 | let mut sources: Vec<_> = editor | 111 | let tops = editor |
| 106 | .visible_outlines() | 112 | .outlines() |
| 107 | .chain(editor.caret_outline()) | 113 | .iter() |
| 114 | .map(|outline| (outline.id, root_tops(outline))) | ||
| 108 | .collect(); | 115 | .collect(); |
| 109 | if sources.iter().filter(|outline| !outline.title).count() > 1 | 116 | // Emptied outlines, and one being started, keep their place while typing. |
| 110 | || sources.iter().filter(|outline| outline.title).count() > 1 | 117 | let ordered = order( |
| 111 | { | 118 | blocks( |
| 112 | return Ok(None); | 119 | editor, |
| 113 | } | 120 | editor.visible_outlines().chain(editor.caret_outline()), |
| 114 | sources.sort_by_key(|outline| !outline.title); | 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 | let mut column = Self { | 142 | let mut column = Self { |
| 116 | width, | 143 | width, |
| 117 | outlines: Vec::new(), | 144 | outlines: Vec::new(), |
| 118 | }; | 145 | }; |
| 119 | let mut top = 14.4; | 146 | let mut top = 14.4; |
| 120 | for source in sources { | 147 | if let Some(title) = title { |
| 121 | if source | 148 | let Some(layout) = column_layout(title, editor, width, engine)? else { |
| 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 | }) { | ||
| 134 | return Ok(None); | 149 | return Ok(None); |
| 135 | } | 150 | }; |
| 136 | let shaped = OutlineLayout::flow( | 151 | top += layout.size[1] + GAP; |
| 137 | source.document().nodes().iter(), | 152 | column.outlines.push((title.id, [36.0, 14.4], layout)); |
| 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)); | ||
| 173 | } | 153 | } |
| 174 | if let Some(scene) = scene { | 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 | for (rect, _) in scene.content_bounds(editor, Some(&column)) { | 155 | for (rect, _) in scene.content_bounds(editor, Some(&column)) { |
| 182 | if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 { | 156 | if rect[0] < 36.0 || rect[2] > 36.0 + width + 1.0 { |
| 183 | return Ok(None); | 157 | return Ok(None); |
| 184 | } | 158 | } |
| 185 | bottom = bottom.max(rect[3] + GAP); | 159 | top = top.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; | ||
| 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 | Ok(Some(column)) | 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 | /// Points between blocks in the column: OneNote's grid row. | 225 | /// Points between blocks in the column: OneNote's grid row. |
| 200 | const GAP: f32 = 18.0; | 226 | const GAP: f32 = 18.0; |
| 201 | /// Strokes this near one another are one drawing, as handwriting is many strokes. | 227 | /// Strokes this near one another are one drawing, as handwriting is many strokes. |
| ... | @@ -285,7 +311,13 @@ impl Verdict { | ... | @@ -285,7 +311,13 @@ impl Verdict { |
| 285 | /// `page` read into a column `column` points wide. | 311 | /// `page` read into a column `column` points wide. |
| 286 | pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading, EditorError> { | 312 | pub fn read(page: &Page, column: f32, engine: &mut TextEngine) -> Result<Reading, EditorError> { |
| 287 | let editor = CanvasEditor::from_page(page.clone(), engine)?; | 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 | let refused = |refusal, blocks| Reading { | 321 | let refused = |refusal, blocks| Reading { |
| 290 | verdict: Verdict::Refused(refusal), | 322 | verdict: Verdict::Refused(refusal), |
| 291 | blocks, | 323 | blocks, |
| ... | @@ -425,12 +457,14 @@ fn root_tops(outline: &TextOutline) -> Vec<f32> { | ... | @@ -425,12 +457,14 @@ fn root_tops(outline: &TextOutline) -> Vec<f32> { |
| 425 | /// lie within them. | 457 | /// lie within them. |
| 426 | type Item = (ExGuid, Kind, [f32; 4], Vec<[f32; 4]>); | 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. | 460 | /// The page's content as laid out, `outlines` among it, overlapping pieces and nearby |
| 429 | fn blocks(editor: &CanvasEditor) -> Vec<Block> { | 461 | /// strokes joined. |
| 430 | let mut items: Vec<Item> = editor | 462 | fn blocks<'a>( |
| 431 | .outlines() | 463 | editor: &CanvasEditor, |
| 432 | .iter() | 464 | outlines: impl Iterator<Item = &'a TextOutline>, |
| 433 | .filter(|outline| !outline.title && !outline.is_empty()) | 465 | ) -> Vec<Block> { |
| 466 | let mut items: Vec<Item> = outlines | ||
| 467 | .filter(|outline| !outline.title) | ||
| 434 | .map(|outline| { | 468 | .map(|outline| { |
| 435 | let b = outline.bounds(); | 469 | let b = outline.bounds(); |
| 436 | let bounds = [b.x0, b.y0, b.x1, b.y1].map(|v| v as f32); | 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,6 +3317,108 @@ fn reader_native_selection_commits_marked_ops_and_keeps_the_original_offline_edi |
| 3317 | assert_eq!(std::fs::read(file).unwrap(), canonical); | 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 | #[test] | 3422 | #[test] |
| 3321 | fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() { | 3423 | fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() { |
| 3322 | use std::sync::mpsc; | 3424 | use std::sync::mpsc; |