1use super::Cluster;
2use super::Component;
3use super::Role;
4use super::hash_cluster;
5use super::patterns::{
6 is_button_text, is_checkbox, is_code_block_border, is_diff_line, is_error_message,
7 is_input_field, is_link, is_menu_item, is_panel_border, is_progress_bar, is_prompt_marker,
8 is_status_indicator, is_tool_block_border,
9};
10use crate::core::CursorPosition;
11use crate::core::style::Color;
12
13const TAB_BG_BLUE: u8 = 4;
15const TAB_BG_CYAN: u8 = 6;
17
18#[derive(Debug, Clone)]
20pub struct ClassifyOptions {
21 pub tab_row_threshold: u16,
23}
24
25impl Default for ClassifyOptions {
26 fn default() -> Self {
27 Self {
28 tab_row_threshold: 2,
29 }
30 }
31}
32
33pub fn classify(
34 clusters: Vec<Cluster>,
35 cursor: &CursorPosition,
36 options: &ClassifyOptions,
37) -> Vec<Component> {
38 clusters
39 .into_iter()
40 .map(|cluster| {
41 let role = infer_role(&cluster, cursor, options);
42 let visual_hash = hash_cluster(&cluster);
43 let selected = is_selected(&cluster);
44
45 Component::with_selected(role, cluster.rect, cluster.text, visual_hash, selected)
46 })
47 .collect()
48}
49
50fn is_selected(cluster: &Cluster) -> bool {
51 cluster.style.inverse || cluster.text.starts_with('❯')
52}
53
54fn infer_role(cluster: &Cluster, cursor: &CursorPosition, options: &ClassifyOptions) -> Role {
84 let text = cluster.text.trim();
85
86 if cluster.rect.y == cursor.row
88 && cursor.col >= cluster.rect.x
89 && cursor.col < cluster.rect.x + cluster.rect.width
90 {
91 return Role::Input;
92 }
93
94 if is_button_text(text) {
95 return Role::Button;
96 }
97
98 if cluster.style.inverse {
99 if cluster.rect.y <= options.tab_row_threshold {
100 return Role::Tab;
101 }
102 return Role::MenuItem;
103 }
104
105 if let Some(Color::Indexed(idx)) = &cluster.style.bg_color {
106 if *idx == TAB_BG_BLUE || *idx == TAB_BG_CYAN {
107 return Role::Tab;
108 }
109 }
110
111 if is_error_message(text) {
112 return Role::ErrorMessage;
113 }
114
115 if is_input_field(text) {
116 return Role::Input;
117 }
118
119 if is_checkbox(text) {
120 return Role::Checkbox;
121 }
122
123 if is_prompt_marker(text) {
126 return Role::PromptMarker;
127 }
128
129 if is_menu_item(text) {
134 return Role::MenuItem;
135 }
136
137 if is_link(text) {
138 return Role::Link;
139 }
140
141 if is_progress_bar(text) {
142 return Role::ProgressBar;
143 }
144
145 if is_diff_line(text) {
146 return Role::DiffLine;
147 }
148
149 if is_tool_block_border(text) {
150 return Role::ToolBlock;
151 }
152
153 if is_code_block_border(text) {
154 return Role::CodeBlock;
155 }
156
157 if is_panel_border(text) {
158 return Role::Panel;
159 }
160
161 if is_status_indicator(text) {
162 return Role::Status;
163 }
164
165 Role::StaticText
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::core::style::CellStyle;
172 use crate::core::vom::Rect;
173
174 fn make_cluster(text: &str, style: CellStyle, x: u16, y: u16) -> Cluster {
175 Cluster {
176 rect: Rect::new(x, y, text.len() as u16, 1),
177 text: text.to_string(),
178 style,
179 is_whitespace: false,
180 }
181 }
182
183 fn default_opts() -> ClassifyOptions {
184 ClassifyOptions::default()
185 }
186
187 fn cursor(row: u16, col: u16) -> CursorPosition {
188 CursorPosition {
189 row,
190 col,
191 visible: true,
192 }
193 }
194
195 fn no_cursor() -> CursorPosition {
196 cursor(99, 99)
197 }
198
199 #[test]
200 fn test_button_detection() {
201 let cluster = make_cluster("[Submit]", CellStyle::default(), 0, 0);
202 let role = infer_role(&cluster, &no_cursor(), &default_opts());
203 assert_eq!(role, Role::Button);
204 }
205
206 #[test]
207 fn test_checkbox_not_button() {
208 let cluster = make_cluster("[x]", CellStyle::default(), 0, 0);
209 let role = infer_role(&cluster, &no_cursor(), &default_opts());
210 assert_eq!(role, Role::Checkbox);
211 }
212
213 #[test]
214 fn test_input_from_cursor() {
215 let cluster = make_cluster("Hello", CellStyle::default(), 0, 0);
216
217 let role = infer_role(&cluster, &cursor(0, 2), &default_opts());
218 assert_eq!(role, Role::Input);
219 }
220
221 #[test]
222 fn test_input_from_underscores() {
223 let cluster = make_cluster("Name: ___", CellStyle::default(), 0, 0);
224 let role = infer_role(&cluster, &no_cursor(), &default_opts());
225 assert_eq!(role, Role::Input);
226 }
227
228 #[test]
229 fn test_tab_from_inverse() {
230 let cluster = make_cluster(
231 "Tab1",
232 CellStyle {
233 inverse: true,
234 ..Default::default()
235 },
236 0,
237 0,
238 );
239 let role = infer_role(&cluster, &no_cursor(), &default_opts());
240 assert_eq!(role, Role::Tab);
241 }
242
243 #[test]
244 fn test_tab_from_blue_bg() {
245 let cluster = make_cluster(
246 "Tab2",
247 CellStyle {
248 bg_color: Some(Color::Indexed(4)),
249 ..Default::default()
250 },
251 0,
252 0,
253 );
254 let role = infer_role(&cluster, &no_cursor(), &default_opts());
255 assert_eq!(role, Role::Tab);
256 }
257
258 #[test]
259 fn test_menu_item() {
260 let cluster = make_cluster("> Option 1", CellStyle::default(), 0, 5);
261 let role = infer_role(&cluster, &no_cursor(), &default_opts());
262 assert_eq!(role, Role::MenuItem);
263 }
264
265 #[test]
266 fn test_static_text_default() {
267 let cluster = make_cluster("Hello World", CellStyle::default(), 0, 5);
268 let role = infer_role(&cluster, &no_cursor(), &default_opts());
269 assert_eq!(role, Role::StaticText);
270 }
271
272 #[test]
273 fn test_classify_multiple() {
274 let clusters = vec![
275 make_cluster("[OK]", CellStyle::default(), 0, 0),
276 make_cluster("Cancel", CellStyle::default(), 10, 0),
277 make_cluster("[ ]", CellStyle::default(), 20, 0),
278 ];
279
280 let components = classify(clusters, &no_cursor(), &default_opts());
281
282 assert_eq!(components.len(), 3);
283 assert_eq!(components[0].role, Role::Button);
284 assert_eq!(components[1].role, Role::StaticText);
285 assert_eq!(components[2].role, Role::Checkbox);
286 }
287
288 #[test]
289 fn test_cursor_at_cluster_start_boundary() {
290 let cluster = make_cluster("Hello", CellStyle::default(), 10, 5);
291
292 let role = infer_role(&cluster, &cursor(5, 10), &default_opts());
293 assert_eq!(role, Role::Input);
294 }
295
296 #[test]
297 fn test_cursor_at_cluster_end_boundary() {
298 let cluster = make_cluster("Hello", CellStyle::default(), 10, 5);
299
300 let role = infer_role(&cluster, &cursor(5, 14), &default_opts());
301 assert_eq!(role, Role::Input);
302 }
303
304 #[test]
305 fn test_cursor_past_cluster_end() {
306 let cluster = make_cluster("Hello", CellStyle::default(), 10, 5);
307
308 let role = infer_role(&cluster, &cursor(5, 15), &default_opts());
309 assert_eq!(role, Role::StaticText);
310 }
311
312 #[test]
313 fn test_status_spinner_braille() {
314 for spinner in ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] {
316 let text = format!("{} Loading...", spinner);
317 let cluster = make_cluster(&text, CellStyle::default(), 0, 0);
318 let role = infer_role(&cluster, &no_cursor(), &default_opts());
319 assert_eq!(role, Role::Status, "Failed for spinner: {}", spinner);
320 }
321 }
322
323 #[test]
324 fn test_status_spinner_circle() {
325 for spinner in ['◐', '◑', '◒', '◓'] {
327 let text = format!("{} Processing", spinner);
328 let cluster = make_cluster(&text, CellStyle::default(), 0, 0);
329 let role = infer_role(&cluster, &no_cursor(), &default_opts());
330 assert_eq!(role, Role::Status, "Failed for spinner: {}", spinner);
331 }
332 }
333
334 #[test]
335 fn test_status_thinking_text() {
336 let cluster = make_cluster("⠋ Thinking...", CellStyle::default(), 0, 0);
337 let role = infer_role(&cluster, &no_cursor(), &default_opts());
338 assert_eq!(role, Role::Status);
339 }
340
341 #[test]
342 fn test_status_done_indicator() {
343 let cluster = make_cluster("✓ Done", CellStyle::default(), 0, 0);
344 let role = infer_role(&cluster, &no_cursor(), &default_opts());
345 assert_eq!(role, Role::Status);
346 }
347
348 #[test]
349 fn test_status_checkmark_complete() {
350 let cluster = make_cluster("✔ Complete", CellStyle::default(), 0, 0);
351 let role = infer_role(&cluster, &no_cursor(), &default_opts());
352 assert_eq!(role, Role::Status);
353 }
354
355 #[test]
356 fn test_status_not_regular_text() {
357 let cluster = make_cluster("Hello World", CellStyle::default(), 0, 0);
359 let role = infer_role(&cluster, &no_cursor(), &default_opts());
360 assert_ne!(role, Role::Status);
361 }
362
363 #[test]
364 fn test_tool_block_top_border() {
365 let cluster = make_cluster(
367 "╭─ Write ─────────────────────╮",
368 CellStyle::default(),
369 0,
370 0,
371 );
372 let role = infer_role(&cluster, &no_cursor(), &default_opts());
373 assert_eq!(role, Role::ToolBlock);
374 }
375
376 #[test]
377 fn test_tool_block_bottom_border() {
378 let cluster = make_cluster(
380 "╰──────────────────────────────╯",
381 CellStyle::default(),
382 0,
383 0,
384 );
385 let role = infer_role(&cluster, &no_cursor(), &default_opts());
386 assert_eq!(role, Role::ToolBlock);
387 }
388
389 #[test]
390 fn test_tool_block_not_regular_panel() {
391 let cluster = make_cluster(
393 "┌──────────────────────────────┐",
394 CellStyle::default(),
395 0,
396 0,
397 );
398 let role = infer_role(&cluster, &no_cursor(), &default_opts());
399 assert_eq!(role, Role::Panel);
400 }
401
402 #[test]
403 fn test_prompt_marker_simple() {
404 let cluster = make_cluster(">", CellStyle::default(), 0, 5);
406 let role = infer_role(&cluster, &no_cursor(), &default_opts());
407 assert_eq!(role, Role::PromptMarker);
408 }
409
410 #[test]
411 fn test_prompt_marker_with_space() {
412 let cluster = make_cluster("> ", CellStyle::default(), 0, 5);
414 let role = infer_role(&cluster, &no_cursor(), &default_opts());
415 assert_eq!(role, Role::PromptMarker);
416 }
417
418 #[test]
419 fn test_prompt_marker_not_menu_item() {
420 let cluster = make_cluster("> Option 1", CellStyle::default(), 0, 5);
422 let role = infer_role(&cluster, &no_cursor(), &default_opts());
423 assert_eq!(role, Role::MenuItem);
424 }
425
426 #[test]
427 fn test_prompt_marker_is_interactive() {
428 assert!(Role::PromptMarker.is_interactive());
429 }
430
431 #[test]
432 fn test_yn_button_y_with_spaces() {
433 let cluster = make_cluster("[ Y ]", CellStyle::default(), 0, 0);
434 let role = infer_role(&cluster, &no_cursor(), &default_opts());
435 assert_eq!(role, Role::Button);
436 }
437
438 #[test]
439 fn test_yn_button_n_with_spaces() {
440 let cluster = make_cluster("[ N ]", CellStyle::default(), 0, 0);
441 let role = infer_role(&cluster, &no_cursor(), &default_opts());
442 assert_eq!(role, Role::Button);
443 }
444
445 #[test]
446 fn test_yn_button_yes() {
447 let cluster = make_cluster("[Yes]", CellStyle::default(), 0, 0);
448 let role = infer_role(&cluster, &no_cursor(), &default_opts());
449 assert_eq!(role, Role::Button);
450 }
451
452 #[test]
453 fn test_yn_button_no() {
454 let cluster = make_cluster("[No]", CellStyle::default(), 0, 0);
455 let role = infer_role(&cluster, &no_cursor(), &default_opts());
456 assert_eq!(role, Role::Button);
457 }
458
459 #[test]
460 fn test_yn_not_checkbox() {
461 let cluster = make_cluster("[x]", CellStyle::default(), 0, 0);
463 let role = infer_role(&cluster, &no_cursor(), &default_opts());
464 assert_eq!(role, Role::Checkbox);
465 }
466
467 #[test]
472 fn test_progress_bar_detection() {
473 let cluster = make_cluster("████░░░░", CellStyle::default(), 0, 5);
474 let role = infer_role(&cluster, &no_cursor(), &default_opts());
475 assert_eq!(role, Role::ProgressBar);
476 }
477
478 #[test]
479 fn test_progress_bar_bracket_detection() {
480 let cluster = make_cluster("[===> ]", CellStyle::default(), 0, 5);
481 let role = infer_role(&cluster, &no_cursor(), &default_opts());
482 assert_eq!(role, Role::ProgressBar);
483 }
484
485 #[test]
486 fn test_link_url_detection() {
487 let cluster = make_cluster("https://example.com", CellStyle::default(), 0, 5);
488 let role = infer_role(&cluster, &no_cursor(), &default_opts());
489 assert_eq!(role, Role::Link);
490 }
491
492 #[test]
493 fn test_link_file_path_detection() {
494 let cluster = make_cluster("src/main.rs:42", CellStyle::default(), 0, 5);
495 let role = infer_role(&cluster, &no_cursor(), &default_opts());
496 assert_eq!(role, Role::Link);
497 }
498
499 #[test]
500 fn test_link_is_interactive() {
501 assert!(Role::Link.is_interactive());
502 }
503
504 #[test]
505 fn test_error_message_detection() {
506 let cluster = make_cluster("Error: something failed", CellStyle::default(), 0, 5);
507 let role = infer_role(&cluster, &no_cursor(), &default_opts());
508 assert_eq!(role, Role::ErrorMessage);
509 }
510
511 #[test]
512 fn test_error_message_failure_marker() {
513 let cluster = make_cluster("✗ Failed to compile", CellStyle::default(), 0, 5);
514 let role = infer_role(&cluster, &no_cursor(), &default_opts());
515 assert_eq!(role, Role::ErrorMessage);
516 }
517
518 #[test]
519 fn test_error_message_not_interactive() {
520 assert!(!Role::ErrorMessage.is_interactive());
521 }
522
523 #[test]
524 fn test_diff_line_addition_detection() {
525 let cluster = make_cluster("+ added line", CellStyle::default(), 0, 5);
526 let role = infer_role(&cluster, &no_cursor(), &default_opts());
527 assert_eq!(role, Role::DiffLine);
528 }
529
530 #[test]
531 fn test_diff_line_deletion_detection() {
532 let cluster = make_cluster("-removed_line", CellStyle::default(), 0, 5);
535 let role = infer_role(&cluster, &no_cursor(), &default_opts());
536 assert_eq!(role, Role::DiffLine);
537 }
538
539 #[test]
540 fn test_diff_line_header_detection() {
541 let cluster = make_cluster("@@ -1,5 +1,6 @@", CellStyle::default(), 0, 5);
542 let role = infer_role(&cluster, &no_cursor(), &default_opts());
543 assert_eq!(role, Role::DiffLine);
544 }
545
546 #[test]
547 fn test_diff_line_not_interactive() {
548 assert!(!Role::DiffLine.is_interactive());
549 }
550
551 #[test]
552 fn test_code_block_detection() {
553 let cluster = make_cluster("│ let x = 5;", CellStyle::default(), 0, 5);
554 let role = infer_role(&cluster, &no_cursor(), &default_opts());
555 assert_eq!(role, Role::CodeBlock);
556 }
557
558 #[test]
559 fn test_code_block_not_interactive() {
560 assert!(!Role::CodeBlock.is_interactive());
561 }
562
563 #[test]
564 fn test_menu_item_selected_via_inverse() {
565 let cluster = make_cluster(
566 "Option 1",
567 CellStyle {
568 inverse: true,
569 ..Default::default()
570 },
571 0,
572 5,
573 );
574 let components = classify(vec![cluster], &no_cursor(), &default_opts());
575 assert!(components[0].selected);
576 }
577
578 #[test]
579 fn test_menu_item_selected_via_prefix() {
580 let cluster = make_cluster("❯ Selected Option", CellStyle::default(), 0, 5);
581 let components = classify(vec![cluster], &no_cursor(), &default_opts());
582 assert!(components[0].selected);
583 }
584
585 #[test]
586 fn test_menu_item_not_selected_by_default() {
587 let cluster = make_cluster("Normal Option", CellStyle::default(), 0, 5);
588 let components = classify(vec![cluster], &no_cursor(), &default_opts());
589 assert!(!components[0].selected);
590 }
591
592 #[test]
593 fn test_tab_row_threshold_configurable() {
594 let cluster = make_cluster(
596 "Option",
597 CellStyle {
598 inverse: true,
599 ..Default::default()
600 },
601 0,
602 5,
603 );
604 let role = infer_role(&cluster, &no_cursor(), &default_opts());
605 assert_eq!(role, Role::MenuItem);
606
607 let opts = ClassifyOptions {
609 tab_row_threshold: 5,
610 };
611 let role = infer_role(&cluster, &no_cursor(), &opts);
612 assert_eq!(role, Role::Tab);
613 }
614
615 #[test]
616 fn test_menu_item_with_file_path_not_link() {
617 let cluster = make_cluster("> src/main.rs", CellStyle::default(), 0, 5);
620 let role = infer_role(&cluster, &no_cursor(), &default_opts());
621 assert_eq!(
622 role,
623 Role::MenuItem,
624 "Menu item with file path should be MenuItem, not Link"
625 );
626 }
627
628 #[test]
629 fn test_menu_item_with_file_path_and_line_number() {
630 let cluster = make_cluster("> src/lib.rs:42", CellStyle::default(), 0, 5);
632 let role = infer_role(&cluster, &no_cursor(), &default_opts());
633 assert_eq!(
634 role,
635 Role::MenuItem,
636 "Menu item with file:line should be MenuItem"
637 );
638 }
639
640 #[test]
641 fn test_dash_list_item_not_diff_line() {
642 let cluster = make_cluster("- List item", CellStyle::default(), 0, 5);
645 let role = infer_role(&cluster, &no_cursor(), &default_opts());
646 assert_eq!(
647 role,
648 Role::MenuItem,
649 "Dash list item should be MenuItem, not DiffLine"
650 );
651 }
652
653 #[test]
654 fn test_dash_list_navigation() {
655 let cluster = make_cluster("- Select option", CellStyle::default(), 0, 5);
657 let role = infer_role(&cluster, &no_cursor(), &default_opts());
658 assert_eq!(
659 role,
660 Role::MenuItem,
661 "Dash navigation item should be MenuItem"
662 );
663 }
664
665 mod prop_tests {
666 use super::*;
667 use proptest::prelude::*;
668
669 fn arb_cluster() -> impl Strategy<Value = Cluster> {
670 (
671 "[a-zA-Z0-9 ]{1,20}",
672 any::<bool>(),
673 any::<bool>(),
674 0u16..100,
675 0u16..50,
676 )
677 .prop_map(|(text, bold, inverse, x, y)| Cluster {
678 rect: Rect::new(x, y, text.len() as u16, 1),
679 text,
680 style: CellStyle {
681 bold,
682 underline: false,
683 inverse,
684 fg_color: None,
685 bg_color: None,
686 },
687 is_whitespace: false,
688 })
689 }
690
691 proptest! {
692 #[test]
693 fn classification_is_deterministic(
694 clusters in prop::collection::vec(arb_cluster(), 1..10),
695 cursor_row in 0u16..50,
696 cursor_col in 0u16..100
697 ) {
698 let clusters_clone: Vec<Cluster> = clusters.iter().map(|c| Cluster {
699 rect: c.rect,
700 text: c.text.clone(),
701 style: c.style.clone(),
702 is_whitespace: c.is_whitespace,
703 }).collect();
704 let opts = ClassifyOptions::default();
705 let cur = cursor(cursor_row, cursor_col);
706
707 let result1 = classify(clusters, &cur, &opts);
708 let result2 = classify(clusters_clone, &cur, &opts);
709
710 prop_assert_eq!(result1.len(), result2.len());
711 for (a, b) in result1.iter().zip(result2.iter()) {
712 prop_assert_eq!(a.role, b.role);
713 prop_assert_eq!(a.bounds, b.bounds);
714 prop_assert_eq!(&a.text_content, &b.text_content);
715 prop_assert_eq!(a.visual_hash, b.visual_hash);
716 }
717 }
718
719 #[test]
720 fn classify_preserves_count(
721 clusters in prop::collection::vec(arb_cluster(), 0..20),
722 cursor_row in 0u16..50,
723 cursor_col in 0u16..100
724 ) {
725 let count = clusters.len();
726 let opts = ClassifyOptions::default();
727 let cur = cursor(cursor_row, cursor_col);
728 let components = classify(clusters, &cur, &opts);
729 prop_assert_eq!(components.len(), count);
730 }
731
732 #[test]
733 fn component_ids_unique(
734 clusters in prop::collection::vec(arb_cluster(), 2..10),
735 cursor_row in 0u16..50,
736 cursor_col in 0u16..100
737 ) {
738 let opts = ClassifyOptions::default();
739 let cur = cursor(cursor_row, cursor_col);
740 let components = classify(clusters, &cur, &opts);
741 let ids: Vec<_> = components.iter().map(|c| c.id).collect();
742
743 for (i, id) in ids.iter().enumerate() {
744 prop_assert!(
745 !ids[i + 1..].contains(id),
746 "Duplicate component ID found"
747 );
748 }
749 }
750 }
751 }
752}