1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub enum Framework {
3 Ink,
4 Blessed,
5 BubbleTea,
6 Textual,
7 Ncurses,
8 Inquirer,
9 Prompts,
10 Ratatui,
11 Unknown,
12}
13
14mod signatures {
16 pub const BRAILLE_SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17 pub const CHARM_SPINNERS: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
18 pub const BLOCK_CHARS: &[&str] = &["█", "▓", "▒", "░", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
19 pub const SPARKLINE_CHARS: &[&str] = &["▁", "▂", "▃", "▄", "▅", "▆", "▇"];
20 pub const SELECT_POINTERS: &[char] = &['❯', '›'];
21 pub const INQUIRER_CIRCLES: &[char] = &['◯', '◉'];
22 pub const HEAVY_BORDERS: &[&str] = &["┏", "┓", "┗"];
23}
24
25fn has_any(screen: &str, chars: &[&str]) -> bool {
26 chars.iter().any(|c| screen.contains(c))
27}
28
29fn has_all(screen: &str, chars: &[&str]) -> bool {
30 chars.iter().all(|c| screen.contains(c))
31}
32
33fn count_matches(screen: &str, chars: &[&str]) -> usize {
34 chars.iter().filter(|c| screen.contains(*c)).count()
35}
36
37fn line_starts_with_any(screen: &str, chars: &[char]) -> bool {
38 screen.lines().any(|l| {
39 let trimmed = l.trim();
40 chars.iter().any(|c| trimmed.starts_with(*c))
41 })
42}
43
44type Detector = fn(&str) -> bool;
45
46const DETECTORS: &[(Framework, Detector)] = &[
47 (Framework::Inquirer, is_inquirer),
48 (Framework::Ink, is_ink),
49 (Framework::Prompts, is_prompts),
50 (Framework::BubbleTea, is_bubbletea),
51 (Framework::Textual, is_textual),
52 (Framework::Blessed, is_blessed),
53 (Framework::Ratatui, is_ratatui),
54 (Framework::Ncurses, is_ncurses),
55];
56
57pub fn detect_framework(screen: &str) -> Framework {
58 DETECTORS
59 .iter()
60 .find(|(_, detect)| detect(screen))
61 .map(|(fw, _)| *fw)
62 .unwrap_or(Framework::Unknown)
63}
64
65fn is_ink(screen: &str) -> bool {
66 use signatures::*;
67 let has_spinner = has_any(screen, BRAILLE_SPINNERS);
68 let has_pointer = SELECT_POINTERS.iter().any(|c| screen.contains(*c));
69 let has_select = has_pointer && line_starts_with_any(screen, SELECT_POINTERS);
70 has_spinner || has_select || (screen.contains("?") && has_pointer)
71}
72
73fn is_inquirer(screen: &str) -> bool {
74 use signatures::*;
75 let circle_count = screen
76 .lines()
77 .filter(|l| INQUIRER_CIRCLES.iter().any(|c| l.trim().starts_with(*c)))
78 .count();
79 circle_count >= 2
80 || screen.contains("◻")
81 || screen.contains("◼")
82 || screen.contains("(Y/n)")
83 || screen.contains("(y/N)")
84}
85
86fn is_prompts(screen: &str) -> bool {
87 let toggle = screen.contains("◉") && screen.contains("○");
88 let select = screen.contains("›") && screen.lines().any(|l| l.contains("←") || l.contains("→"));
89 toggle || select
90}
91
92fn is_bubbletea(screen: &str) -> bool {
93 use signatures::*;
94 let has_spinner = has_any(screen, CHARM_SPINNERS);
95 let has_help = ["q: quit", "ctrl+c", "esc: back", "enter: select"]
96 .iter()
97 .any(|s| screen.contains(s));
98 let has_input = screen.contains("│") && screen.lines().any(|l| l.contains(">"));
99 has_spinner || (has_help && has_input)
100}
101
102fn is_textual(screen: &str) -> bool {
103 use signatures::*;
104 let footer_keys = ["^q", "^c", "F1", "ESC"];
105 let has_footer = screen
106 .lines()
107 .last()
108 .is_some_and(|l| footer_keys.iter().any(|k| l.contains(k)));
109 let has_borders = has_all(screen, HEAVY_BORDERS);
110 let has_table = has_all(screen, &["│", "─", "┼"]);
111 has_footer || has_borders || has_table
112}
113
114fn is_blessed(screen: &str) -> bool {
115 let has_scrollbar = screen.contains("▒") || screen.contains("░");
116 let has_input = has_all(screen, &["[", "]"]) && screen.contains("_____");
117 let has_box = has_all(screen, &["┌─", "─┐", "└─"]);
118 (has_scrollbar && has_box) || has_input
119}
120
121fn is_ratatui(screen: &str) -> bool {
122 use signatures::*;
123 count_matches(screen, BLOCK_CHARS) >= 3 || count_matches(screen, SPARKLINE_CHARS) >= 3
124}
125
126fn is_ncurses(screen: &str) -> bool {
127 let has_fkeys = ["F1", "F2", "F10", "^X"].iter().any(|k| screen.contains(k));
128 let has_box = has_all(screen, &["+-", "-+"]) && (screen.contains("|") || screen.contains("│"));
129 let has_menu = screen
130 .lines()
131 .next()
132 .is_some_and(|l| l.contains("File") || l.contains("Help"));
133 (has_fkeys && has_box) || has_menu
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn test_detect_ink() {
142 let screen = "? Select a color\n ❯ Red\n Blue\n Green";
143 assert_eq!(detect_framework(screen), Framework::Ink);
144 }
145
146 #[test]
147 fn test_detect_inquirer() {
148 let screen = "? Choose an option (Y/n)\n ◯ Option 1\n ◉ Option 2";
149 assert_eq!(detect_framework(screen), Framework::Inquirer);
150 }
151
152 #[test]
153 fn test_detect_bubbletea() {
154 let screen = "My App\n\n> Select an item\n\nq: quit | enter: select";
155 assert!(matches!(
156 detect_framework(screen),
157 Framework::BubbleTea | Framework::Unknown
158 ));
159 }
160
161 #[test]
162 fn test_detect_unknown() {
163 let screen = "Hello World";
164 assert_eq!(detect_framework(screen), Framework::Unknown);
165 }
166
167 #[test]
168 fn test_detect_ratatui() {
169 let screen = "Progress: [████████░░░░░░░░] 50%\nSparkline: ▁▂▃▄▅▆▇█";
170 assert_eq!(detect_framework(screen), Framework::Ratatui);
171 }
172}