diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 8e49d5e9a954f71c0c253d5790e712e763031ed9..9f702348e84820e29a6637f25411a7c7f417db5b 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -454,6 +454,7 @@ impl PageScene { pub(crate) fn update_pictures( &mut self, editor: Option<&CanvasEditor>, + column: Option<&crate::reading::Column>, view: [f32; 4], scale: f32, paper: Paper, @@ -463,7 +464,7 @@ impl PageScene { for art in self.backgrounds.values_mut() { settled &= art.update(paper, scale, waker); } - let rects = self.picture_rects(editor); + let rects = self.picture_rects(editor, column); picture::update(&mut self.pictures, rects, view, scale, waker) && settled } @@ -476,7 +477,7 @@ impl PageScene { f32::INFINITY, f32::INFINITY, ]; - while !self.update_pictures(editor, everything, scale, paper, Waker::noop()) { + while !self.update_pictures(editor, None, everything, scale, paper, Waker::noop()) { std::thread::sleep(Duration::from_millis(1)); } } @@ -498,7 +499,11 @@ impl PageScene { } /// Where pictures and file icons sit, in scene coordinates. - fn picture_rects(&self, editor: Option<&CanvasEditor>) -> Vec<(onestore::ExGuid, [f32; 4])> { + fn picture_rects( + &self, + editor: Option<&CanvasEditor>, + column: Option<&crate::reading::Column>, + ) -> Vec<(onestore::ExGuid, [f32; 4])> { fn outline( layout: &crate::outline::OutlineLayout, [x, y]: [f32; 2], @@ -514,12 +519,15 @@ impl PageScene { let mut rects = Vec::new(); for content in self.objects(editor).into_iter().flatten() { match content { - Content::Image(source) => { - let [x, y] = crate::origin(&source.layout); - if let Some([width, height]) = crate::outline::image_size(source) { - rects.push((source.id, [x, y, x + width, y + height])); + Content::Image(source) => match column { + Some(column) => rects.extend(column.picture(source.id).map(|r| (source.id, r))), + None => { + let [x, y] = crate::origin(&source.layout); + if let Some([width, height]) = crate::outline::image_size(source) { + rects.push((source.id, [x, y, x + width, y + height])); + } } - } + }, Content::File { source, layout } => { let origin = crate::origin(&source.layout); rects.push((source.id, crate::translated(layout.rect, origin))); @@ -530,7 +538,7 @@ impl PageScene { below_title, } => { let origin = crate::origin(&source.layout); - if let Ok(origin) = outline_origin(origin, *below_title, editor, None) { + if let Ok(origin) = outline_origin(origin, *below_title, editor, column) { outline(layout, origin, &mut rects); } } @@ -542,7 +550,8 @@ impl PageScene { } if let Some(editor) = editor { for editable in editor.visible_outlines() { - outline(editable.shaped(), editable.origin(), &mut rects); + let (geometry, origin) = crate::reading::geometry(editable, column); + outline(geometry.shaped, origin, &mut rects); } } rects @@ -628,6 +637,7 @@ impl PageScene { fn object_tags( &self, editor: Option<&CanvasEditor>, + column: Option<&crate::reading::Column>, moving: Option<(onestore::ExGuid, [f32; 4])>, ) -> Vec<(onestore::ExGuid, crate::outline::ParagraphTag)> { let definitions = match (&self.reference, editor) { @@ -641,8 +651,9 @@ impl PageScene { .iter() .filter_map(|object| { let (id, tags, bounds) = object.tagged()?; - let bounds = match moving { - Some((moved, rect)) if moved == id => rect, + let bounds = match (column, moving) { + (Some(column), _) => column.picture(id)?, + (None, Some((moved, rect))) if moved == id => rect, _ => bounds, }; Some( @@ -721,11 +732,14 @@ impl PageScene { true, )) } - Content::Image(source) => { - let [x, y] = crate::origin(&source.layout); - let [width, height] = crate::outline::image_size(source)?; - Some(([x, y, x + width, y + height], false)) - } + Content::Image(source) => match column { + Some(column) => Some((column.picture(source.id)?, false)), + None => { + let [x, y] = crate::origin(&source.layout); + let [width, height] = crate::outline::image_size(source)?; + Some(([x, y, x + width, y + height], false)) + } + }, Content::File { .. } => Some((object.file()?.1, true)), Content::Ink(ink) => Some((crate::editor::page::ink_bounds(ink)?, true)), Content::ReadOnly(object) => Some((object.rect(), true)), @@ -833,11 +847,14 @@ impl PageScene { column: Option<&crate::reading::Column>, mut outline: impl FnMut(onestore::ExGuid) -> Option, ) -> Option> { - let check = self.object_tags(editor, None).into_iter().find(|(_, tag)| { - tag.icon.checkable() - && (tag.origin[0]..=tag.origin[0] + tag.size).contains(&point[0]) - && (tag.origin[1]..=tag.origin[1] + tag.size).contains(&point[1]) - }); + let check = self + .object_tags(editor, column, None) + .into_iter() + .find(|(_, tag)| { + tag.icon.checkable() + && (tag.origin[0]..=tag.origin[0] + tag.size).contains(&point[0]) + && (tag.origin[1]..=tag.origin[1] + tag.size).contains(&point[1]) + }); if let Some((id, _)) = check { return Some(SceneHit::Check(id)); } @@ -867,7 +884,7 @@ impl PageScene { return Some(SceneHit::ReadOnly(readonly)); } } - Content::Image(source) if !source.background => { + Content::Image(source) if !source.background && column.is_none() => { let [x, y] = crate::origin(&source.layout); if let Some([width, height]) = crate::outline::image_size(source) && (x..=x + width).contains(&point[0]) @@ -1004,7 +1021,13 @@ impl PageScene { column, )? } - Content::Image(source) => crate::origin(&source.layout), + Content::Image(source) => match column { + Some(column) => match column.picture(source.id) { + Some([x, y, ..]) => [x, y], + None => continue, + }, + None => crate::origin(&source.layout), + }, Content::ReadOnly(object) => { let rect = crate::translated(object.rect(), offset); append_placeholder(rect, paper, primitives); @@ -1069,8 +1092,11 @@ impl PageScene { .pictures .get(&source.id) .ok_or(SceneError::MissingImage)?; - let size = crate::outline::image_size(source) - .ok_or(SceneError::MissingImage)?; + let size = match column.and_then(|column| column.picture(source.id)) { + Some([x0, y0, x1, y1]) => [x1 - x0, y1 - y0], + None => crate::outline::image_size(source) + .ok_or(SceneError::MissingImage)?, + }; if picture.failed() { append_placeholder( [ @@ -1147,7 +1173,7 @@ impl PageScene { } } let art = super::TagArt::default(); - for (_, tag) in self.object_tags(editor, moving) { + for (_, tag) in self.object_tags(editor, column, moving) { primitives.push(Primitive::Icon { sources: art.sources(&tag), origin: [tag.origin[0] + offset[0], tag.origin[1] + offset[1]], @@ -2835,7 +2861,14 @@ mod tests { }; let settle = |scene: &mut PageScene, view: [f32; 4]| { let give_up = Instant::now() + Duration::from_secs(20); - while !scene.update_pictures(Some(&editor), view, 2.0, Paper::WHITE, Waker::noop()) { + while !scene.update_pictures( + Some(&editor), + None, + view, + 2.0, + Paper::WHITE, + Waker::noop(), + ) { assert!( Instant::now() < give_up, "the pictures in view never landed" diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index d629ab8ea04d59e63016b3196518c8716cb922b3..0e7bb59c3992458794c2c6dacc07a44db1efdffc 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -304,6 +304,7 @@ fn update_pictures( scene: &mut PageScene, [x, y]: [f32; 2], editor: &CanvasEditor, + column: Option<&crate::reading::Column>, viewport: Viewport, paper: Paper, waker: &std::task::Waker, @@ -311,7 +312,7 @@ fn update_pictures( let [x0, y0] = viewport.document_point([0.0; 2]); let [x1, y1] = viewport.document_point(viewport.size.map(|side| side as f32)); let view = [x0 - x, y0 - y, x1 - x, y1 - y]; - scene.update_pictures(Some(editor), view, viewport.scale, paper, waker) + scene.update_pictures(Some(editor), column, view, viewport.scale, paper, waker) } impl PageView { @@ -982,7 +983,15 @@ impl PageView { /// should be drawn again because a raster landed. pub fn update_pictures(&mut self, paper: Paper, waker: &std::task::Waker) { if let Some((scene, offset)) = &mut self.scene { - update_pictures(scene, *offset, &self.editor, self.viewport, paper, waker); + update_pictures( + scene, + *offset, + &self.editor, + self.column.as_ref(), + self.viewport, + paper, + waker, + ); } } @@ -999,7 +1008,7 @@ impl PageView { origin: home(self.display_scale), ..self.viewport }; - update_pictures(scene, *offset, editor, viewport, paper, waker) + update_pictures(scene, *offset, editor, None, viewport, paper, waker) } pub fn set_reading(&mut self, width: Option) -> bool { @@ -1013,6 +1022,10 @@ impl PageView { .ok() .flatten() }); + // The column shows pictures only; one picked in the page view lets go. + if self.column.is_some() { + self.set_object_focus(None); + } self.column.is_some() } @@ -2902,6 +2915,7 @@ fn page_primitives<'a>( primitives, ); } + let shaped = geometry.shaped; append_outline( (object_focus.is_none() && outline.id == active @@ -2921,7 +2935,7 @@ fn page_primitives<'a>( _ => None, }; scene.append_outline_objects( - outline.shaped(), + shaped, [origin[0] + offset[0], origin[1] + offset[1]], moving, paint.colors.paper, diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index 160c9a8b6ec12014f5482bc8f6b91807433435d7..1d24e624df7c02a642b90f38149102428c387c96 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -3915,6 +3915,146 @@ fn reader_stacks_several_outlines_in_reading_order_each_editable_where_it_is() { ); } +/// A picture of the corpus photo `size` points across, at `origin` on the page. +fn reader_picture(origin: [f32; 2], size: [f32; 2]) -> onestore::page::Image { + let bytes = include_bytes!("../../../../corpus/object-tags/native/notebook/photo.png"); + onestore::page::Image { + size: Some(size), + id: onestore::page::text::new_id().unwrap(), + layout: onestore::document::Layout { + x: Some(origin[0]), + y: Some(origin[1]), + ..Default::default() + }, + bytes: Some(Arc::from(&bytes[..])), + display: None, + alt: None, + background: false, + printout: None, + tags: Vec::new(), + link: None, + text: None, + } +} + +#[test] +fn reader_shrinks_pictures_to_the_column_in_reading_order_and_leaves_them_alone() { + use onestore::page::{PageObject, ParagraphContent}; + let (mut page, [left, right, below]) = reader_columns_page(); + let wide = reader_picture([60.0, 330.0], [400.0, 160.0]); + let small = reader_picture([520.0, 524.0], [20.0, 15.0]); + let [wide_id, small_id] = [wide.id, small.id]; + page.objects + .extend([PageObject::Image(small), PageObject::Image(wide)]); + let PageObject::Outline(outline) = page + .objects + .iter_mut() + .find(|object| object.id() == left) + .unwrap() + else { + unreachable!() + }; + let mut inline = outline.paragraphs[0].clone(); + inline.id = onestore::page::text::new_id().unwrap(); + let inline_picture = reader_picture([0.0; 2], [240.0, 48.0]); + let inline_id = inline_picture.id; + inline.content = ParagraphContent::Image(onestore::page::Image { + layout: Default::default(), + ..inline_picture + }); + outline.paragraphs.insert(1, inline); + let mut view = reader_view(page); + let (scene, _) = view.scene.as_mut().unwrap(); + scene.settle(Some(&view.editor), 1.0, COLORS.paper); + let original = view.editor.page().unwrap(); + let offset = view.scene.as_ref().unwrap().1; + // A picture picked in the page view lets go in the column. + click( + &mut view, + [60.0 + offset[0] + 10.0, 330.0 + offset[1] + 10.0], + Instant::now(), + ); + assert_eq!(view.object_focus, Some(ObjectFocus::Image(wide_id))); + assert!(view.set_reading(Some(200.0))); + assert!(view.object_focus.is_none()); + let column = view.column.as_ref().unwrap(); + let place = |id| { + column + .outlines + .iter() + .find(|(outline, ..)| *outline == id) + .map(|(_, origin, shaped)| [origin[1], origin[1] + shaped.size[1]]) + .or_else(|| column.picture(id).map(|rect| [rect[1], rect[3]])) + .unwrap() + }; + let stacked = [left, right, wide_id, below, small_id].map(place); + for pair in stacked.windows(2) { + assert!(pair[1][0] >= pair[0][1] + 18.0); + } + let wide_rect = column.picture(wide_id).unwrap(); + assert_eq!(wide_rect, [36.0, wide_rect[1], 236.0, wide_rect[1] + 80.0]); + let small_rect = column.picture(small_id).unwrap(); + assert_eq!( + small_rect, + [36.0, small_rect[1], 56.0, small_rect[1] + 15.0] + ); + let (_, _, shaped) = column.outlines.iter().find(|(id, ..)| *id == left).unwrap(); + let object = shaped.objects.iter().find(|o| o.id == inline_id).unwrap(); + let [x0, y0, x1, y1] = object.rect; + assert!(x1 <= 200.0 + 0.01); + assert!(((x1 - x0) / (y1 - y0) - 5.0).abs() < 0.01); + let primitives = view.primitives(COLORS).unwrap(); + for rect in [ + wide_rect, + small_rect, + crate::translated(object.rect, [36.0, place(left)[0]]), + ] { + let rect = crate::translated(rect, offset); + assert!( + primitives + .iter() + .any(|primitive| matches!(primitive, Primitive::Image { rect: drawn, .. } if *drawn == rect)), + "{rect:?}" + ); + } + drop(primitives); + let centre = [ + (wide_rect[0] + wide_rect[2]) / 2.0 + offset[0], + (wide_rect[1] + wide_rect[3]) / 2.0 + offset[1], + ]; + assert!(!matches!(view.hit(centre), Some(Hit::Image { .. }))); + click(&mut view, centre, Instant::now()); + assert!(view.object_focus.is_none()); + // Nor does a drag across one, or across its corner, move or resize it. + for from in [ + centre, + [ + wide_rect[2] + offset[0] - 1.0, + wide_rect[3] + offset[1] - 1.0, + ], + ] { + let _ = view.pointer_moved(from).unwrap(); + let _ = view.pointer_pressed(Instant::now()).unwrap(); + let _ = view + .pointer_moved([from[0] + 40.0, from[1] + 30.0]) + .unwrap(); + let _ = view.pointer_released().unwrap(); + assert!(view.object_focus.is_none()); + } + let bounds: Vec<_> = view + .scene + .as_ref() + .unwrap() + .0 + .content_bounds(&view.editor, view.column.as_ref()) + .map(|(rect, _)| rect) + .collect(); + assert!(bounds.contains(&wide_rect) && bounds.contains(&small_rect)); + assert_eq!(view.editor.page().unwrap(), original); + assert!(view.editor.take_ops().unwrap().is_empty()); + assert!(!view.set_reading(None) && view.column.is_none()); +} + #[test] fn reader_consumers_share_title_header_date_and_body_geometry() { let mut view = reader_view(reader_page()); diff --git a/crates/canvas/src/reading.rs b/crates/canvas/src/reading.rs index 4bcf3767a4985e30572cb5ee51a3543fb55b96ea..d1bb7471755a7cd208b44f076760d2ef3dc7beb7 100644 --- a/crates/canvas/src/reading.rs +++ b/crates/canvas/src/reading.rs @@ -28,6 +28,18 @@ pub(crate) struct Column { #[cfg(feature = "interaction")] pub(crate) width: f32, pub(crate) outlines: Vec<(ExGuid, [f32; 2], crate::outline::OutlineLayout)>, + /// Page pictures as the column shows them, `[x0, y0, x1, y1]` in page points. + pub(crate) pictures: Vec<(ExGuid, [f32; 4])>, +} + +#[cfg(feature = "gpu")] +impl Column { + /// Where the column shows page picture `id`; None for one it leaves out. + pub(crate) fn picture(&self, id: ExGuid) -> Option<[f32; 4]> { + self.pictures + .iter() + .find_map(|(picture, rect)| (*picture == id).then_some(*rect)) + } } #[cfg(feature = "gpu")] @@ -51,8 +63,8 @@ pub(crate) fn geometry<'a>( #[cfg(feature = "interaction")] impl Column { - /// The page's editable outlines stacked in reading order, title and its header first, - /// or None where something on the page would not reflow. + /// The page's editable outlines and pictures stacked in reading order, title and its + /// header first, or None where something on the page would not reflow. pub(crate) fn build( editor: &CanvasEditor, scene: Option<&crate::gpu::page::PageScene>, @@ -96,6 +108,7 @@ impl Column { below_title: Some(_), .. } + | Content::Image(_) ) }) { return Ok(None); @@ -128,20 +141,40 @@ impl Column { .position(|block| block.members.contains(&id)) .unwrap_or(ordered.len()) }; - let mut body: Vec<_> = body + enum Piece<'a> { + Text(&'a TextOutline), + Picture(ExGuid, [f32; 2], [f32; 2]), + } + let pictures = editor.objects.iter().filter_map(|object| match object { + Content::Image(image) if !image.background => Some(Piece::Picture( + image.id, + crate::origin(&image.layout), + crate::outline::image_size(image)?, + )), + _ => None, + }); + let mut pieces: Vec<_> = body .into_iter() - .map(|outline| (rank(outline.id), outline)) + .map(Piece::Text) + .chain(pictures) + .map(|piece| { + let (id, origin) = match piece { + Piece::Text(outline) => (outline.id, outline.origin()), + Piece::Picture(id, origin, _) => (id, origin), + }; + ((rank(id), origin), piece) + }) .collect(); - body.sort_by(|(a_rank, a), (b_rank, b)| { - let ([ax, ay], [bx, by]) = (a.origin(), b.origin()); + pieces.sort_by(|((a_rank, [ax, ay]), _), ((b_rank, [bx, by]), _)| { a_rank .cmp(b_rank) - .then(ay.total_cmp(&by)) - .then(ax.total_cmp(&bx)) + .then(ay.total_cmp(by)) + .then(ax.total_cmp(bx)) }); let mut column = Self { width, outlines: Vec::new(), + pictures: Vec::new(), }; let mut top = 14.4; if let Some(title) = title { @@ -159,12 +192,24 @@ impl Column { top = top.max(rect[3] + GAP); } } - for (_, source) in body { - let Some(layout) = column_layout(source, editor, width, engine)? else { - return Ok(None); + for (_, piece) in pieces { + let height = match piece { + Piece::Text(source) => { + let Some(layout) = column_layout(source, editor, width, engine)? else { + return Ok(None); + }; + let height = layout.size[1]; + column.outlines.push((source.id, [36.0, top], layout)); + height + } + Piece::Picture(id, _, [w, h]) => { + let scale = (width / w).min(1.0); + column + .pictures + .push((id, [36.0, top, 36.0 + w * scale, top + h * scale])); + h * scale + } }; - let height = layout.size[1]; - column.outlines.push((source.id, [36.0, top], layout)); top += height + GAP; } Ok(Some(column)) @@ -180,14 +225,6 @@ fn column_layout( engine: &mut TextEngine, ) -> Result, crate::layout::LayoutError> { use crate::outline::{OutlineLayout, ParagraphLayout}; - if source - .document() - .nodes() - .iter() - .any(|node| !matches!(node.content, ParagraphContent::Text(_))) - { - return Ok(None); - } if source.document().paragraphs().any(|text| { text.spans() .iter() @@ -195,8 +232,20 @@ fn column_layout( }) { return Ok(None); } + // Pictures in the flow shrink to the room their level leaves. + let mut nodes = source.document().nodes().to_vec(); + for node in &mut nodes { + match &mut node.content { + ParagraphContent::Text(_) => {} + ParagraphContent::Image(image) => { + let indent = crate::outline::indentation(node.level, &source.indents, width)?; + shrink(image, width - indent); + } + _ => return Ok(None), + } + } let layout = OutlineLayout::flow( - source.document().nodes().iter(), + nodes.iter(), &source.indents, width, false, diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index c2ee82634cfdbf1476f0eedecae98a760263156c..4ca3ed62effea7908c21de63f9ff32e38b8b3f04 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -3419,6 +3419,69 @@ fn reader_stacks_text_boxes_and_stores_an_edit_in_the_second_one() { assert!(texts.contains(&"Lower box, edited in reading 😀".to_string())); } +/// A view of the page `title` of the corpus section `path`, copied, at 100%. +fn reader_corpus_view(path: &str, title: &str) -> (tempfile::TempDir, Section, View) { + let (directory, file) = copy(path); + let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap()); + let section = open(&library, &file.to_string_lossy()); + let space = space(§ion, title); + let (page, read_only) = section.shared.page(space).unwrap(); + let mut view = View { + canvas: ManuallyDrop::new(Canvas::new(space, page, [720, 1280], 2.0).unwrap()), + presentation: None, + frame: Arc::default(), + section: Arc::clone(§ion.shared), + read_only, + reload_pending: false, + local_edits: Arc::new(Mutex::new(LocalEdits::default())), + }; + view.canvas.set_transform(1.0, [0.0; 2]); + (directory, section, view) +} + +#[test] +fn reader_shows_a_corpus_pages_picture_no_wider_than_the_column() { + let (_directory, _section, mut view) = + reader_corpus_view("print/native/Print.one", "Printing test"); + let query = sb_view_reading(&view, 240.0); + let status = serde_json::from_str::(&string(query)).unwrap(); + unsafe { sb_string_free(query) }; + assert_eq!(status["offered"], true); + let mut page = [0.0; 4]; + sb_view_content(&mut view, &mut page); + let mut anchor = [0.0; 2]; + assert!(sb_view_set_reading(&mut view, 240.0, &mut anchor)); + let mut column = [0.0; 4]; + sb_view_content(&mut view, &mut column); + assert!(column[2] < page[2]); + // The picture follows the long outline: the whole column in view. + view.canvas.set_transform(0.05, [0.0; 2]); + let paper = view.canvas.page_paper(); + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let pictures = loop { + view.canvas + .page + .update_pictures(paper, std::task::Waker::noop()); + let pictures: Vec<[f32; 4]> = view + .canvas + .page + .primitives(view.canvas.colors()) + .unwrap() + .iter() + .filter_map(|primitive| match primitive { + draw::Primitive::Image { rect, .. } => Some(*rect), + _ => None, + }) + .collect(); + if !pictures.is_empty() || std::time::Instant::now() > deadline { + break pictures; + } + std::thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(pictures.len(), 1); + assert!(pictures[0][2] - pictures[0][0] <= 240.0 / POINT + 0.01); +} + #[test] fn reader_local_apply_fence_tracks_overlapping_replies_and_view_lifetime() { use std::sync::mpsc;