selection.rs
raw
//! 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<Brush>> {
self.paragraph.layout()
}
/// The run's box in the widget's coordinates, if it has been laid out.
fn bounds(&self) -> Option<Rect> {
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<usize> {
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<usize>)> {
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<ProseSelection>,
/// 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<ProseSelection> {
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<String> {
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<Brush>, range: Range<usize>) -> 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<ProsePoint> {
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<ProseSelection> {
unit_at(runs, point, ParleySelection::word_from_point)
}
/// The line under a point, as a selection.
fn line_at(runs: &[ProseRun], point: Point) -> Option<ProseSelection> {
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<Brush>, f32, f32) -> ParleySelection,
) -> Option<ProseSelection> {
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<Paragraph> {
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<ProseRun<'a>> {
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)]);
}
}