Skip to main content

beamterm_core/gl/
cell_query.rs

1use compact_str::{CompactString, CompactStringExt};
2
3use crate::gl::TerminalGrid;
4
5/// Configuration for querying and extracting text from terminal cells.
6///
7/// Defines the selection mode, coordinate range, and text processing options
8/// for extracting content from the terminal grid.
9#[derive(Debug, Clone, Copy, Default)]
10pub struct CellQuery {
11    pub(crate) mode: SelectionMode,
12    pub(crate) start: Option<(u16, u16)>,
13    pub(crate) end: Option<(u16, u16)>,
14    pub(crate) trim_trailing_whitespace: bool,
15    /// Hash of the selected content when selection was completed.
16    /// Used to detect if underlying content has changed.
17    pub(crate) content_hash: Option<u64>,
18}
19
20/// Defines how cells are selected in the terminal grid.
21#[derive(Debug, Clone, Copy, Default)]
22#[non_exhaustive]
23pub enum SelectionMode {
24    /// Rectangular selection of cells.
25    ///
26    /// Selects all cells within the rectangle defined by start and end points.
27    #[default]
28    Block,
29    /// Linear selection following text flow.
30    ///
31    /// Selects cells from start to end following line wrapping, similar to
32    /// standard text selection in terminals.
33    Linear,
34}
35
36/// Zero-allocation iterator over terminal cell indices.
37///
38/// Provides efficient iteration over selected cells without allocating
39/// intermediate collections.
40#[derive(Debug)]
41pub enum CellIterator {
42    /// Iterator for block (rectangular) selections.
43    Block(BlockCellIterator),
44    /// Iterator for linear (text-flow) selections.
45    Linear(LinearCellIterator),
46}
47
48/// Iterator for block (rectangular) cell selection.
49///
50/// Iterates over cells row by row within the rectangular region defined
51/// by start and end coordinates.
52#[derive(Debug)]
53pub struct BlockCellIterator {
54    cols: u16,
55    start: (u16, u16),
56    end: (u16, u16),
57    current: (u16, u16),
58    finished: bool,
59}
60
61/// Iterator for linear cell selection.
62///
63/// Iterates over cells following text flow from start to end position,
64/// wrapping at line boundaries like standard text selection.
65#[derive(Debug)]
66pub struct LinearCellIterator {
67    cols: u16,
68    current_idx: usize,
69    end_idx: usize,
70    finished: bool,
71}
72
73/// Creates a new cell query with the specified selection mode.
74#[must_use]
75pub fn select(mode: SelectionMode) -> CellQuery {
76    CellQuery { mode, ..CellQuery::default() }
77}
78
79impl CellQuery {
80    /// Sets the starting position for the selection.
81    ///
82    /// # Arguments
83    /// * `start` - Starting coordinates as (column, row)
84    #[must_use]
85    pub fn start(mut self, start: (u16, u16)) -> Self {
86        self.start = Some(start);
87        self
88    }
89
90    /// Sets the ending position for the selection.
91    ///
92    /// # Arguments
93    /// * `end` - Ending coordinates as (column, row)
94    #[must_use]
95    pub fn end(mut self, end: (u16, u16)) -> Self {
96        self.end = Some(end);
97        self
98    }
99
100    /// Checks if the query has no selection range defined.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.start.is_none() && self.end.is_none()
104    }
105
106    /// Returns the normalized selection range if both start and end are defined.
107    ///
108    /// The returned range has coordinates ordered so that the first tuple
109    /// contains the minimum coordinates and the second contains the maximum.
110    #[must_use]
111    pub fn range(&self) -> Option<((u16, u16), (u16, u16))> {
112        if let (Some(start), Some(end)) = (self.start, self.end) {
113            Some((
114                (start.0.min(end.0), start.1.min(end.1)),
115                (start.0.max(end.0), start.1.max(end.1)),
116            ))
117        } else {
118            None
119        }
120    }
121
122    /// Configures whether to remove trailing whitespace from each line.
123    ///
124    /// When enabled, spaces at the end of each selected line are removed
125    /// from the extracted text.
126    #[must_use]
127    pub fn trim_trailing_whitespace(mut self, enabled: bool) -> Self {
128        self.trim_trailing_whitespace = enabled;
129        self
130    }
131
132    /// Sets the content hash for this query.
133    ///
134    /// The hash is computed from the selected cells when the selection
135    /// is completed, and used to detect if the underlying content changes.
136    pub(crate) fn with_content_hash(mut self, hash: u64) -> Self {
137        self.content_hash = Some(hash);
138        self
139    }
140}
141
142impl Iterator for CellIterator {
143    type Item = (usize, bool); // (cell_index, needs_newline_after)
144
145    fn next(&mut self) -> Option<Self::Item> {
146        match self {
147            CellIterator::Block(iter) => iter.next(),
148            CellIterator::Linear(iter) => iter.next(),
149        }
150    }
151}
152
153impl Iterator for BlockCellIterator {
154    type Item = (usize, bool);
155
156    fn next(&mut self) -> Option<Self::Item> {
157        if self.finished || self.current.1 > self.end.1 {
158            return None;
159        }
160
161        let idx = self.current.1 as usize * self.cols as usize + self.current.0 as usize;
162
163        // Check if we need a newline after this cell
164        let is_end_of_row = self.current.0 == self.end.0;
165        let is_last_row = self.current.1 == self.end.1;
166        let needs_newline = is_end_of_row && !is_last_row;
167
168        // Advance to next position
169        if self.current.0 < self.end.0 {
170            self.current.0 += 1;
171        } else {
172            self.current.0 = self.start.0;
173            self.current.1 += 1;
174            if self.current.1 > self.end.1 {
175                self.finished = true;
176            }
177        }
178
179        Some((idx, needs_newline))
180    }
181}
182
183impl Iterator for LinearCellIterator {
184    type Item = (usize, bool);
185
186    fn next(&mut self) -> Option<Self::Item> {
187        if self.finished || self.current_idx > self.end_idx {
188            return None;
189        }
190
191        let idx = self.current_idx;
192
193        self.current_idx += 1;
194        if self.current_idx > self.end_idx {
195            self.finished = true;
196        }
197
198        // Check if NEXT cell will need a newline before it
199        let needs_newline_after = if self.current_idx <= self.end_idx {
200            self.current_idx
201                .is_multiple_of(self.cols as usize)
202        } else {
203            false
204        };
205
206        Some((idx, needs_newline_after))
207    }
208}
209
210impl BlockCellIterator {
211    /// Creates a new block iterator with bounds checking.
212    ///
213    /// Ensures coordinates are within terminal bounds and properly ordered.
214    fn new(cols: u16, start: (u16, u16), end: (u16, u16), max_cells: usize) -> Self {
215        // Bounds checking and coordinate ordering
216        let start = (
217            start.0.min(cols.saturating_sub(1)),
218            start
219                .1
220                .min((max_cells / cols as usize).saturating_sub(1) as u16),
221        );
222        let end = (
223            end.0.min(cols.saturating_sub(1)),
224            end.1
225                .min((max_cells / cols as usize).saturating_sub(1) as u16),
226        );
227        let (start, end) = if start > end { (end, start) } else { (start, end) };
228
229        Self {
230            cols,
231            start,
232            end,
233            current: start,
234            finished: start.1 > end.1,
235        }
236    }
237}
238
239impl LinearCellIterator {
240    /// Creates a new linear iterator with bounds checking.
241    ///
242    /// Converts coordinates to linear indices and ensures they're within bounds.
243    fn new(cols: u16, start: (u16, u16), end: (u16, u16), max_cells: usize) -> Self {
244        let cols_usize = cols as usize;
245
246        // Bounds checking and coordinate ordering
247        let start = (
248            start.0.min(cols.saturating_sub(1)),
249            start
250                .1
251                .min((max_cells / cols_usize).saturating_sub(1) as u16),
252        );
253        let end = (
254            end.0.min(cols.saturating_sub(1)),
255            end.1
256                .min((max_cells / cols_usize).saturating_sub(1) as u16),
257        );
258        let (start, end) = if start > end { (end, start) } else { (start, end) };
259
260        let start_idx = start.1 as usize * cols_usize + start.0 as usize;
261        let end_idx = end.1 as usize * cols_usize + end.0 as usize;
262        let end_idx = end_idx.min(max_cells.saturating_sub(1));
263        let start_idx = start_idx.min(end_idx);
264
265        Self {
266            cols,
267            current_idx: start_idx,
268            end_idx,
269            finished: start_idx > end_idx,
270        }
271    }
272}
273
274impl TerminalGrid {
275    /// Zero-allocation iterator over cell indices for a given selection range and mode.
276    ///
277    /// Creates an efficient iterator that yields cell indices and newline indicators
278    /// without allocating intermediate collections.
279    ///
280    /// # Returns
281    /// Iterator yielding (cell_index, needs_newline_after) tuples.
282    #[must_use]
283    pub fn cell_iter(&self, selection: CellQuery) -> CellIterator {
284        let cols = self.terminal_size().cols;
285        let max_cells = self.cell_count();
286
287        let (start, end) = selection.range().unwrap_or_default();
288
289        match selection.mode {
290            SelectionMode::Block => {
291                CellIterator::Block(BlockCellIterator::new(cols, start, end, max_cells))
292            },
293            SelectionMode::Linear => {
294                CellIterator::Linear(LinearCellIterator::new(cols, start, end, max_cells))
295            },
296        }
297    }
298
299    /// Extracts text content from the terminal based on the selection query.
300    ///
301    /// Retrieves the text within the selection range, optionally trimming
302    /// trailing whitespace from each line based on the query configuration.
303    ///
304    /// # Arguments
305    /// * `selection` - Query defining the selection range and options
306    ///
307    /// # Returns
308    /// The selected text as a `CompactString`, or empty string if no selection.
309    pub fn get_text(&self, selection: CellQuery) -> CompactString {
310        if selection.is_empty() {
311            return CompactString::const_new("");
312        }
313
314        let text = self.get_symbols(self.cell_iter(selection));
315
316        if selection.trim_trailing_whitespace {
317            text.lines().map(str::trim_end).join_compact("\n")
318        } else {
319            text
320        }
321    }
322}