1#[derive(Debug, Clone)]
11pub struct Region {
12 pub top: u16,
14 pub left: u16,
16 pub bottom: u16,
18 pub right: u16,
20 pub border_style: BorderStyle,
22 pub title: Option<String>,
24 pub left_label: Option<String>,
26 pub right_label: Option<String>,
28}
29
30impl Region {
31 pub fn width(&self) -> u16 {
33 self.right.saturating_sub(self.left) + 1
34 }
35
36 pub fn height(&self) -> u16 {
38 self.bottom.saturating_sub(self.top) + 1
39 }
40
41 pub fn is_modal(&self, screen_cols: u16, _screen_rows: u16) -> bool {
43 let width = self.width();
44 let height = self.height();
45
46 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum BorderStyle {
72 Single,
74 Rounded,
76 Double,
78 Heavy,
80 Ascii,
82 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
154const TOP_LEFT_CORNERS: [char; 5] = ['┌', '╭', '╔', '┏', '+'];
156
157const HORIZONTAL_CHARS: [char; 4] = ['─', '═', '━', '-'];
159
160const VERTICAL_CHARS: [char; 4] = ['│', '║', '┃', '|'];
162
163pub 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 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 if let Some(region) = trace_box(&lines, row_idx, col_idx) {
178 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 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 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
215fn 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 if !border_style.top_left().contains(&top_left_char) {
222 return None;
223 }
224
225 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 if !ch.is_alphanumeric() && ch != ' ' && ch != ':' && ch != '-' {
235 break;
236 }
237 }
238 }
239
240 let right_col = top_right_col?;
241
242 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 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 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 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 let title = extract_title(&lines[start_row], start_col, right_col);
290
291 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
307fn 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
319fn extract_title(line: &[char], left: usize, right: usize) -> Option<String> {
321 if right <= left + 2 {
322 return None;
323 }
324
325 let content: String = line[(left + 1)..right].iter().collect();
327
328 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
342pub 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 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 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
376pub 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
384pub 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)); assert!(region.contains(15, 50)); assert!(region.contains(10, 30)); assert!(!region.contains(4, 10)); assert!(!region.contains(10, 9)); }
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}