1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
use anyhow::Result;
use crossterm::{
cursor::{Hide, MoveDown, MoveToColumn, MoveUp, Show},
event::{self, Event, KeyCode, KeyEventKind, poll},
execute,
style::{Color, Print, ResetColor, SetForegroundColor},
terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode},
};
use std::io::{self, Write};
use std::time::Duration;
/// Action that can be taken when a menu item is selected
pub enum MenuAction<T> {
/// Execute a callback function with current context
Callback {
callback: Box<dyn Fn(&mut T, &mut KeyMenuLineCounts) -> Result<MenuResult>>,
clear_menu: bool,
},
/// Navigate to a nested submenu
Submenu(KeyMenuConfig<T>),
/// Exit current menu level
Exit,
ExitKeepHeader,
}
/// Result of executing a menu action
#[derive(Debug, Clone)]
pub enum MenuResult {
/// Stay in current menu
Stay,
/// Exit current menu level
Exit,
/// Exit current menu level
ExitKeepHeader,
}
/// Configuration for a single menu item
pub struct KeyMenuItem<T> {
pub key: KeyCode,
pub description: String,
pub color: Option<Color>,
pub action: MenuAction<T>,
}
/// Configuration for an entire key menu
pub struct KeyMenuConfig<T> {
/// Optional header lines to display above menu (e.g., "Description: foo", "Channels: RGB")
pub header_lines: Option<Box<dyn Fn(&T) -> Vec<String>>>,
/// Menu items
pub items: Vec<KeyMenuItem<T>>,
/// Whether this menu should loop (stay open after actions) or exit after one action
pub should_loop: bool,
}
pub struct KeyMenuLineCounts {
pub menu: usize,
pub header: usize,
}
impl KeyMenuLineCounts {
fn total(&self) -> usize {
self.menu + self.header
}
}
/// Clear the specified number of lines from the terminal
pub fn clear_lines(line_count: usize) -> Result<()> {
if line_count == 0 {
return Ok(());
}
execute!(io::stdout(), MoveUp(line_count as u16), MoveToColumn(0))?;
for _ in 0..line_count {
execute!(io::stdout(), Clear(ClearType::CurrentLine))?;
println!();
}
execute!(io::stdout(), MoveUp(line_count as u16), MoveToColumn(0))?;
Ok(())
}
impl<T> KeyMenuConfig<T> {
/// Run a key menu with the given configuration and context
pub fn run_menu(&self, context: &mut T) -> Result<MenuResult> {
// let mut total_lines = 0;
let mut line_counts = KeyMenuLineCounts { menu: 0, header: 0 };
loop {
// Always clear previous menu content
if line_counts.total() > 0 {
clear_lines(line_counts.total())?;
}
disable_raw_mode()?;
execute!(io::stdout(), Show)?;
// Display header lines if configured
line_counts.header = if let Some(header_fn) = &self.header_lines {
let header_lines = header_fn(context);
let mut line_count = 0;
for header_line in &header_lines {
let lines = crate::split_text_into_lines(&header_line)?;
for line in &lines {
println!("{line}",);
}
line_count += lines.len();
}
line_count
} else {
0
};
// Display menu items
let items: Vec<_> = self.items.iter().collect();
line_counts.menu = self.display_menu_items()?;
io::stdout().flush()?;
enable_raw_mode()?;
execute!(io::stdout(), Hide)?;
let item = loop {
if poll(Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
disable_raw_mode()?;
execute!(io::stdout(), Show)?;
println!("\nExiting...");
std::process::exit(0);
}
_ => {
// Check menu items
if let Some(item) =
items.iter().find(|item| item.key == key.code)
{
break Some(item);
}
continue; // Unknown key, keep listening
}
};
}
}
}
};
let choice = if let Some(item) = item {
match &item.action {
MenuAction::Callback {
callback,
clear_menu,
} => {
if *clear_menu {
clear_lines(line_counts.menu)?;
line_counts.menu = 0;
}
callback(context, &mut line_counts)
}
MenuAction::Submenu(submenu_config) => {
// Clear the current menu completely before showing submenu
clear_lines(line_counts.total())?;
line_counts.menu = 0;
line_counts.header = 0;
// Disable raw mode before running submenu so header printing works
disable_raw_mode()?;
execute!(io::stdout(), Show)?;
// Run the submenu
let result = Self::run_menu(submenu_config, context)?;
match result {
MenuResult::Exit => Ok(MenuResult::Stay),
other => Ok(other),
}
}
MenuAction::Exit => Ok(MenuResult::Exit),
MenuAction::ExitKeepHeader => Ok(MenuResult::ExitKeepHeader),
}
} else {
Ok(MenuResult::Exit)
};
disable_raw_mode()?;
execute!(io::stdout(), Show)?;
io::stdout().flush()?;
match choice? {
MenuResult::Stay => {
if !self.should_loop {
return Ok(MenuResult::Exit);
}
}
MenuResult::Exit => {
clear_lines(line_counts.total())?;
return Ok(MenuResult::Exit);
}
MenuResult::ExitKeepHeader => {
clear_lines(line_counts.menu)?;
return Ok(MenuResult::Exit);
}
}
}
}
/// Display menu items and return the number of lines used
fn display_menu_items(&self) -> Result<usize> {
if self.items.is_empty() {
return Ok(0);
}
let terminal_width = crossterm::terminal::size().unwrap_or((80, 24)).0 as usize;
// Check if any description contains newlines or would cause wrapping
let has_multiline_descriptions = self.items.iter().any(|item| {
item.description.contains('\n')
|| (item.key.to_string().len() + item.description.len() + 7) > terminal_width // "key description"
});
// If any description is multi-line or would wrap, force vertical layout
if has_multiline_descriptions {
return self.display_vertically();
}
// Check if we can display horizontally (all items fit on one line)
let total_width: usize = self
.items
.iter()
.map(|item| item.key.to_string().len() + item.description.len() + 4) // "key description "
.sum();
if total_width < terminal_width {
// Display horizontally
for (i, item) in self.items.iter().enumerate() {
let color = item.color.unwrap_or(Color::Cyan);
execute!(
io::stdout(),
SetForegroundColor(color),
Print(&item.key),
ResetColor,
Print(format!(" {}", item.description))
)?;
if i < self.items.len() - 1 {
print!(" ");
}
}
println!();
Ok(1) // One line used
} else {
// Display vertically
self.display_vertically()
}
}
/// Display menu items vertically, handling multi-line descriptions properly
fn display_vertically(&self) -> Result<usize> {
let mut total_lines = 0;
let terminal_width = crossterm::terminal::size().unwrap_or((80, 24)).0 as usize;
for item in &self.items {
let color = item.color.unwrap_or(Color::Cyan);
let key_str = item.key.to_string();
let prefix = format!("{} ", key_str);
let prefix_len = prefix.len();
// Handle multi-line descriptions
if item.description.contains('\n') {
let lines: Vec<&str> = item.description.split('\n').collect();
for (line_idx, line) in lines.iter().enumerate() {
if line_idx == 0 {
// First line: show key + description
execute!(
io::stdout(),
SetForegroundColor(color),
Print(&key_str),
ResetColor,
Print(format!(" {}", line))
)?;
} else {
// Subsequent lines: indent to align with description
execute!(
io::stdout(),
Print(format!("{}{}", " ".repeat(prefix_len), line))
)?;
}
println!();
total_lines += 1;
}
} else {
// Single line description - check if it needs wrapping
let full_line = format!("{}{}", prefix, item.description);
if full_line.len() <= terminal_width {
// Fits on one line
execute!(
io::stdout(),
SetForegroundColor(color),
Print(&key_str),
ResetColor,
Print(format!(" {}", item.description))
)?;
println!();
total_lines += 1;
} else {
// Needs wrapping
let available_width = terminal_width.saturating_sub(prefix_len);
if available_width < 10 {
// Not enough space for reasonable wrapping, just print as-is
execute!(
io::stdout(),
SetForegroundColor(color),
Print(&key_str),
ResetColor,
Print(format!(" {}", item.description))
)?;
println!();
total_lines += 1;
} else {
// Wrap the description
let wrapped_lines = Self::wrap_text(&item.description, available_width);
for (line_idx, line) in wrapped_lines.iter().enumerate() {
if line_idx == 0 {
// First line: show key + description
execute!(
io::stdout(),
SetForegroundColor(color),
Print(&key_str),
ResetColor,
Print(format!(" {}", line))
)?;
} else {
// Subsequent lines: indent to align with description
execute!(
io::stdout(),
Print(format!("{}{}", " ".repeat(prefix_len), line))
)?;
}
println!();
total_lines += 1;
}
}
}
}
}
Ok(total_lines)
}
/// Wrap text to fit within the specified width, breaking at word boundaries when possible
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
// If adding this word would exceed the width
if !current_line.is_empty() && current_line.len() + 1 + word.len() > width {
// Start a new line
lines.push(current_line);
current_line = word.to_string();
} else {
// Add word to current line
if !current_line.is_empty() {
current_line.push(' ');
}
current_line.push_str(word);
}
}
// Add the last line if it's not empty
if !current_line.is_empty() {
lines.push(current_line);
}
// If no lines were created, return the original text
if lines.is_empty() {
lines.push(text.to_string());
}
lines
}
}