//! Selecting text across a sequence of paragraphs. //! //! A selection is not a property of one paragraph. A reader who drags from the //! middle of one paragraph into the next means the text between, and a widget //! that can only highlight within a single layout cannot express that. So the //! model here is stated over a *sequence* of laid-out paragraphs — a //! [`ProseRun`] each — and a widget with one paragraph is the degenerate case //! of a widget with many. That is why [`Text`] and a document view share this //! file rather than each carrying its own idea of what a selection is. //! //! # Byte offsets, not cursors //! //! A position here is a paragraph index and a byte offset ([`ProsePoint`]), //! and the parley [`Cursor`]s a highlight needs are derived from it at the //! moment they are needed. Cursors cache positions within a layout, so a //! stored one has to be refreshed every time the text re-wraps; a byte offset //! means the same character at every width. Re-wrapping therefore costs the //! selection nothing, and only a change to the *characters* invalidates it — //! which is a thing the widget already knows about, because it is the one //! making the change. //! //! [`Text`]: crate::widget::Text use std::ops::Range; use guiduck_scene::Fragment; use guiduck_scene::geom::{Point, Rect}; use guiduck_scene::paint::Brush; use parley::editing::{Cursor, Selection as ParleySelection}; use parley::{Affinity, Layout}; use super::Paragraph; use crate::clipboard::{Clipboard, Selection as ClipboardSelection}; use crate::event::{EventKind, Key, KeyInput, PointerButton, PointerEvent}; /// One paragraph a selection may run through: the laid-out text, where it sits /// in the widget's coordinates, and what separates it from the paragraph /// before it when copied text spans both. /// /// The separator belongs to the run rather than to the selection because only /// the widget knows what its paragraphs are to each other: a blank line /// between two blocks of prose, a tab between two cells of the same table row. #[derive(Clone, Copy)] pub struct ProseRun<'a> { paragraph: &'a Paragraph, origin: Point, lead: &'a str, } impl<'a> ProseRun<'a> { /// A run at `origin`, joined to the run before it by a blank line - the /// separation between two paragraphs of prose. pub fn new(paragraph: &'a Paragraph, origin: Point) -> Self { ProseRun { paragraph, origin, lead: "\n\n", } } /// Join this run to the one before it with `lead` instead of a blank line. pub fn led_by(mut self, lead: &'a str) -> Self { self.lead = lead; self } pub fn text(&self) -> &'a str { self.paragraph.text() } /// What joins this run to the one before it in copied or read-out text. pub fn lead(&self) -> &'a str { self.lead } fn layout(&self) -> Option<&'a Layout> { self.paragraph.layout() } /// The run's box in the widget's coordinates, if it has been laid out. fn bounds(&self) -> Option { let layout = self.layout()?; Some(Rect::new( self.origin.x, self.origin.y, self.origin.x + f64::from(layout.width()), self.origin.y + f64::from(layout.height()), )) } /// A point in the widget's coordinates, in this run's own. fn localize(&self, point: Point) -> (f32, f32) { ( (point.x - self.origin.x) as f32, (point.y - self.origin.y) as f32, ) } /// The byte range of this run a parley selection covers. fn byte_range(&self, selection: ParleySelection) -> Range { let range = selection.text_range(); let text = self.text(); let start = text.floor_char_boundary(range.start.min(text.len())); let end = text.floor_char_boundary(range.end.min(text.len())); start..end.max(start) } } /// A position within a sequence of runs: which run, and which byte of it. /// /// Ordered by run and then by byte, which is document order, so comparing two /// positions is how the model knows which end of a drag came first. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] pub struct ProsePoint { pub run: usize, pub byte: usize, } impl ProsePoint { pub fn new(run: usize, byte: usize) -> Self { ProsePoint { run, byte } } } /// A stretch of text across one or more runs: where the drag started, and /// where it is now. The two are kept in that order rather than sorted, because /// extending a selection moves the focus and leaves the anchor - which is what /// makes dragging backwards over the start of a selection behave. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub struct ProseSelection { anchor: ProsePoint, focus: ProsePoint, } impl ProseSelection { pub fn new(anchor: ProsePoint, focus: ProsePoint) -> Self { ProseSelection { anchor, focus } } /// The ends in document order. pub fn ends(&self) -> (ProsePoint, ProsePoint) { if self.anchor <= self.focus { (self.anchor, self.focus) } else { (self.focus, self.anchor) } } /// Whether the selection covers no characters at all. pub fn is_collapsed(&self) -> bool { self.anchor == self.focus } /// The byte range selected within each run the selection touches, in /// document order. Runs outside the selection do not appear; a run wholly /// inside it appears with its whole text. pub fn ranges<'a>(&self, runs: &'a [ProseRun<'a>]) -> Vec<(usize, Range)> { let (start, end) = self.ends(); (start.run..=end.run) .filter(|run| *run < runs.len()) .filter_map(|run| { let text = runs[run].text(); let from = if run == start.run { start.byte } else { 0 }; let to = if run == end.run { end.byte } else { text.len() }; let from = text.floor_char_boundary(from.min(text.len())); let to = text.floor_char_boundary(to.min(text.len())); (from < to).then_some((run, from..to)) }) .collect() } } /// The selection a text-bearing widget carries, and the pointer and key /// handling that maintains it. /// /// One implementation for a paragraph and for a document: what a widget /// supplies is its [`ProseRun`]s, and everything a reader can do to a /// selection - drag it, double-click a word, triple-click a line, Ctrl+A, /// Ctrl+C, middle-click paste from the primary selection - happens here. A /// widget that answered these events itself would be a second definition of /// what selecting text means, and the two would drift. #[derive(Default)] pub struct SelectionState { selection: Option, /// A left press began a drag and moves are extending the selection. dragging: bool, } /// What the widget must do about an event the selection has seen. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct SelectionResponse { /// The event was the selection's business; the widget should consume it /// rather than pass it on. pub consumed: bool, /// The highlight changed, so the widget needs a repaint. pub repaint: bool, } impl SelectionResponse { const IGNORED: Self = SelectionResponse { consumed: false, repaint: false, }; fn consumed(repaint: bool) -> Self { SelectionResponse { consumed: true, repaint, } } } impl SelectionState { pub fn selection(&self) -> Option { self.selection } /// Whether anything is highlighted. A collapsed selection - a click that /// placed a caret and selected nothing - counts as nothing. pub fn is_empty(&self) -> bool { self.selection.is_none_or(|s| s.is_collapsed()) } /// Drop the selection, reporting whether there was one to drop. Called /// when focus leaves, so at most one widget is highlighted at a time, and /// when the text changes, because byte offsets into replaced characters /// name nothing. pub fn clear(&mut self) -> bool { self.dragging = false; self.selection.take().is_some() } /// The selected text, with each run after the first preceded by whatever /// its [`ProseRun`] says joins it to the one before. pub fn selected_text(&self, runs: &[ProseRun]) -> Option { let ranges = self.selection?.ranges(runs); let mut text = String::new(); for (run, range) in ranges { if !text.is_empty() { text.push_str(runs[run].lead); } text.push_str(&runs[run].text()[range]); } (!text.is_empty()).then_some(text) } /// Publish the selection as the primary selection, the Wayland/X11 /// convention that middle-click then pastes - through /// [`crate::widget::sync_primary_selection`], which is that convention's /// one definition, shared with the text input, so it cannot come to mean /// two things. An empty selection leaves the primary alone. pub fn sync_primary(&self, runs: &[ProseRun], clipboard: &mut dyn Clipboard) { let selected = self.selected_text(runs); crate::widget::sync_primary_selection(clipboard, selected.as_deref()); } /// Select everything, from the first byte of the first run to the last of /// the last. pub fn select_all(&mut self, runs: &[ProseRun]) -> bool { let Some(last) = runs.len().checked_sub(1) else { return false; }; self.selection = Some(ProseSelection::new( ProsePoint::new(0, 0), ProsePoint::new(last, runs[last].text().len()), )); true } /// Paint the highlight, under the glyphs so the text reads over it. pub fn paint(&self, fragment: &mut Fragment, runs: &[ProseRun], brush: &Brush) { let Some(selection) = self.selection else { return; }; for (run, range) in selection.ranges(runs) { let run = runs[run]; let Some(layout) = run.layout() else { continue; }; parley_selection(layout, range).geometry_with(layout, |rect, _line| { fragment.fill( Rect::new( rect.x0 + run.origin.x, rect.y0 + run.origin.y, rect.x1 + run.origin.x, rect.y1 + run.origin.y, ), brush.clone(), ); }); } } /// Ctrl+C copies the selection; Ctrl+A selects everything. Other keys are /// declined, so they bubble on to shortcuts and Tab traversal. pub fn on_key( &mut self, key: &KeyInput, runs: &[ProseRun], clipboard: &mut dyn Clipboard, ) -> SelectionResponse { if !key.modifiers.ctrl { return SelectionResponse::IGNORED; } let Key::Character(c) = &key.key else { return SelectionResponse::IGNORED; }; match c.as_str() { "c" | "C" => { if let Some(selected) = self.selected_text(runs) { clipboard.set_text(ClipboardSelection::Clipboard, &selected); } SelectionResponse::consumed(false) } "a" | "A" => { if !self.select_all(runs) { return SelectionResponse::IGNORED; } self.sync_primary(runs, clipboard); SelectionResponse::consumed(true) } _ => SelectionResponse::IGNORED, } } /// Pointer selection: a left press anchors it and moves extend it, a /// double press takes the word under the pointer and a triple the line, /// and the release publishes the result to the primary selection. /// /// A press alone has to stay free to become a drag, so nothing here /// consumes a *click*; a widget with links decides that for itself, on the /// evidence of whether the drag selected anything. pub fn on_pointer( &mut self, kind: EventKind, event: &PointerEvent, runs: &[ProseRun], clipboard: &mut dyn Clipboard, ) -> SelectionResponse { match kind { EventKind::PointerDown if event.button == Some(PointerButton::Left) => { let Some(point) = position_at(runs, event.local) else { return SelectionResponse::IGNORED; }; self.selection = Some(match event.click_count { 0 | 1 => ProseSelection::new(point, point), 2 => word_at(runs, event.local).unwrap_or(ProseSelection::new(point, point)), _ => line_at(runs, event.local).unwrap_or(ProseSelection::new(point, point)), }); // A single click may grow into a drag; a multi-click selection // is complete at the press, so it publishes now and does not // drag (extending it would be by character, not by word). self.dragging = event.click_count <= 1; if event.click_count >= 2 { self.sync_primary(runs, clipboard); } SelectionResponse::consumed(true) } EventKind::PointerMove if self.dragging => { let (Some(selection), Some(focus)) = (self.selection, position_at(runs, event.local)) else { return SelectionResponse::consumed(false); }; let extended = ProseSelection::new(selection.anchor, focus); let repaint = extended != selection; self.selection = Some(extended); SelectionResponse::consumed(repaint) } EventKind::PointerUp if event.button == Some(PointerButton::Left) => { let was_dragging = self.dragging; self.dragging = false; if was_dragging { self.sync_primary(runs, clipboard); } SelectionResponse::consumed(false) } _ => SelectionResponse::IGNORED, } } } /// The parley selection covering `range` of a laid-out paragraph. Derived /// where it is needed rather than stored; see the module docs. fn parley_selection(layout: &Layout, range: Range) -> ParleySelection { ParleySelection::new( Cursor::from_byte_index(layout, range.start, Affinity::Downstream), Cursor::from_byte_index(layout, range.end, Affinity::Upstream), ) } /// Which run a point in the widget's coordinates falls in, and where in it. /// /// A point outside every run still resolves to one: a drag that leaves the /// text on its way to somewhere else should keep selecting, and a reader who /// drags past the last line means the end of it. The run that contains the /// point wins; failing that, the nearest one does. fn position_at(runs: &[ProseRun], point: Point) -> Option { let (index, run) = nearest_run(runs, point)?; let layout = run.layout()?; let (x, y) = run.localize(point); let cursor = Cursor::from_point(layout, x, y); let text = run.text(); let byte = text.floor_char_boundary(cursor.index().min(text.len())); Some(ProsePoint::new(index, byte)) } /// The word under a point, as a selection. fn word_at(runs: &[ProseRun], point: Point) -> Option { unit_at(runs, point, ParleySelection::word_from_point) } /// The line under a point, as a selection. fn line_at(runs: &[ProseRun], point: Point) -> Option { unit_at(runs, point, ParleySelection::line_from_point) } /// The word or line under a point, as a selection within the run it landed in. /// A multi-click selects within one paragraph: the unit it names does not /// straddle two, whatever the pointer is over. fn unit_at( runs: &[ProseRun], point: Point, unit: fn(&Layout, f32, f32) -> ParleySelection, ) -> Option { let (index, run) = nearest_run(runs, point)?; let layout = run.layout()?; let (x, y) = run.localize(point); let range = run.byte_range(unit(layout, x, y)); Some(ProseSelection::new( ProsePoint::new(index, range.start), ProsePoint::new(index, range.end), )) } /// The run a point is in, or the closest one to it. fn nearest_run<'a>(runs: &'a [ProseRun<'a>], point: Point) -> Option<(usize, ProseRun<'a>)> { let mut nearest: Option<(usize, ProseRun, f64)> = None; for (index, run) in runs.iter().enumerate() { let Some(bounds) = run.bounds() else { continue; }; let distance = rect_distance(bounds, point); if distance == 0.0 { return Some((index, *run)); } if nearest.is_none_or(|(_, _, best)| distance < best) { nearest = Some((index, *run, distance)); } } nearest.map(|(index, run, _)| (index, run)) } /// How far a point lies outside a rectangle; zero when it is inside. /// /// Vertical distance dominates: the runs of a document are stacked, so a /// pointer to the left of one line is in that line, not in a shorter line /// above it that happens to end nearer the pointer. fn rect_distance(rect: Rect, point: Point) -> f64 { let dx = (rect.x0 - point.x).max(point.x - rect.x1).max(0.0); let dy = (rect.y0 - point.y).max(point.y - rect.y1).max(0.0); dy * 1024.0 + dx } #[cfg(test)] mod tests { use super::*; use crate::text::RichText; /// Paragraphs with content but no layout: the model's ranges and the text /// it yields are byte arithmetic over the characters, which is exactly the /// property that makes a re-wrap cost the selection nothing. fn paragraphs(texts: &[&str]) -> Vec { texts .iter() .map(|text| { let mut paragraph = Paragraph::default(); paragraph.set_content(RichText::new(*text)); paragraph }) .collect() } fn runs<'a>(paragraphs: &'a [Paragraph], lead: &'a str) -> Vec> { paragraphs .iter() .map(|p| ProseRun::new(p, Point::ORIGIN).led_by(lead)) .collect() } fn selecting(selection: ProseSelection) -> SelectionState { SelectionState { selection: Some(selection), dragging: false, } } #[test] fn a_selection_within_one_run_names_one_range() { let paragraphs = paragraphs(&["First block.", "Second block."]); let runs = runs(¶graphs, "\n"); let selection = ProseSelection::new(ProsePoint::new(0, 0), ProsePoint::new(0, 5)); assert_eq!(selection.ranges(&runs), vec![(0, 0..5)]); assert_eq!( selecting(selection).selected_text(&runs).as_deref(), Some("First") ); } #[test] fn a_selection_across_runs_takes_all_of_the_ones_between() { let paragraphs = paragraphs(&["First.", "Middle.", "Last."]); let runs = runs(¶graphs, "\n"); let selection = ProseSelection::new(ProsePoint::new(0, 3), ProsePoint::new(2, 4)); assert_eq!( selection.ranges(&runs), vec![(0, 3..6), (1, 0..7), (2, 0..4)] ); assert_eq!( selecting(selection).selected_text(&runs).as_deref(), Some("st.\nMiddle.\nLast") ); } #[test] fn a_backwards_drag_selects_the_same_text() { let paragraphs = paragraphs(&["First.", "Last."]); let runs = runs(¶graphs, "\n"); let forwards = ProseSelection::new(ProsePoint::new(0, 3), ProsePoint::new(1, 4)); let backwards = ProseSelection::new(ProsePoint::new(1, 4), ProsePoint::new(0, 3)); assert_eq!(forwards.ranges(&runs), backwards.ranges(&runs)); } #[test] fn each_run_brings_its_own_separator() { let paragraphs = paragraphs(&["Name", "Score", "Next row"]); // A table row: the cells after the first are tab-joined, the next row // starts a line. let runs = vec![ ProseRun::new(¶graphs[0], Point::ORIGIN).led_by("\n"), ProseRun::new(¶graphs[1], Point::ORIGIN).led_by("\t"), ProseRun::new(¶graphs[2], Point::ORIGIN).led_by("\n"), ]; let mut state = SelectionState::default(); assert!(state.select_all(&runs)); assert_eq!( state.selected_text(&runs).as_deref(), Some("Name\tScore\nNext row") ); } #[test] fn a_collapsed_selection_is_no_selection() { let paragraphs = paragraphs(&["Something."]); let runs = runs(¶graphs, "\n"); let point = ProsePoint::new(0, 4); let state = selecting(ProseSelection::new(point, point)); assert!(state.is_empty()); assert_eq!(state.selected_text(&runs), None); } #[test] fn selecting_all_of_nothing_selects_nothing() { let mut state = SelectionState::default(); assert!(!state.select_all(&[])); assert!(state.is_empty()); } #[test] fn a_shrunken_document_does_not_take_ranges_with_it() { // The widget clears the selection when its text changes; if one // survived anyway, it names bytes and runs that are gone, and asking // for them is not a panic. let paragraphs = paragraphs(&["Short."]); let runs = runs(¶graphs, "\n"); let selection = ProseSelection::new(ProsePoint::new(0, 2), ProsePoint::new(4, 900)); assert_eq!(selection.ranges(&runs), vec![(0, 2..6)]); } }