Skip to main content

computer_use/
text.rs

1//! Locating a piece of text inside an element's value for `select_text`.
2
3use std::ops::Range;
4
5use crate::error::{Error, Result};
6
7/// What `select_text` should do with the matched text.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9#[non_exhaustive]
10pub enum SelectionMode {
11    /// Select the matched text.
12    #[default]
13    Text,
14    /// Place the caret immediately before it.
15    CursorBefore,
16    /// Place the caret immediately after it.
17    CursorAfter,
18}
19
20/// A request to select (or place the caret around) some text inside an element.
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22pub struct TextSelection {
23    /// The text to find, exactly as it appears in the element's value.
24    pub text: String,
25    /// Text that must appear immediately before the match, to disambiguate repeats.
26    pub prefix: Option<String>,
27    /// Text that must appear immediately after the match, to disambiguate repeats.
28    pub suffix: Option<String>,
29    /// What to do with the match.
30    pub mode: SelectionMode,
31}
32
33impl TextSelection {
34    /// Selects `text`.
35    #[must_use]
36    pub fn new(text: impl Into<String>) -> Self {
37        Self { text: text.into(), ..Self::default() }
38    }
39
40    /// Requires `prefix` immediately before the match.
41    #[must_use]
42    pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
43        self.prefix = Some(prefix.into());
44        self
45    }
46
47    /// Requires `suffix` immediately after the match.
48    #[must_use]
49    pub fn suffix(mut self, suffix: impl Into<String>) -> Self {
50        self.suffix = Some(suffix.into());
51        self
52    }
53
54    /// Sets the selection mode.
55    #[must_use]
56    pub const fn mode(mut self, mode: SelectionMode) -> Self {
57        self.mode = mode;
58        self
59    }
60
61    /// Resolves the request against `value`, returning a byte range to select.
62    ///
63    /// A collapsed range (`start == end`) places the caret.
64    ///
65    /// # Errors
66    ///
67    /// Fails when the text is empty, absent, or occurs more than once after applying the
68    /// prefix and suffix constraints.
69    pub fn resolve(&self, value: &str) -> Result<Range<usize>> {
70        if self.text.is_empty() {
71            return Err(Error::invalid("text to select must not be empty"));
72        }
73        let prefix = self.prefix.as_deref().unwrap_or("");
74        let suffix = self.suffix.as_deref().unwrap_or("");
75        let mut matches = value.match_indices(&self.text).filter_map(|(start, m)| {
76            let end = start + m.len();
77            (value[..start].ends_with(prefix) && value[end..].starts_with(suffix)).then_some(start..end)
78        });
79        let found = matches
80            .next()
81            .ok_or_else(|| Error::invalid(format!("text {:?} was not found in the element's value", self.text)))?;
82        if matches.next().is_some() {
83            return Err(Error::invalid(format!(
84                "text {:?} occurs more than once; add a prefix or suffix to disambiguate",
85                self.text
86            )));
87        }
88        Ok(match self.mode {
89            SelectionMode::Text => found,
90            SelectionMode::CursorBefore => found.start..found.start,
91            SelectionMode::CursorAfter => found.end..found.end,
92        })
93    }
94}
95
96/// Converts a byte offset in `value` to a UTF-16 code-unit offset.
97#[must_use]
98pub fn utf16_offset(value: &str, byte_offset: usize) -> usize {
99    value[..byte_offset].encode_utf16().count()
100}
101
102/// Converts a byte offset in `value` to a `char` (Unicode scalar) offset.
103#[must_use]
104pub fn char_offset(value: &str, byte_offset: usize) -> usize {
105    value[..byte_offset].chars().count()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn resolves_unique_match() -> Result<()> {
114        assert_eq!(TextSelection::new("world").resolve("hello world")?, 6..11);
115        let before = TextSelection::new("world").mode(SelectionMode::CursorBefore);
116        assert_eq!(before.resolve("hello world")?, 6..6);
117        let after = TextSelection::new("hello").mode(SelectionMode::CursorAfter);
118        assert_eq!(after.resolve("hello world")?, 5..5);
119        Ok(())
120    }
121
122    #[test]
123    fn disambiguates_with_prefix_and_suffix() -> Result<()> {
124        let value = "cat dog cat bird";
125        assert!(TextSelection::new("cat").resolve(value).is_err());
126        assert_eq!(TextSelection::new("cat").prefix("dog ").resolve(value)?, 8..11);
127        assert_eq!(TextSelection::new("cat").suffix(" dog").resolve(value)?, 0..3);
128        Ok(())
129    }
130
131    #[test]
132    fn reports_missing_text() {
133        assert!(TextSelection::new("zebra").resolve("cat").is_err());
134        assert!(TextSelection::new("").resolve("cat").is_err());
135    }
136
137    #[test]
138    fn converts_offsets() {
139        let value = "a😀b";
140        assert_eq!(utf16_offset(value, 5), 3);
141        assert_eq!(char_offset(value, 5), 2);
142    }
143}