Skip to main content

damascene_core/
selection.rs

1//! Library-level text selection model.
2//!
3//! Selection is a single, application-owned [`Selection`] value that
4//! identifies *which* keyed text-bearing element holds the active
5//! selection and *where* in that element's text the anchor and head
6//! sit. The library enforces the single-selection invariant by
7//! emitting `SelectionChanged` events; the app folds them into its
8//! `Selection` field the same way it folds `apply_event` results into
9//! a [`crate::widgets::text_input::TextSelection`] today.
10//!
11//! # Model
12//!
13//! - [`Selection`] — the slot, holds an `Option<SelectionRange>`.
14//! - [`SelectionRange`] — anchor + head, both [`SelectionPoint`].
15//! - [`SelectionPoint`] — `(key, byte)`. The key references the same
16//!   widget-key form that `focus_order` already uses; the byte indexes
17//!   into that element's text content.
18//!
19//! When `anchor.key == head.key` the selection lives entirely inside
20//! one leaf — equivalent to a [`crate::widgets::text_input::TextSelection`]
21//! over that leaf's text. When they differ, the selection spans
22//! multiple leaves in document order.
23//!
24//! # Key requirement
25//!
26//! Selectable leaves must carry an explicit `.key(...)` — same
27//! convention as focusable widgets. Without a key the leaf is silently
28//! excluded from `selection_order` because nothing could survive a
29//! tree rebuild as a stable identity. See [`crate::tree::El::selectable`].
30
31// Lock in full per-item documentation for this module (issue #73).
32#![warn(missing_docs)]
33
34use std::ops::Range;
35
36use crate::tree::{El, Kind};
37use crate::widgets::text_input::TextSelection;
38
39/// The application's single selection slot. `None` means nothing is
40/// selected. The library emits `SelectionChanged` events that fold
41/// into this; widgets read it back to draw highlight bands and route
42/// editing operations.
43#[derive(Clone, Debug, Default, PartialEq, Eq)]
44pub struct Selection {
45    /// The active range, or `None` when nothing is selected.
46    pub range: Option<SelectionRange>,
47}
48
49/// A non-empty selection range. `anchor` is where the user started
50/// (pointer-down); `head` is where they ended up (pointer current /
51/// last move). The pair may be in tree order or reversed.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct SelectionRange {
54    /// Where the selection started (pointer-down).
55    pub anchor: SelectionPoint,
56    /// Where the selection currently ends (pointer position / last
57    /// extension).
58    pub head: SelectionPoint,
59}
60
61/// A point inside a selectable leaf's text content. `key` is the
62/// widget key that owns the leaf; `byte` is a byte offset into that
63/// leaf's text (clamped to a UTF-8 char boundary by anything that
64/// reads or writes it).
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct SelectionPoint {
67    /// Widget key of the selectable leaf that owns this point.
68    pub key: String,
69    /// Byte offset into that leaf's visible text. Readers clamp it to
70    /// a UTF-8 char boundary before slicing.
71    pub byte: usize,
72}
73
74impl SelectionPoint {
75    /// A point at `byte` inside the leaf keyed `key`.
76    pub fn new(key: impl Into<String>, byte: usize) -> Self {
77        Self {
78            key: key.into(),
79            byte,
80        }
81    }
82}
83
84/// Source-backed copy/hit-test payload for a selectable rich-text
85/// node.
86///
87/// `visible` is the logical text users point at while selecting;
88/// `source` is what copy should return. `spans` maps byte ranges in
89/// `visible` to byte ranges in `source`. A plain text leaf has one
90/// identity span. Markdown can instead map rendered words to their
91/// original markdown source, and atomic embeds such as math can map a
92/// one-byte object slot to the full `$...$` / `$$...$$` source.
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct SelectionSource {
95    /// What copy should return — the underlying source text (e.g. raw
96    /// markdown).
97    pub source: String,
98    /// The rendered text users point at while selecting.
99    pub visible: String,
100    /// Mappings from `visible` byte ranges to `source` byte ranges, in
101    /// visible order.
102    pub spans: Vec<SelectionSourceSpan>,
103    /// Dedup group for full-leaf selections: adjacent leaves carrying
104    /// the same group emit their shared `source_full` text once when a
105    /// selection covers all of them (e.g. cells of one markdown table
106    /// row). Set via [`Self::full_selection_group()`].
107    pub full_selection_group: Option<String>,
108}
109
110/// One `visible` → `source` byte-range mapping inside a
111/// [`SelectionSource`].
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct SelectionSourceSpan {
114    /// Byte range in [`SelectionSource::visible`] this span covers.
115    pub visible: Range<usize>,
116    /// Corresponding byte range in [`SelectionSource::source`], used
117    /// for partial slices inside the span.
118    pub source: Range<usize>,
119    /// Byte range in [`SelectionSource::source`] emitted when the span
120    /// is selected in full (or is atomic) — may include surrounding
121    /// markup, e.g. `**bold**` for a visible `bold`.
122    pub source_full: Range<usize>,
123    /// Treat the span as indivisible: any overlap selects the whole
124    /// `source_full` range (math embeds, object slots).
125    pub atomic: bool,
126}
127
128impl SelectionSource {
129    /// A payload with the given source and visible text and no spans
130    /// yet — add mappings with [`Self::push_span`] /
131    /// [`Self::push_span_with_full_source`].
132    pub fn new(source: impl Into<String>, visible: impl Into<String>) -> Self {
133        Self {
134            source: source.into(),
135            visible: visible.into(),
136            spans: Vec::new(),
137            full_selection_group: None,
138        }
139    }
140
141    /// A plain-text payload: `visible` and `source` are the same
142    /// string, mapped by a single identity span.
143    pub fn identity(text: impl Into<String>) -> Self {
144        let text = text.into();
145        let len = text.len();
146        Self {
147            source: text.clone(),
148            visible: text,
149            spans: vec![SelectionSourceSpan {
150                visible: 0..len,
151                source: 0..len,
152                source_full: 0..len,
153                atomic: false,
154            }],
155            full_selection_group: None,
156        }
157    }
158
159    /// Builder-style: tag this payload with a full-selection dedup
160    /// group (see the [`full_selection_group`][field@Self::full_selection_group]
161    /// field docs).
162    pub fn full_selection_group(mut self, group: impl Into<String>) -> Self {
163        self.full_selection_group = Some(group.into());
164        self
165    }
166
167    /// Append a span mapping `visible` to `source`, with
168    /// `source_full = source`. Out-of-bounds or inverted ranges are
169    /// dropped silently.
170    pub fn push_span(&mut self, visible: Range<usize>, source: Range<usize>, atomic: bool) {
171        self.push_span_with_full_source(visible, source.clone(), source, atomic);
172    }
173
174    /// Append a span whose full-selection copy text (`source_full`)
175    /// differs from its partial-slice range (`source`) — e.g. a
176    /// visible `bold` mapping to source `bold` inside the full
177    /// `**bold**`. Out-of-bounds or inverted ranges are dropped
178    /// silently.
179    pub fn push_span_with_full_source(
180        &mut self,
181        visible: Range<usize>,
182        source: Range<usize>,
183        source_full: Range<usize>,
184        atomic: bool,
185    ) {
186        if visible.start <= visible.end
187            && visible.end <= self.visible.len()
188            && source.start <= source.end
189            && source.end <= self.source.len()
190            && source_full.start <= source_full.end
191            && source_full.end <= self.source.len()
192        {
193            self.spans.push(SelectionSourceSpan {
194                visible,
195                source,
196                source_full,
197                atomic,
198            });
199        }
200    }
201
202    /// Byte length of the visible text — the range selection offsets
203    /// index into.
204    pub fn visible_len(&self) -> usize {
205        self.visible.len()
206    }
207
208    /// Map the visible byte range `a..b` (order-insensitive, clamped
209    /// to char boundaries) to one contiguous slice of `source`.
210    /// Selecting the whole visible text returns the whole source.
211    /// `None` when the mapped range collapses. Unlike
212    /// [`Self::source_text_for_visible`] this cannot stitch
213    /// per-span `source_full` pieces; prefer the owned variant for
214    /// copy text.
215    pub fn source_slice_for_visible(&self, a: usize, b: usize) -> Option<&str> {
216        let (a, b) = (a.min(b), a.max(b));
217        if a == 0 && b >= self.visible.len() && !self.source.is_empty() {
218            return Some(&self.source);
219        }
220        let a = clamp_to_char_boundary(&self.visible, a.min(self.visible.len()));
221        let b = clamp_to_char_boundary(&self.visible, b.min(self.visible.len()));
222        let lo = self.source_offset_for_visible(a, Bias::Start)?;
223        let hi = self.source_offset_for_visible(b, Bias::End)?;
224        let (lo, hi) = (lo.min(hi), lo.max(hi));
225        let lo = clamp_to_char_boundary(&self.source, lo.min(self.source.len()));
226        let hi = clamp_to_char_boundary(&self.source, hi.min(self.source.len()));
227        (lo < hi).then(|| &self.source[lo..hi])
228    }
229
230    /// Copy text for the visible byte range `a..b` (order-insensitive,
231    /// clamped to char boundaries): walks the spans, emitting
232    /// `source_full` for atomic or fully-covered spans and
233    /// proportional partial slices otherwise. Selecting the whole
234    /// visible text returns the whole source. `None` when nothing
235    /// maps. This is what [`selected_text`] uses for source-backed
236    /// leaves.
237    pub fn source_text_for_visible(&self, a: usize, b: usize) -> Option<String> {
238        let (a, b) = (a.min(b), a.max(b));
239        if a == 0 && b >= self.visible.len() && !self.source.is_empty() {
240            return Some(self.source.clone());
241        }
242        let a = clamp_to_char_boundary(&self.visible, a.min(self.visible.len()));
243        let b = clamp_to_char_boundary(&self.visible, b.min(self.visible.len()));
244        if a >= b {
245            return None;
246        }
247        if self.spans.is_empty() {
248            return self.source_slice_for_visible(a, b).map(str::to_string);
249        }
250
251        let mut out = String::new();
252        for span in &self.spans {
253            let start = a.max(span.visible.start);
254            let end = b.min(span.visible.end);
255            if start >= end {
256                continue;
257            }
258            if span.atomic || (start == span.visible.start && end == span.visible.end) {
259                out.push_str(&self.source[span.source_full.clone()]);
260                continue;
261            }
262            let lo = source_offset_in_span(span, start, Bias::Start)?;
263            let hi = source_offset_in_span(span, end, Bias::End)?;
264            let (lo, hi) = (lo.min(hi), lo.max(hi));
265            let lo = clamp_to_char_boundary(&self.source, lo.min(self.source.len()));
266            let hi = clamp_to_char_boundary(&self.source, hi.min(self.source.len()));
267            if lo < hi {
268                out.push_str(&self.source[lo..hi]);
269            }
270        }
271        if out.is_empty() { None } else { Some(out) }
272    }
273
274    fn full_group_for_visible(&self, start: usize, end: usize) -> Option<&str> {
275        (start == 0 && end >= self.visible.len())
276            .then_some(self.full_selection_group.as_deref())
277            .flatten()
278    }
279
280    fn source_offset_for_visible(&self, byte: usize, bias: Bias) -> Option<usize> {
281        if self.spans.is_empty() {
282            return Some(byte.min(self.source.len()));
283        }
284        for span in &self.spans {
285            if byte < span.visible.start || byte > span.visible.end {
286                continue;
287            }
288            if byte == span.visible.end && byte != span.visible.start && matches!(bias, Bias::Start)
289            {
290                continue;
291            }
292            if span.atomic {
293                return Some(match bias {
294                    Bias::Start => span.source.start,
295                    Bias::End => span.source.end,
296                });
297            }
298            let visible_len = span.visible.end.saturating_sub(span.visible.start);
299            let source_len = span.source.end.saturating_sub(span.source.start);
300            if visible_len == 0 {
301                return Some(match bias {
302                    Bias::Start => span.source.start,
303                    Bias::End => span.source.end,
304                });
305            }
306            let offset = byte.saturating_sub(span.visible.start).min(visible_len);
307            let mapped = if source_len == visible_len {
308                span.source.start + offset
309            } else {
310                span.source.start
311                    + ((offset as f32 / visible_len as f32) * source_len as f32) as usize
312            };
313            return Some(mapped.min(span.source.end));
314        }
315        let first = self.spans.first()?;
316        if byte <= first.visible.start {
317            return Some(first.source.start);
318        }
319        let last = self.spans.last()?;
320        if byte >= last.visible.end {
321            return Some(last.source.end);
322        }
323        self.spans
324            .windows(2)
325            .find(|pair| byte > pair[0].visible.end && byte < pair[1].visible.start)
326            .map(|pair| match bias {
327                Bias::Start => pair[0].source.end,
328                Bias::End => pair[1].source.start,
329            })
330    }
331}
332
333fn source_offset_in_span(span: &SelectionSourceSpan, byte: usize, bias: Bias) -> Option<usize> {
334    if span.atomic {
335        return Some(match bias {
336            Bias::Start => span.source_full.start,
337            Bias::End => span.source_full.end,
338        });
339    }
340    let visible_len = span.visible.end.saturating_sub(span.visible.start);
341    let source_len = span.source.end.saturating_sub(span.source.start);
342    if visible_len == 0 {
343        return Some(match bias {
344            Bias::Start => span.source.start,
345            Bias::End => span.source.end,
346        });
347    }
348    let offset = byte.saturating_sub(span.visible.start).min(visible_len);
349    let mapped = if source_len == visible_len {
350        span.source.start + offset
351    } else {
352        span.source.start + ((offset as f32 / visible_len as f32) * source_len as f32) as usize
353    };
354    Some(mapped.min(span.source.end))
355}
356
357#[derive(Clone, Copy)]
358enum Bias {
359    Start,
360    End,
361}
362
363impl Selection {
364    /// A collapsed caret at `(key, byte)`. Convenience for tests and
365    /// app-side initialization.
366    pub fn caret(key: impl Into<String>, byte: usize) -> Self {
367        let pt = SelectionPoint::new(key, byte);
368        Self {
369            range: Some(SelectionRange {
370                anchor: pt.clone(),
371                head: pt,
372            }),
373        }
374    }
375
376    /// True when there is no active selection.
377    pub fn is_empty(&self) -> bool {
378        self.range.is_none()
379    }
380
381    /// True when the selection lives entirely inside `key` — both
382    /// anchor and head reference it. False for cross-element
383    /// selections and for the empty selection.
384    pub fn is_within(&self, key: &str) -> bool {
385        match &self.range {
386            Some(r) => r.anchor.key == key && r.head.key == key,
387            None => false,
388        }
389    }
390
391    /// True when `key` is the anchor's key (the originating leaf).
392    pub fn anchored_at(&self, key: &str) -> bool {
393        self.range.as_ref().is_some_and(|r| r.anchor.key == key)
394    }
395
396    /// View the selection through one leaf's lens: returns
397    /// `Some(TextSelection)` only when the selection lives entirely
398    /// inside `key`. Cross-element selections return `None` here —
399    /// callers that need a per-leaf slice for a spanned leaf should
400    /// instead consult the document-order range.
401    pub fn within(&self, key: &str) -> Option<TextSelection> {
402        let r = self.range.as_ref()?;
403        if r.anchor.key == key && r.head.key == key {
404            Some(TextSelection {
405                anchor: r.anchor.byte,
406                head: r.head.byte,
407            })
408        } else {
409            None
410        }
411    }
412
413    /// Replace this selection's slice for `key` from a freshly
414    /// produced [`TextSelection`]. Used by editable widgets after
415    /// folding an event: take the slice via [`Self::within`], let the
416    /// widget mutate it, and write it back. No-op when the selection
417    /// isn't currently within `key`.
418    pub fn set_within(&mut self, key: &str, sel: TextSelection) {
419        let Some(r) = self.range.as_mut() else { return };
420        if r.anchor.key == key && r.head.key == key {
421            r.anchor.byte = sel.anchor;
422            r.head.byte = sel.head;
423        }
424    }
425
426    /// Clear the selection.
427    pub fn clear(&mut self) {
428        self.range = None;
429    }
430}
431
432/// Compute the byte range within `key`'s text that should be
433/// highlighted, given the current `selection` and the document-order
434/// list of selectable leaves. Returns `None` when `key` isn't part
435/// of the selection range.
436///
437/// The painter calls this for each selectable leaf to decide whether
438/// (and where) to draw a highlight band:
439///
440/// - Single-leaf selection: returns the `(lo, hi)` byte range when
441///   `key` matches both endpoints.
442/// - Anchor leaf (in cross-leaf): returns `(anchor.byte, text_len)`
443///   for the leaf where the drag started.
444/// - Head leaf (in cross-leaf): returns `(0, head.byte)` for the
445///   leaf where the drag currently ends.
446/// - Middle leaf: returns `(0, text_len)` — fully selected.
447///
448/// Anchor / head are normalized to document order using
449/// `order` (keys in tree order, e.g. `selection_order` from
450/// [`crate::state::UiState::selection_order`]).
451pub fn slice_for_leaf(
452    selection: &Selection,
453    order: &[crate::event::UiTarget],
454    key: &str,
455    text_len: usize,
456) -> Option<(usize, usize)> {
457    let r = selection.range.as_ref()?;
458    if r.anchor.key == r.head.key {
459        if r.anchor.key != key {
460            return None;
461        }
462        let (lo, hi) = (
463            r.anchor.byte.min(r.head.byte).min(text_len),
464            r.anchor.byte.max(r.head.byte).min(text_len),
465        );
466        return (lo < hi).then_some((lo, hi));
467    }
468    let pos = |k: &str| order.iter().position(|t| t.key == k);
469    let (a_idx, h_idx, key_idx) = (pos(&r.anchor.key)?, pos(&r.head.key)?, pos(key)?);
470    let (lo_idx, lo_byte, hi_idx, hi_byte) = if a_idx <= h_idx {
471        (a_idx, r.anchor.byte, h_idx, r.head.byte)
472    } else {
473        (h_idx, r.head.byte, a_idx, r.anchor.byte)
474    };
475    if key_idx < lo_idx || key_idx > hi_idx {
476        return None;
477    }
478    let lo = if key_idx == lo_idx {
479        lo_byte.min(text_len)
480    } else {
481        0
482    };
483    let hi = if key_idx == hi_idx {
484        hi_byte.min(text_len)
485    } else {
486        text_len
487    };
488    (lo < hi).then_some((lo, hi))
489}
490
491/// Walk `tree` and return the substring covered by `selection`.
492/// Returns `None` for an empty selection or when the selection
493/// references a key with no matching keyed text leaf in the tree.
494///
495/// For single-leaf selections (the only kind P1a renders) the
496/// returned string is `value[lo..hi]` for that leaf. Cross-leaf
497/// selections walk in tree order: anchor leaf from anchor.byte to
498/// end, every leaf strictly between anchor and head fully, head leaf
499/// up to head.byte. Joined with `\n` between leaves.
500pub fn selected_text(tree: &El, selection: &Selection) -> Option<String> {
501    let r = selection.range.as_ref()?;
502    if r.anchor.key == r.head.key {
503        if let Some(source) = find_keyed_selection_source(tree, &r.anchor.key) {
504            let lo = r.anchor.byte.min(r.head.byte);
505            let hi = r.anchor.byte.max(r.head.byte);
506            return source.source_text_for_visible(lo, hi);
507        }
508        let value = find_keyed_text(tree, &r.anchor.key)?;
509        // Selections are app-owned and can outlive the text they were
510        // made against (streaming/edited leaves), so the offsets may
511        // land mid-codepoint — snap to a boundary before slicing.
512        let lo = clamp_to_char_boundary(&value, r.anchor.byte.min(r.head.byte).min(value.len()));
513        let hi = clamp_to_char_boundary(&value, r.anchor.byte.max(r.head.byte).min(value.len()));
514        if lo >= hi {
515            return None;
516        }
517        return Some(value[lo..hi].to_string());
518    }
519    // Cross-leaf walk in tree order.
520    let mut leaves: Vec<(String, LeafSelectionText)> = Vec::new();
521    collect_keyed_selection_leaves(tree, &mut leaves);
522    let anchor_idx = leaves.iter().position(|(k, _)| *k == r.anchor.key)?;
523    let head_idx = leaves.iter().position(|(k, _)| *k == r.head.key)?;
524    let (lo_idx, lo_byte, hi_idx, hi_byte) = if anchor_idx <= head_idx {
525        (anchor_idx, r.anchor.byte, head_idx, r.head.byte)
526    } else {
527        (head_idx, r.head.byte, anchor_idx, r.anchor.byte)
528    };
529    let mut out = String::new();
530    let mut last_group: Option<String> = None;
531    for (i, (_, value)) in leaves
532        .iter()
533        .enumerate()
534        .skip(lo_idx)
535        .take(hi_idx - lo_idx + 1)
536    {
537        let start = if i == lo_idx {
538            lo_byte.min(value.visible_len())
539        } else {
540            0
541        };
542        let end = if i == hi_idx {
543            hi_byte.min(value.visible_len())
544        } else {
545            value.visible_len()
546        };
547        if start >= end {
548            continue;
549        }
550        let Some(slice) = value.source_text_for_visible(start, end) else {
551            continue;
552        };
553        let group = value.full_group_for_visible(start, end).map(str::to_string);
554        if group.is_some() && group == last_group {
555            continue;
556        }
557        if !out.is_empty() {
558            out.push('\n');
559        }
560        out.push_str(&slice);
561        last_group = group;
562    }
563    if out.is_empty() { None } else { Some(out) }
564}
565
566pub(crate) fn find_keyed_text(node: &El, key: &str) -> Option<String> {
567    if node.key.as_deref() == Some(key) {
568        if let Some(source) = &node.selection_source {
569            return Some(source.visible.clone());
570        }
571        if matches!(node.kind, Kind::Text | Kind::Heading)
572            && let Some(t) = &node.text
573        {
574            return Some(t.clone());
575        }
576        let mut out = String::new();
577        collect_text_content(node, &mut out);
578        if !out.is_empty() {
579            return Some(out);
580        }
581    }
582    node.children.iter().find_map(|c| find_keyed_text(c, key))
583}
584
585pub(crate) fn find_keyed_selection_source(node: &El, key: &str) -> Option<SelectionSource> {
586    if node.key.as_deref() == Some(key)
587        && let Some(source) = &node.selection_source
588    {
589        return Some((**source).clone());
590    }
591    node.children
592        .iter()
593        .find_map(|c| find_keyed_selection_source(c, key))
594}
595
596fn collect_text_content(node: &El, out: &mut String) {
597    if matches!(node.kind, Kind::Text | Kind::Heading)
598        && let Some(t) = &node.text
599    {
600        out.push_str(t);
601    }
602    for c in &node.children {
603        collect_text_content(c, out);
604    }
605}
606
607enum LeafSelectionText {
608    Source(SelectionSource),
609    Text(String),
610}
611
612impl LeafSelectionText {
613    fn visible_len(&self) -> usize {
614        match self {
615            LeafSelectionText::Source(source) => source.visible_len(),
616            LeafSelectionText::Text(text) => text.len(),
617        }
618    }
619
620    fn source_text_for_visible(&self, start: usize, end: usize) -> Option<String> {
621        match self {
622            LeafSelectionText::Source(source) => source.source_text_for_visible(start, end),
623            LeafSelectionText::Text(text) => {
624                let start = clamp_to_char_boundary(text, start.min(text.len()));
625                let end = clamp_to_char_boundary(text, end.min(text.len()));
626                (start < end).then(|| text[start..end].to_string())
627            }
628        }
629    }
630
631    fn full_group_for_visible(&self, start: usize, end: usize) -> Option<&str> {
632        match self {
633            LeafSelectionText::Source(source) => source.full_group_for_visible(start, end),
634            LeafSelectionText::Text(_) => None,
635        }
636    }
637}
638
639fn collect_keyed_selection_leaves(node: &El, out: &mut Vec<(String, LeafSelectionText)>) {
640    if let (Some(k), Some(source)) = (&node.key, &node.selection_source) {
641        out.push((k.clone(), LeafSelectionText::Source((**source).clone())));
642        return;
643    }
644    if matches!(node.kind, Kind::Text | Kind::Heading)
645        && let (Some(k), Some(t)) = (&node.key, &node.text)
646    {
647        out.push((k.clone(), LeafSelectionText::Text(t.clone())));
648    }
649    for c in &node.children {
650        collect_keyed_selection_leaves(c, out);
651    }
652}
653
654/// Word range containing `byte`, returned as `(lo, hi)` byte offsets
655/// into `text`. A *word* is a maximal run of `is_word_char` scalars
656/// (alphanumeric, `_`, or `'`); when `byte` lands on a non-word
657/// character the result is just that single codepoint, matching the
658/// browser convention where double-clicking a punctuation mark
659/// selects only that mark rather than the surrounding whitespace.
660/// Used for double-click word selection.
661///
662/// `byte` is clamped to a UTF-8 char boundary; positions inside a
663/// multi-byte codepoint snap to the previous boundary. An empty
664/// `text` returns `(0, 0)`.
665pub fn word_range_at(text: &str, byte: usize) -> (usize, usize) {
666    if text.is_empty() {
667        return (0, 0);
668    }
669    let byte = clamp_to_char_boundary(text, byte.min(text.len()));
670    // At the very end of the text, point at the previous codepoint so
671    // double-click after the last word still selects that word rather
672    // than collapsing to (len, len).
673    let probe = if byte == text.len() {
674        prev_char_boundary(text, byte)
675    } else {
676        byte
677    };
678    let probe_char = text[probe..].chars().next().unwrap_or(' ');
679    if !is_word_char(probe_char) {
680        // Non-word char → select just this codepoint. Avoids the
681        // awkward "comma + space" double-select that grouping would
682        // produce.
683        return (probe, probe + probe_char.len_utf8());
684    }
685
686    // Word char → expand left and right through the run.
687    let mut lo = probe;
688    while lo > 0 {
689        let p = prev_char_boundary(text, lo);
690        let ch = text[p..].chars().next().unwrap();
691        if !is_word_char(ch) {
692            break;
693        }
694        lo = p;
695    }
696    let mut hi = probe;
697    while hi < text.len() {
698        let ch = text[hi..].chars().next().unwrap();
699        if !is_word_char(ch) {
700            break;
701        }
702        hi += ch.len_utf8();
703    }
704    (lo, hi)
705}
706
707/// Line range containing `byte`, returned as `(lo, hi)` byte offsets
708/// into `text`. The range excludes the trailing `\n` so the matching
709/// substring renders the visible line. An empty text returns `(0, 0)`.
710/// Used for triple-click line selection.
711///
712/// `byte` is clamped to a UTF-8 char boundary; positions inside a
713/// multi-byte codepoint snap to the previous boundary.
714pub fn line_range_at(text: &str, byte: usize) -> (usize, usize) {
715    let byte = clamp_to_char_boundary(text, byte.min(text.len()));
716    let lo = text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
717    let hi = text[byte..]
718        .find('\n')
719        .map(|i| byte + i)
720        .unwrap_or(text.len());
721    (lo, hi)
722}
723
724fn is_word_char(c: char) -> bool {
725    c.is_alphanumeric() || c == '_' || c == '\''
726}
727
728/// The byte offset of the next word boundary at or after `byte`, used
729/// by `Ctrl+Right` style word-forward navigation in `text_input` and
730/// `text_area`. Skips any non-word characters immediately after the
731/// caret, then skips the following run of word characters and stops
732/// just after it — matching the "jump to end of next word" convention
733/// most desktop text editors use.
734///
735/// `byte` is clamped to a UTF-8 char boundary. At end of text, returns
736/// `text.len()`.
737pub fn next_word_boundary(text: &str, byte: usize) -> usize {
738    let mut i = clamp_to_char_boundary(text, byte.min(text.len()));
739    // Skip any non-word characters first (whitespace, punctuation).
740    while i < text.len() {
741        let ch = text[i..].chars().next().unwrap();
742        if is_word_char(ch) {
743            break;
744        }
745        i += ch.len_utf8();
746    }
747    // Then skip the run of word characters.
748    while i < text.len() {
749        let ch = text[i..].chars().next().unwrap();
750        if !is_word_char(ch) {
751            break;
752        }
753        i += ch.len_utf8();
754    }
755    i
756}
757
758/// The byte offset of the previous word boundary at or before `byte`,
759/// used by `Ctrl+Left` style word-backward navigation. Skips any
760/// non-word characters immediately before the caret, then skips the
761/// preceding run of word characters and stops at its start — matching
762/// the "jump to start of previous word" convention.
763///
764/// `byte` is clamped to a UTF-8 char boundary. At start of text,
765/// returns `0`.
766pub fn prev_word_boundary(text: &str, byte: usize) -> usize {
767    let mut i = clamp_to_char_boundary(text, byte.min(text.len()));
768    // Skip any non-word characters going backward.
769    while i > 0 {
770        let p = prev_char_boundary(text, i);
771        let ch = text[p..].chars().next().unwrap();
772        if is_word_char(ch) {
773            break;
774        }
775        i = p;
776    }
777    // Then skip the run of word characters.
778    while i > 0 {
779        let p = prev_char_boundary(text, i);
780        let ch = text[p..].chars().next().unwrap();
781        if !is_word_char(ch) {
782            break;
783        }
784        i = p;
785    }
786    i
787}
788
789fn clamp_to_char_boundary(text: &str, byte: usize) -> usize {
790    let mut b = byte;
791    while b > 0 && !text.is_char_boundary(b) {
792        b -= 1;
793    }
794    b
795}
796
797fn prev_char_boundary(text: &str, byte: usize) -> usize {
798    let mut b = byte.saturating_sub(1);
799    while b > 0 && !text.is_char_boundary(b) {
800        b -= 1;
801    }
802    b
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn empty_selection_has_no_views() {
811        let sel = Selection::default();
812        assert!(sel.is_empty());
813        assert!(!sel.is_within("name"));
814        assert!(sel.within("name").is_none());
815    }
816
817    #[test]
818    fn caret_constructor_is_within_its_key() {
819        let sel = Selection::caret("name", 3);
820        assert!(!sel.is_empty());
821        assert!(sel.is_within("name"));
822        assert!(!sel.is_within("email"));
823        let view = sel.within("name").expect("within name");
824        assert_eq!(view, TextSelection::caret(3));
825    }
826
827    #[test]
828    fn within_returns_none_for_cross_element_selection() {
829        let sel = Selection {
830            range: Some(SelectionRange {
831                anchor: SelectionPoint::new("para_a", 0),
832                head: SelectionPoint::new("para_b", 5),
833            }),
834        };
835        // Cross-element: neither lens reveals the full selection.
836        assert!(sel.within("para_a").is_none());
837        assert!(sel.within("para_b").is_none());
838        // But the originating-leaf check still works.
839        assert!(sel.anchored_at("para_a"));
840        assert!(!sel.anchored_at("para_b"));
841    }
842
843    #[test]
844    fn set_within_writes_back_a_modified_slice() {
845        let mut sel = Selection::caret("name", 0);
846        let mut view = sel.within("name").expect("caret");
847        view.head = 5; // simulate widget editing the slice
848        sel.set_within("name", view);
849        let view_back = sel.within("name").expect("still within name");
850        assert_eq!(view_back, TextSelection::range(0, 5));
851    }
852
853    #[test]
854    fn set_within_is_a_noop_when_selection_is_not_in_key() {
855        let mut sel = Selection::caret("name", 0);
856        sel.set_within("email", TextSelection::range(0, 9));
857        // Selection unchanged.
858        assert_eq!(sel.within("name"), Some(TextSelection::caret(0)));
859        assert!(sel.within("email").is_none());
860    }
861
862    #[test]
863    fn selected_text_returns_single_leaf_substring() {
864        let tree = crate::widgets::text::text("Hello, world!").key("p");
865        let sel = Selection {
866            range: Some(SelectionRange {
867                anchor: SelectionPoint::new("p", 7),
868                head: SelectionPoint::new("p", 12),
869            }),
870        };
871        assert_eq!(selected_text(&tree, &sel).as_deref(), Some("world"));
872    }
873
874    #[test]
875    fn selected_text_snaps_stale_mid_codepoint_offsets_single_leaf() {
876        // Selections persist across rebuilds; if the leaf text changed
877        // underneath one, its byte offsets can land inside a multi-byte
878        // codepoint. Slicing must snap, not panic.
879        // "aé€b": a=0, é=1..3, €=3..6, b=6..7.
880        let tree = crate::widgets::text::text("aé€b").key("p");
881        let sel = Selection {
882            range: Some(SelectionRange {
883                anchor: SelectionPoint::new("p", 2), // inside 'é'
884                head: SelectionPoint::new("p", 4),   // inside '€'
885            }),
886        };
887        assert_eq!(selected_text(&tree, &sel).as_deref(), Some("é"));
888    }
889
890    #[test]
891    fn selected_text_snaps_stale_mid_codepoint_offsets_cross_leaf() {
892        let tree = crate::column([
893            crate::widgets::text::text("héllo").key("a"),
894            crate::widgets::text::text("wörld").key("b"),
895        ]);
896        let sel = Selection {
897            range: Some(SelectionRange {
898                anchor: SelectionPoint::new("a", 2), // inside 'é'
899                head: SelectionPoint::new("b", 2),   // inside 'ö'
900            }),
901        };
902        assert_eq!(selected_text(&tree, &sel).as_deref(), Some("éllo\nw"));
903    }
904
905    #[test]
906    fn line_range_at_snaps_mid_codepoint_byte() {
907        // "é\nö": é=0..2, \n=2, ö=3..5. Byte 1 is inside 'é'.
908        assert_eq!(line_range_at("é\nö", 1), (0, 2));
909        assert_eq!(line_range_at("é\nö", 4), (3, 5));
910    }
911
912    #[test]
913    fn selected_text_reads_text_inside_keyed_composite_widget() {
914        let sel = Selection {
915            range: Some(SelectionRange {
916                anchor: SelectionPoint::new("name", 1),
917                head: SelectionPoint::new("name", 4),
918            }),
919        };
920        let tree = crate::widgets::text_input::text_input("name", "hello", &sel);
921        assert_eq!(selected_text(&tree, &sel).as_deref(), Some("ell"));
922    }
923
924    #[test]
925    fn selected_text_walks_tree_order_for_cross_leaf_selection() {
926        let tree = crate::column([
927            crate::widgets::text::text("alpha").key("a"),
928            crate::widgets::text::text("bravo").key("b"),
929            crate::widgets::text::text("charlie").key("c"),
930        ]);
931        // Anchor inside "alpha" at byte 2, head inside "charlie" at
932        // byte 4 — should yield "pha\nbravo\nchar" (joined by newline
933        // between leaves; full middle leaf included).
934        let sel = Selection {
935            range: Some(SelectionRange {
936                anchor: SelectionPoint::new("a", 2),
937                head: SelectionPoint::new("c", 4),
938            }),
939        };
940        assert_eq!(
941            selected_text(&tree, &sel).as_deref(),
942            Some("pha\nbravo\nchar")
943        );
944    }
945
946    #[test]
947    fn selected_text_uses_source_payload_for_single_leaf() {
948        let mut source = SelectionSource::new("This is **bold**.", "This is bold.");
949        source.push_span(0..8, 0..8, false);
950        source.push_span_with_full_source(8..12, 10..14, 8..16, false);
951        source.push_span(12..13, 16..17, false);
952        let tree = crate::text_runs([crate::text("This is "), crate::text("bold").bold()])
953            .key("md:p")
954            .selectable()
955            .selection_source(source);
956
957        let inner_only = Selection {
958            range: Some(SelectionRange {
959                anchor: SelectionPoint::new("md:p", 8),
960                head: SelectionPoint::new("md:p", 12),
961            }),
962        };
963        assert_eq!(
964            selected_text(&tree, &inner_only).as_deref(),
965            Some("**bold**")
966        );
967
968        let partial_inner = Selection {
969            range: Some(SelectionRange {
970                anchor: SelectionPoint::new("md:p", 9),
971                head: SelectionPoint::new("md:p", 11),
972            }),
973        };
974        assert_eq!(selected_text(&tree, &partial_inner).as_deref(), Some("ol"));
975
976        let through_styled_span = Selection {
977            range: Some(SelectionRange {
978                anchor: SelectionPoint::new("md:p", 0),
979                head: SelectionPoint::new("md:p", 12),
980            }),
981        };
982        assert_eq!(
983            selected_text(&tree, &through_styled_span).as_deref(),
984            Some("This is **bold**")
985        );
986
987        let whole = Selection {
988            range: Some(SelectionRange {
989                anchor: SelectionPoint::new("md:p", 0),
990                head: SelectionPoint::new("md:p", 13),
991            }),
992        };
993        assert_eq!(
994            selected_text(&tree, &whole).as_deref(),
995            Some("This is **bold**.")
996        );
997    }
998
999    #[test]
1000    fn selected_text_dedupes_adjacent_full_source_group_leaves() {
1001        let mut first = SelectionSource::new("| **Ada** | dev |", "Ada");
1002        first.push_span_with_full_source(0..3, 4..7, 0..17, false);
1003        let first = first.full_selection_group("row:0");
1004
1005        let mut second = SelectionSource::new("| **Ada** | dev |", "dev");
1006        second.push_span_with_full_source(0..3, 12..15, 0..17, false);
1007        let second = second.full_selection_group("row:0");
1008
1009        let tree = crate::row([
1010            crate::text("Ada")
1011                .key("a")
1012                .selectable()
1013                .selection_source(first),
1014            crate::text("dev")
1015                .key("b")
1016                .selectable()
1017                .selection_source(second),
1018        ]);
1019        let sel = Selection {
1020            range: Some(SelectionRange {
1021                anchor: SelectionPoint::new("a", 0),
1022                head: SelectionPoint::new("b", 3),
1023            }),
1024        };
1025
1026        assert_eq!(
1027            selected_text(&tree, &sel).as_deref(),
1028            Some("| **Ada** | dev |")
1029        );
1030    }
1031
1032    #[test]
1033    fn slice_for_leaf_single_leaf() {
1034        let order = order_for(&["a", "b", "c"]);
1035        let sel = Selection {
1036            range: Some(SelectionRange {
1037                anchor: SelectionPoint::new("b", 2),
1038                head: SelectionPoint::new("b", 5),
1039            }),
1040        };
1041        assert_eq!(slice_for_leaf(&sel, &order, "b", 10), Some((2, 5)));
1042        assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
1043        assert_eq!(slice_for_leaf(&sel, &order, "c", 10), None);
1044    }
1045
1046    #[test]
1047    fn slice_for_leaf_cross_leaf_anchor_to_head_in_doc_order() {
1048        // anchor = a@2, head = c@4: spans a, b, c.
1049        let order = order_for(&["a", "b", "c"]);
1050        let sel = Selection {
1051            range: Some(SelectionRange {
1052                anchor: SelectionPoint::new("a", 2),
1053                head: SelectionPoint::new("c", 4),
1054            }),
1055        };
1056        assert_eq!(
1057            slice_for_leaf(&sel, &order, "a", 10),
1058            Some((2, 10)),
1059            "anchor leaf: from anchor.byte to text_len"
1060        );
1061        assert_eq!(
1062            slice_for_leaf(&sel, &order, "b", 8),
1063            Some((0, 8)),
1064            "middle leaf: fully selected"
1065        );
1066        assert_eq!(
1067            slice_for_leaf(&sel, &order, "c", 10),
1068            Some((0, 4)),
1069            "head leaf: from 0 to head.byte"
1070        );
1071    }
1072
1073    #[test]
1074    fn slice_for_leaf_cross_leaf_reversed_drag() {
1075        // anchor in c (later), head in a (earlier) — order shouldn't
1076        // matter; the slice is the same as forward drag.
1077        let order = order_for(&["a", "b", "c"]);
1078        let sel = Selection {
1079            range: Some(SelectionRange {
1080                anchor: SelectionPoint::new("c", 3),
1081                head: SelectionPoint::new("a", 1),
1082            }),
1083        };
1084        // Forward in doc order: a@1..end, b full, c 0..3.
1085        assert_eq!(slice_for_leaf(&sel, &order, "a", 5), Some((1, 5)));
1086        assert_eq!(slice_for_leaf(&sel, &order, "b", 6), Some((0, 6)));
1087        assert_eq!(slice_for_leaf(&sel, &order, "c", 9), Some((0, 3)));
1088    }
1089
1090    #[test]
1091    fn slice_for_leaf_returns_none_for_leaves_outside_range() {
1092        // 5-leaf order; selection covers only b..d.
1093        let order = order_for(&["a", "b", "c", "d", "e"]);
1094        let sel = Selection {
1095            range: Some(SelectionRange {
1096                anchor: SelectionPoint::new("b", 0),
1097                head: SelectionPoint::new("d", 0),
1098            }),
1099        };
1100        assert_eq!(slice_for_leaf(&sel, &order, "a", 10), None);
1101        assert_eq!(slice_for_leaf(&sel, &order, "e", 10), None);
1102        // Boundary leaves with collapsed endpoints: anchor at b@0
1103        // means b's slice is (0, len). head at d@0 means d's slice is
1104        // (0, 0) which collapses → None.
1105        assert_eq!(slice_for_leaf(&sel, &order, "b", 4), Some((0, 4)));
1106        assert_eq!(slice_for_leaf(&sel, &order, "c", 7), Some((0, 7)));
1107        assert_eq!(slice_for_leaf(&sel, &order, "d", 5), None);
1108    }
1109
1110    fn order_for(keys: &[&str]) -> Vec<crate::event::UiTarget> {
1111        keys.iter()
1112            .map(|k| crate::event::UiTarget {
1113                key: (*k).to_string(),
1114                node_id: format!("root.{k}").into(),
1115                rect: crate::tree::Rect::new(0.0, 0.0, 0.0, 0.0),
1116                tooltip: None,
1117                scroll_offset_y: 0.0,
1118            })
1119            .collect()
1120    }
1121
1122    #[test]
1123    fn selected_text_returns_none_for_empty_or_unknown_keys() {
1124        let tree = crate::widgets::text::text("hi").key("p");
1125        assert!(selected_text(&tree, &Selection::default()).is_none());
1126        let unknown = Selection::caret("missing", 0);
1127        assert!(selected_text(&tree, &unknown).is_none());
1128    }
1129
1130    #[test]
1131    fn word_range_at_picks_run_around_byte() {
1132        let text = "Hello, world!";
1133        // Byte 0 in "Hello" → whole word.
1134        assert_eq!(word_range_at(text, 0), (0, 5));
1135        // Byte 3 (inside "Hello") → whole word.
1136        assert_eq!(word_range_at(text, 3), (0, 5));
1137        // Byte 5 (the comma) → run of non-word chars (just ",").
1138        assert_eq!(word_range_at(text, 5), (5, 6));
1139        // Byte 6 (the space) → run of non-word chars (just " ").
1140        assert_eq!(word_range_at(text, 6), (6, 7));
1141        // Byte 7 (start of "world") → "world".
1142        assert_eq!(word_range_at(text, 7), (7, 12));
1143        // Byte 12 ("!") → "!".
1144        assert_eq!(word_range_at(text, 12), (12, 13));
1145    }
1146
1147    #[test]
1148    fn word_range_at_treats_apostrophe_and_underscore_as_word_chars() {
1149        // Contractions stay one word.
1150        assert_eq!(word_range_at("don't stop", 2), (0, 5));
1151        // Identifier-style.
1152        assert_eq!(word_range_at("foo_bar baz", 4), (0, 7));
1153    }
1154
1155    #[test]
1156    fn word_range_at_handles_end_of_text_and_empty() {
1157        let text = "hello";
1158        // Byte at len → snaps back into the trailing word.
1159        assert_eq!(word_range_at(text, 5), (0, 5));
1160        // Empty text → (0, 0).
1161        assert_eq!(word_range_at("", 0), (0, 0));
1162    }
1163
1164    #[test]
1165    fn word_range_at_clamps_off_utf8_boundary() {
1166        // 'é' is two bytes; byte=1 sits inside the codepoint and snaps
1167        // back to byte 0, then expands into the run of non-ASCII word chars.
1168        let text = "café";
1169        let (lo, hi) = word_range_at(text, 1);
1170        assert_eq!((lo, hi), (0, text.len()));
1171    }
1172
1173    #[test]
1174    fn line_range_at_returns_line_around_byte() {
1175        let text = "first\nsecond line\nthird";
1176        // First line: bytes 0..5 ("first"), \n at byte 5.
1177        assert_eq!(line_range_at(text, 0), (0, 5));
1178        assert_eq!(line_range_at(text, 3), (0, 5));
1179        assert_eq!(line_range_at(text, 5), (0, 5));
1180        // Second line: bytes 6..17 ("second line"), \n at byte 17.
1181        assert_eq!(line_range_at(text, 6), (6, 17));
1182        assert_eq!(line_range_at(text, 12), (6, 17));
1183        assert_eq!(line_range_at(text, 17), (6, 17));
1184        // Third (final, no trailing \n) line: bytes 18..23.
1185        assert_eq!(line_range_at(text, 18), (18, 23));
1186        assert_eq!(line_range_at(text, 23), (18, 23));
1187    }
1188
1189    #[test]
1190    fn line_range_at_handles_empty_and_single_line() {
1191        assert_eq!(line_range_at("", 0), (0, 0));
1192        assert_eq!(line_range_at("just one line", 4), (0, 13));
1193    }
1194}