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