Skip to main content

agent_tui/detection/
region.rs

1//! Region and modal detection for TUI applications
2//!
3//! Detects box-drawing character boundaries to identify:
4//! - Modal dialogs
5//! - Panels
6//! - Windows
7//! - Bordered regions
8
9/// A detected region/box in the terminal
10#[derive(Debug, Clone)]
11pub struct Region {
12    /// Top-left row (0-indexed)
13    pub top: u16,
14    /// Top-left column (0-indexed)
15    pub left: u16,
16    /// Bottom-right row (0-indexed)
17    pub bottom: u16,
18    /// Bottom-right column (0-indexed)
19    pub right: u16,
20    /// The border style used
21    pub border_style: BorderStyle,
22    /// Optional title extracted from the top border
23    pub title: Option<String>,
24    /// Optional label extracted from the left border
25    pub left_label: Option<String>,
26    /// Optional label extracted from the right border
27    pub right_label: Option<String>,
28}
29
30impl Region {
31    /// Get the width of the region
32    pub fn width(&self) -> u16 {
33        self.right.saturating_sub(self.left) + 1
34    }
35
36    /// Get the height of the region
37    pub fn height(&self) -> u16 {
38        self.bottom.saturating_sub(self.top) + 1
39    }
40
41    /// Check if this region is likely a modal (centered, not full-width)
42    pub fn is_modal(&self, screen_cols: u16, _screen_rows: u16) -> bool {
43        let width = self.width();
44        let height = self.height();
45
46        // Modal criteria:
47        // - Not full width
48        // - Not at edges
49        // - Reasonable size
50        let not_full_width = width < screen_cols - 4;
51        let not_at_left_edge = self.left > 2;
52        let not_at_top_edge = self.top > 0;
53        let reasonable_size = width > 10 && height > 3;
54
55        not_full_width && not_at_left_edge && not_at_top_edge && reasonable_size
56    }
57
58    /// Check if a position is inside this region
59    pub fn contains(&self, row: u16, col: u16) -> bool {
60        row >= self.top && row <= self.bottom && col >= self.left && col <= self.right
61    }
62
63    /// Check if a position is inside the content area (excluding border)
64    pub fn contains_content(&self, row: u16, col: u16) -> bool {
65        row > self.top && row < self.bottom && col > self.left && col < self.right
66    }
67}
68
69/// Border styles for box drawing
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum BorderStyle {
72    /// Single line: ┌ ─ ┐ │ └ ─ ┘
73    Single,
74    /// Rounded corners: ╭ ─ ╮ │ ╰ ─ ╯
75    Rounded,
76    /// Double line: ╔ ═ ╗ ║ ╚ ═ ╝
77    Double,
78    /// Heavy/thick line: ┏ ━ ┓ ┃ ┗ ━ ┛
79    Heavy,
80    /// ASCII: + - + | + - +
81    Ascii,
82    /// Unknown/mixed style
83    Unknown,
84}
85
86impl BorderStyle {
87    fn top_left(&self) -> &[char] {
88        match self {
89            BorderStyle::Single => &['┌'],
90            BorderStyle::Rounded => &['╭'],
91            BorderStyle::Double => &['╔'],
92            BorderStyle::Heavy => &['┏'],
93            BorderStyle::Ascii => &['+'],
94            BorderStyle::Unknown => &['┌', '╭', '╔', '┏', '+'],
95        }
96    }
97
98    fn top_right(&self) -> &[char] {
99        match self {
100            BorderStyle::Single => &['┐'],
101            BorderStyle::Rounded => &['╮'],
102            BorderStyle::Double => &['╗'],
103            BorderStyle::Heavy => &['┓'],
104            BorderStyle::Ascii => &['+'],
105            BorderStyle::Unknown => &['┐', '╮', '╗', '┓', '+'],
106        }
107    }
108
109    fn bottom_left(&self) -> &[char] {
110        match self {
111            BorderStyle::Single => &['└'],
112            BorderStyle::Rounded => &['╰'],
113            BorderStyle::Double => &['╚'],
114            BorderStyle::Heavy => &['┗'],
115            BorderStyle::Ascii => &['+'],
116            BorderStyle::Unknown => &['└', '╰', '╚', '┗', '+'],
117        }
118    }
119
120    fn bottom_right(&self) -> &[char] {
121        match self {
122            BorderStyle::Single => &['┘'],
123            BorderStyle::Rounded => &['╯'],
124            BorderStyle::Double => &['╝'],
125            BorderStyle::Heavy => &['┛'],
126            BorderStyle::Ascii => &['+'],
127            BorderStyle::Unknown => &['┘', '╯', '╝', '┛', '+'],
128        }
129    }
130
131    fn horizontal(&self) -> &[char] {
132        match self {
133            BorderStyle::Single => &['─'],
134            BorderStyle::Rounded => &['─'],
135            BorderStyle::Double => &['═'],
136            BorderStyle::Heavy => &['━'],
137            BorderStyle::Ascii => &['-'],
138            BorderStyle::Unknown => &['─', '═', '━', '-'],
139        }
140    }
141
142    fn vertical(&self) -> &[char] {
143        match self {
144            BorderStyle::Single => &['│'],
145            BorderStyle::Rounded => &['│'],
146            BorderStyle::Double => &['║'],
147            BorderStyle::Heavy => &['┃'],
148            BorderStyle::Ascii => &['|'],
149            BorderStyle::Unknown => &['│', '║', '┃', '|'],
150        }
151    }
152}
153
154/// All top-left corner characters
155const TOP_LEFT_CORNERS: [char; 5] = ['┌', '╭', '╔', '┏', '+'];
156
157/// All horizontal line characters
158const HORIZONTAL_CHARS: [char; 4] = ['─', '═', '━', '-'];
159
160/// All vertical line characters
161const VERTICAL_CHARS: [char; 4] = ['│', '║', '┃', '|'];
162
163/// Detect regions (boxes) in the screen
164pub fn detect_regions(screen: &str) -> Vec<Region> {
165    let lines: Vec<Vec<char>> = screen.lines().map(|l| l.chars().collect()).collect();
166    let mut regions = Vec::new();
167
168    if lines.is_empty() {
169        return regions;
170    }
171
172    // Scan for top-left corners
173    for (row_idx, row) in lines.iter().enumerate() {
174        for (col_idx, &ch) in row.iter().enumerate() {
175            if TOP_LEFT_CORNERS.contains(&ch) {
176                // Found a potential top-left corner, try to find the complete box
177                if let Some(region) = trace_box(&lines, row_idx, col_idx) {
178                    // Check if this region is not a duplicate or nested inside another
179                    let dominated = regions.iter().any(|r: &Region| {
180                        r.top <= region.top
181                            && r.left <= region.left
182                            && r.bottom >= region.bottom
183                            && r.right >= region.right
184                            && !(r.top == region.top
185                                && r.left == region.left
186                                && r.bottom == region.bottom
187                                && r.right == region.right)
188                    });
189
190                    if !dominated {
191                        // Remove any existing regions that are dominated by this one
192                        regions.retain(|r: &Region| {
193                            !(region.top <= r.top
194                                && region.left <= r.left
195                                && region.bottom >= r.bottom
196                                && region.right >= r.right)
197                        });
198                        regions.push(region);
199                    }
200                }
201            }
202        }
203    }
204
205    // Sort by size (larger first, likely to be more important)
206    regions.sort_by(|a, b| {
207        let area_a = a.width() as u32 * a.height() as u32;
208        let area_b = b.width() as u32 * b.height() as u32;
209        area_b.cmp(&area_a)
210    });
211
212    regions
213}
214
215/// Trace a complete box starting from a top-left corner
216fn trace_box(lines: &[Vec<char>], start_row: usize, start_col: usize) -> Option<Region> {
217    let top_left_char = lines[start_row][start_col];
218    let border_style = detect_border_style(top_left_char);
219
220    // Verify the detected style matches the corner character
221    if !border_style.top_left().contains(&top_left_char) {
222        return None;
223    }
224
225    // Find the top-right corner by following horizontal lines
226    let mut top_right_col = None;
227    let start_row_chars = &lines[start_row];
228    for (col, &ch) in start_row_chars.iter().enumerate().skip(start_col + 1) {
229        if border_style.top_right().contains(&ch) {
230            top_right_col = Some(col);
231            break;
232        } else if !border_style.horizontal().contains(&ch) && ch != ' ' {
233            // Allow spaces in titles
234            if !ch.is_alphanumeric() && ch != ' ' && ch != ':' && ch != '-' {
235                break;
236            }
237        }
238    }
239
240    let right_col = top_right_col?;
241
242    // Find the bottom-left corner by following vertical lines
243    let mut bottom_row = None;
244    for (row, row_chars) in lines.iter().enumerate().skip(start_row + 1) {
245        if start_col >= row_chars.len() {
246            break;
247        }
248        let ch = row_chars[start_col];
249        if border_style.bottom_left().contains(&ch) {
250            // Verify bottom-right corner exists
251            if right_col < row_chars.len() {
252                let br = row_chars[right_col];
253                if border_style.bottom_right().contains(&br) {
254                    bottom_row = Some(row);
255                    break;
256                }
257            }
258        } else if !border_style.vertical().contains(&ch) {
259            break;
260        }
261    }
262
263    let bottom = bottom_row?;
264
265    // Verify the box has complete sides
266    // Check right side has vertical lines
267    for row_chars in lines.iter().take(bottom).skip(start_row + 1) {
268        if right_col >= row_chars.len() {
269            return None;
270        }
271        let ch = row_chars[right_col];
272        if !border_style.vertical().contains(&ch) {
273            return None;
274        }
275    }
276
277    // Check bottom side has horizontal lines
278    for col in (start_col + 1)..right_col {
279        if col >= lines[bottom].len() {
280            return None;
281        }
282        let ch = lines[bottom][col];
283        if !border_style.horizontal().contains(&ch) && ch != ' ' {
284            return None;
285        }
286    }
287
288    // Extract title from top border if present
289    let title = extract_title(&lines[start_row], start_col, right_col);
290
291    // Extract labels from side borders if present
292    let left_label = extract_side_label(lines, start_col, start_row, bottom);
293    let right_label = extract_side_label(lines, right_col, start_row, bottom);
294
295    Some(Region {
296        top: start_row as u16,
297        left: start_col as u16,
298        bottom: bottom as u16,
299        right: right_col as u16,
300        border_style,
301        title,
302        left_label,
303        right_label,
304    })
305}
306
307/// Detect the border style from a top-left corner character
308fn detect_border_style(corner: char) -> BorderStyle {
309    match corner {
310        '┌' => BorderStyle::Single,
311        '╭' => BorderStyle::Rounded,
312        '╔' => BorderStyle::Double,
313        '┏' => BorderStyle::Heavy,
314        '+' => BorderStyle::Ascii,
315        _ => BorderStyle::Unknown,
316    }
317}
318
319/// Extract a title from the top border
320fn extract_title(line: &[char], left: usize, right: usize) -> Option<String> {
321    if right <= left + 2 {
322        return None;
323    }
324
325    // Look for text between the corners
326    let content: String = line[(left + 1)..right].iter().collect();
327
328    // Remove border characters and trim
329    let title: String = content
330        .chars()
331        .filter(|c| !HORIZONTAL_CHARS.contains(c))
332        .collect();
333
334    let trimmed = title.trim();
335    if trimmed.is_empty() || trimmed.len() < 2 {
336        None
337    } else {
338        Some(trimmed.to_string())
339    }
340}
341
342/// Extract a label from a side border (left or right edge of a region)
343/// Some TUIs place labels along vertical borders
344pub fn extract_side_label(
345    lines: &[Vec<char>],
346    col: usize,
347    top: usize,
348    bottom: usize,
349) -> Option<String> {
350    if bottom <= top + 2 {
351        return None;
352    }
353
354    // Collect characters from the column between top and bottom
355    let content: String = lines
356        .iter()
357        .skip(top + 1)
358        .take(bottom - top - 1)
359        .filter_map(|line| line.get(col).copied())
360        .collect();
361
362    // Remove border characters and trim
363    let label: String = content
364        .chars()
365        .filter(|c| !VERTICAL_CHARS.contains(c))
366        .collect();
367
368    let trimmed = label.trim();
369    if trimmed.is_empty() || trimmed.len() < 2 {
370        None
371    } else {
372        Some(trimmed.to_string())
373    }
374}
375
376/// Find the innermost region containing a point
377pub fn find_region_at(regions: &[Region], row: u16, col: u16) -> Option<&Region> {
378    regions
379        .iter()
380        .filter(|r| r.contains_content(row, col))
381        .min_by_key(|r| r.width() as u32 * r.height() as u32)
382}
383
384/// Find modal dialogs (regions that appear to be overlay dialogs)
385pub fn find_modals(regions: &[Region], screen_cols: u16, screen_rows: u16) -> Vec<&Region> {
386    regions
387        .iter()
388        .filter(|r| r.is_modal(screen_cols, screen_rows))
389        .collect()
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_detect_single_box() {
398        let screen = "┌────────┐\n│  Test  │\n└────────┘";
399        let regions = detect_regions(screen);
400
401        assert_eq!(regions.len(), 1);
402        assert_eq!(regions[0].top, 0);
403        assert_eq!(regions[0].left, 0);
404        assert_eq!(regions[0].bottom, 2);
405        assert_eq!(regions[0].right, 9);
406        assert_eq!(regions[0].border_style, BorderStyle::Single);
407    }
408
409    #[test]
410    fn test_detect_rounded_box() {
411        let screen = "╭──────╮\n│ Modal│\n╰──────╯";
412        let regions = detect_regions(screen);
413
414        assert_eq!(regions.len(), 1);
415        assert_eq!(regions[0].border_style, BorderStyle::Rounded);
416    }
417
418    #[test]
419    fn test_detect_double_box() {
420        let screen = "╔════════╗\n║ Dialog ║\n╚════════╝";
421        let regions = detect_regions(screen);
422
423        assert_eq!(regions.len(), 1);
424        assert_eq!(regions[0].border_style, BorderStyle::Double);
425    }
426
427    #[test]
428    fn test_detect_ascii_box() {
429        let screen = "+--------+\n| Text   |\n+--------+";
430        let regions = detect_regions(screen);
431
432        assert_eq!(regions.len(), 1);
433        assert_eq!(regions[0].border_style, BorderStyle::Ascii);
434    }
435
436    #[test]
437    fn test_extract_title() {
438        let screen = "┌─ Title ─┐\n│ Content │\n└─────────┘";
439        let regions = detect_regions(screen);
440
441        assert_eq!(regions.len(), 1);
442        assert_eq!(regions[0].title, Some("Title".to_string()));
443    }
444
445    #[test]
446    fn test_region_contains() {
447        let region = Region {
448            top: 5,
449            left: 10,
450            bottom: 15,
451            right: 50,
452            border_style: BorderStyle::Single,
453            title: None,
454            left_label: None,
455            right_label: None,
456        };
457
458        assert!(region.contains(5, 10)); // top-left corner
459        assert!(region.contains(15, 50)); // bottom-right corner
460        assert!(region.contains(10, 30)); // middle
461        assert!(!region.contains(4, 10)); // above
462        assert!(!region.contains(10, 9)); // left of
463    }
464
465    #[test]
466    fn test_is_modal() {
467        let modal = Region {
468            top: 5,
469            left: 20,
470            bottom: 15,
471            right: 60,
472            border_style: BorderStyle::Rounded,
473            title: Some("Confirm".to_string()),
474            left_label: None,
475            right_label: None,
476        };
477
478        assert!(modal.is_modal(80, 24));
479
480        let fullwidth = Region {
481            top: 0,
482            left: 0,
483            bottom: 23,
484            right: 79,
485            border_style: BorderStyle::Single,
486            title: None,
487            left_label: None,
488            right_label: None,
489        };
490
491        assert!(!fullwidth.is_modal(80, 24));
492    }
493}