From f2300b7f791d89840114e52f007f185d6c858e89 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 23:26:47 -0700 Subject: [PATCH] feat(mobile): show pictures in the Reading View Pictures on the page now take their place in the Reading View's reading order instead of keeping the page out of it. Each shows at the column's width, or at its own size where that is smaller, and pictures inside a text box shrink the same way within it. They are only shown there: a tap or drag on one does not select, move or resize it, a picture picked in the page view lets go when the Reading View opens, and the page keeps every picture where it was. Refs #128 Assisted-by: claude-opus-5.5 --- crates/canvas/src/gpu/page.rs | 89 +++++++++++----- crates/canvas/src/interaction/mod.rs | 22 +++- crates/canvas/src/interaction/tests.rs | 140 +++++++++++++++++++++++++ crates/canvas/src/reading.rs | 93 ++++++++++++---- crates/mobile/src/tests.rs | 63 +++++++++++ 5 files changed, 353 insertions(+), 54 deletions(-) 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; -- 2.54.0