1pub use ratatui_cfg_derive::ConfigMenu;
7
8use {
9 color_eyre::eyre::{Error, Result},
10 ratatui::{
11 Frame,
12 layout::{Constraint, Direction, Layout, Rect},
13 style::{Color, Modifier, Style},
14 text::{Line, Span},
15 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16 },
17 serde::{Deserialize, Serialize},
18 std::{any::Any, fmt::Debug, path::Path, str::FromStr},
19 undo::{Edit, Record},
20};
21
22#[derive(Clone, Debug, PartialEq)]
23pub enum FieldType {
24 String,
25 Bool,
26 I8,
27 I16,
28 I32,
29 I64,
30 I128,
31 Isize,
32 U8,
33 U16,
34 U32,
35 U64,
36 U128,
37 Usize,
38 F32,
39 F64,
40 Nested,
41 Unknown,
42}
43
44impl FromStr for FieldType {
45 type Err = String;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s {
49 "String" => Ok(FieldType::String),
50 "bool" => Ok(FieldType::Bool),
51 "i8" => Ok(FieldType::I8),
52 "i16" => Ok(FieldType::I16),
53 "i32" => Ok(FieldType::I32),
54 "i64" => Ok(FieldType::I64),
55 "i128" => Ok(FieldType::I128),
56 "isize" => Ok(FieldType::Isize),
57 "u8" => Ok(FieldType::U8),
58 "u16" => Ok(FieldType::U16),
59 "u32" => Ok(FieldType::U32),
60 "u64" => Ok(FieldType::U64),
61 "u128" => Ok(FieldType::U128),
62 "usize" => Ok(FieldType::Usize),
63 "f32" => Ok(FieldType::F32),
64 "f64" => Ok(FieldType::F64),
65 _ => Ok(FieldType::Nested),
66 }
67 }
68}
69
70type Getter = Box<dyn Fn(&dyn Any) -> Option<String>>;
71type Setter = Box<dyn Fn(&mut dyn Any, String) -> Result<(), String>>;
72type NestedGetter = Box<dyn Fn(&dyn Any) -> Option<Box<dyn Any>>>;
73type NestedMetadataGetter = Box<dyn Fn() -> Vec<FieldMetadata>>;
74type NestedSetter = Box<dyn Fn(&mut dyn Any, Box<dyn Any>) -> Result<(), String>>;
75
76pub struct FieldMetadata {
77 pub name: &'static str,
78 pub is_nested: bool,
79 pub is_option: bool,
80 pub is_vec: bool,
81 pub field_type: FieldType,
82 pub getter: Getter,
83 pub setter: Setter,
84 pub nested_getter: Option<NestedGetter>,
85 pub nested_metadata_getter: Option<NestedMetadataGetter>,
86 pub nested_setter: Option<NestedSetter>,
87}
88
89pub trait ConfigMenuTrait: Debug + Clone + Serialize + for<'de> Deserialize<'de> + 'static {
90 fn get_field_metadata() -> Vec<FieldMetadata>;
91 fn get_menu_title() -> &'static str;
92 fn as_any(&self) -> &dyn Any;
93 fn as_any_mut(&mut self) -> &mut dyn Any;
94}
95
96pub fn format_field_value<T: Debug>(value: &T) -> String {
97 format!("{:?}", value)
98}
99
100fn strip_debug_quotes(s: &str) -> String {
101 if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
102 let inner = &s[1..s.len() - 1];
103 inner.replace(r#"\""#, "\"").replace(r"\\", r"\")
104 } else {
105 s.to_string()
106 }
107}
108
109pub trait ParsableField: Sized {
110 fn parse_from_string(value: String) -> Result<Self, String>;
111}
112
113impl ParsableField for String {
114 fn parse_from_string(value: String) -> Result<Self, String> {
115 Ok(value)
116 }
117}
118
119impl ParsableField for bool {
120 fn parse_from_string(value: String) -> Result<Self, String> {
121 value
122 .parse()
123 .map_err(|_| format!("Failed to parse '{}'", value))
124 }
125}
126
127macro_rules! impl_parsable_for_primitives {
128 ($($ty:ty),*) => {
129 $(
130 impl ParsableField for $ty {
131 fn parse_from_string(value: String) -> Result<Self, String> {
132 value
133 .parse()
134 .map_err(|_| format!("Failed to parse '{}'", value))
135 }
136 }
137 )*
138 };
139}
140
141impl_parsable_for_primitives!(
142 i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, f32, f64
143);
144
145impl<T> ParsableField for T
146where
147 T: ConfigMenuTrait,
148{
149 fn parse_from_string(value: String) -> Result<Self, String> {
150 toml::from_str(&value).map_err(|e| format!("Failed to parse nested config: {}", e))
151 }
152}
153
154pub fn parse_and_set<T>(field: &mut T, value: String) -> Result<(), String>
155where
156 T: ParsableField,
157{
158 *field = T::parse_from_string(value)?;
159 Ok(())
160}
161
162#[derive(Clone)]
163pub struct ConfigEdit {
164 _field_path: Vec<String>,
165 old_value: String,
166 new_value: String,
167}
168
169impl Edit for ConfigEdit {
170 type Target = String;
171 type Output = ();
172
173 fn edit(&mut self, target: &mut String) {
174 *target = self.new_value.clone();
175 }
176
177 fn undo(&mut self, target: &mut String) {
178 *target = self.old_value.clone();
179 }
180}
181
182pub struct MenuController<T: ConfigMenuTrait> {
183 pub config: T,
184 pub menu_state: MenuState,
185 pub history: Record<ConfigEdit>,
186 pub editing_mode: bool,
187 pub edit_buffer: String,
188 pub edit_cursor: usize,
189}
190
191impl<T: ConfigMenuTrait> MenuController<T> {
192 pub fn new(config: T) -> Self {
193 let menu_state = MenuState::new(&config);
194 Self {
195 config,
196 menu_state,
197 history: Record::new(),
198 editing_mode: false,
199 edit_buffer: String::new(),
200 edit_cursor: 0,
201 }
202 }
203
204 pub fn start_editing(&mut self) {
205 let Some(item) = self.menu_state.get_current_item() else {
206 return;
207 };
208
209 if item.is_submenu || item.is_vec_container {
210 return;
211 }
212
213 self.editing_mode = true;
214
215 if item.field_type == FieldType::String {
216 self.edit_buffer = strip_debug_quotes(&item.value);
217 } else {
218 self.edit_buffer = item.value.clone();
219 }
220
221 self.edit_cursor = self.edit_buffer.len();
222 }
223
224 pub fn toggle_boolean(&mut self) -> Result<(), String> {
225 let Some(item) = self.menu_state.get_current_item() else {
226 return Ok(());
227 };
228
229 if item.field_type != FieldType::Bool || item.is_submenu || item.is_vec_container {
230 return Ok(());
231 }
232
233 let new_value = if item.value == "true" {
234 "false"
235 } else {
236 "true"
237 };
238
239 let field_path = self.menu_state.get_current_field_path();
240 let result = self.apply_edit_at_path(&field_path, new_value);
241
242 if result.is_ok() {
243 self.refresh_menu_state()?;
244 }
245
246 result
247 }
248
249 pub fn finish_editing(&mut self) -> Result<(), String> {
250 if !self.editing_mode {
251 return Ok(());
252 }
253
254 let new_value = self.edit_buffer.clone();
255 let field_path = self.menu_state.get_current_field_path();
256
257 let result = self.apply_edit_at_path(&field_path, &new_value);
258
259 if result.is_ok() {
260 self.refresh_menu_state()?;
261 }
262
263 self.editing_mode = false;
264 result
265 }
266
267 fn refresh_menu_state(&mut self) -> Result<(), String> {
268 let current_path = self.menu_state.get_navigation_path();
269 self.menu_state = MenuState::new(&self.config);
270
271 for field_name in current_path {
272 self.menu_state
273 .enter_submenu_by_name(&self.config, &field_name)
274 .map_err(|e| format!("Failed to restore navigation: {}", e))?;
275 }
276
277 Ok(())
278 }
279
280 fn apply_edit_at_path(&mut self, field_path: &[String], new_value: &str) -> Result<(), String> {
281 if field_path.is_empty() {
282 return Err("Empty field path".to_string());
283 }
284
285 if field_path.len() == 1 {
286 Self::set_field_on_config(&mut self.config, &field_path[0], new_value)
287 } else {
288 self.set_nested_field_recursive(field_path, new_value)
289 }
290 }
291
292 fn set_field_on_config<U: ConfigMenuTrait>(
293 config: &mut U,
294 field_name: &str,
295 value: &str,
296 ) -> Result<(), String> {
297 let metadata = U::get_field_metadata();
298 let field_meta = metadata
299 .iter()
300 .find(|m| m.name == field_name)
301 .ok_or_else(|| format!("Field '{}' not found", field_name))?;
302
303 (field_meta.setter)(config.as_any_mut(), value.to_string())
304 }
305
306 fn set_nested_field_recursive(
307 &mut self,
308 field_path: &[String],
309 new_value: &str,
310 ) -> Result<(), String> {
311 let root_field = &field_path[0];
312 let metadata = T::get_field_metadata();
313
314 let field_meta = metadata
315 .iter()
316 .find(|m| m.name == root_field)
317 .ok_or_else(|| format!("Field '{}' not found", root_field))?;
318
319 if !field_meta.is_nested {
320 return Err(format!("Field '{}' is not nested", root_field));
321 }
322
323 let nested_getter = field_meta
324 .nested_getter
325 .as_ref()
326 .ok_or_else(|| "No nested getter available".to_string())?;
327
328 let nested_any = (nested_getter)(self.config.as_any())
329 .ok_or_else(|| format!("Failed to get nested field '{}'", root_field))?;
330
331 let updated_nested = self.update_nested_any(
332 nested_any,
333 &field_path[1..],
334 new_value,
335 field_meta.nested_metadata_getter.as_ref(),
336 )?;
337
338 let nested_setter = field_meta
339 .nested_setter
340 .as_ref()
341 .ok_or_else(|| "No nested setter available".to_string())?;
342
343 (nested_setter)(self.config.as_any_mut(), updated_nested)
344 }
345
346 fn update_nested_any(
347 &self,
348 mut nested_any: Box<dyn Any>,
349 remaining_path: &[String],
350 new_value: &str,
351 metadata_getter: Option<&NestedMetadataGetter>,
352 ) -> Result<Box<dyn Any>, String> {
353 if remaining_path.is_empty() {
354 return Ok(nested_any);
355 }
356
357 let metadata =
358 metadata_getter.ok_or_else(|| "No metadata getter for nested field".to_string())?();
359
360 let field_name = &remaining_path[0];
361 let field_meta = metadata
362 .iter()
363 .find(|m| m.name == field_name)
364 .ok_or_else(|| format!("Field '{}' not found in nested structure", field_name))?;
365
366 if remaining_path.len() == 1 {
367 (field_meta.setter)(nested_any.as_mut(), new_value.to_string())?;
368 Ok(nested_any)
369 } else {
370 if !field_meta.is_nested {
371 return Err(format!("Field '{}' is not nested", field_name));
372 }
373
374 let inner_nested_getter = field_meta
375 .nested_getter
376 .as_ref()
377 .ok_or_else(|| "No nested getter for inner field".to_string())?;
378
379 let inner_nested = (inner_nested_getter)(nested_any.as_ref())
380 .ok_or_else(|| format!("Failed to get nested field '{}'", field_name))?;
381
382 let updated_inner = self.update_nested_any(
383 inner_nested,
384 &remaining_path[1..],
385 new_value,
386 field_meta.nested_metadata_getter.as_ref(),
387 )?;
388
389 let inner_setter = field_meta
390 .nested_setter
391 .as_ref()
392 .ok_or_else(|| "No nested setter for inner field".to_string())?;
393
394 (inner_setter)(nested_any.as_mut(), updated_inner)?;
395 Ok(nested_any)
396 }
397 }
398
399 pub fn enter_submenu(&mut self) -> Result<(), String> {
400 let item = self
401 .menu_state
402 .get_current_item()
403 .ok_or_else(|| "No item selected".to_string())?;
404
405 if !item.is_submenu {
406 return Err("Current item is not a submenu".to_string());
407 }
408
409 let field_name = item.label.clone();
410 self.menu_state
411 .enter_submenu_by_name(&self.config, &field_name)
412 }
413
414 pub fn cancel_editing(&mut self) {
415 self.editing_mode = false;
416 self.edit_buffer.clear();
417 self.edit_cursor = 0;
418 }
419
420 pub fn is_current_submenu(&self) -> bool {
421 self.menu_state
422 .get_current_item()
423 .is_some_and(|item| item.is_submenu)
424 }
425
426 pub fn is_current_boolean(&self) -> bool {
427 self.menu_state
428 .get_current_item()
429 .is_some_and(|item| item.field_type == FieldType::Bool && !item.is_submenu)
430 }
431
432 pub fn handle_edit_input(&mut self, c: char) {
433 self.edit_buffer.insert(self.edit_cursor, c);
434 self.edit_cursor += 1;
435 }
436
437 pub fn handle_backspace(&mut self) {
438 if self.edit_cursor > 0 {
439 self.edit_buffer.remove(self.edit_cursor - 1);
440 self.edit_cursor -= 1;
441 }
442 }
443
444 pub fn handle_delete(&mut self) {
445 if self.edit_cursor < self.edit_buffer.len() {
446 self.edit_buffer.remove(self.edit_cursor);
447 }
448 }
449
450 pub fn move_cursor_left(&mut self) {
451 if self.edit_cursor > 0 {
452 self.edit_cursor -= 1;
453 }
454 }
455
456 pub fn move_cursor_right(&mut self) {
457 if self.edit_cursor < self.edit_buffer.len() {
458 self.edit_cursor += 1;
459 }
460 }
461
462 pub fn save_to_file(&self, path: impl AsRef<Path>) -> Result<(), Error> {
463 let toml_string = toml::to_string_pretty(&self.config)?;
464 std::fs::write(path, toml_string)?;
465 Ok(())
466 }
467
468 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, Error> {
469 let contents = std::fs::read_to_string(path)?;
470 let config: T = toml::from_str(&contents)?;
471 Ok(Self::new(config))
472 }
473}
474
475pub struct MenuState {
476 pub current_selection: usize,
477 pub items: Vec<MenuItem>,
478 pub list_state: ListState,
479 pub breadcrumb: Vec<String>,
480 pub menu_stack: Vec<MenuLevel>,
481}
482
483pub struct MenuLevel {
484 pub items: Vec<MenuItem>,
485 pub selection: usize,
486 pub title: String,
487 pub field_path: Vec<String>,
488}
489
490#[derive(Clone)]
491pub struct MenuItem {
492 pub label: String,
493 pub value: String,
494 pub is_submenu: bool,
495 pub is_vec_container: bool,
496 pub field_type: FieldType,
497}
498
499impl MenuState {
500 pub fn new<T: ConfigMenuTrait>(config: &T) -> Self {
501 let metadata = T::get_field_metadata();
502 let items = Self::build_menu_items(config, &metadata);
503
504 let mut list_state = ListState::default();
505 if !items.is_empty() {
506 list_state.select(Some(0));
507 }
508
509 Self {
510 current_selection: 0,
511 items: items.clone(),
512 list_state,
513 breadcrumb: vec![T::get_menu_title().to_string()],
514 menu_stack: vec![MenuLevel {
515 items,
516 selection: 0,
517 title: T::get_menu_title().to_string(),
518 field_path: vec![],
519 }],
520 }
521 }
522
523 fn build_menu_items<T: ConfigMenuTrait>(
524 config: &T,
525 metadata: &[FieldMetadata],
526 ) -> Vec<MenuItem> {
527 metadata
528 .iter()
529 .map(|field| {
530 let value = (field.getter)(config.as_any()).unwrap_or_else(|| "N/A".to_string());
531
532 let value_display = if field.is_option {
533 if value.contains("None") {
534 "<not set>".to_string()
535 } else {
536 value.replace("Some(", "").replace(")", "")
537 }
538 } else {
539 value
540 };
541
542 MenuItem {
543 label: field.name.to_string(),
544 value: value_display,
545 is_submenu: field.is_nested,
546 is_vec_container: field.is_vec,
547 field_type: field.field_type.clone(),
548 }
549 })
550 .collect()
551 }
552
553 pub fn enter_submenu_by_name<T: ConfigMenuTrait>(
554 &mut self,
555 parent_config: &T,
556 field_name: &str,
557 ) -> Result<(), String> {
558 let metadata = T::get_field_metadata();
559 let field_meta = metadata
560 .iter()
561 .find(|m| m.name == field_name)
562 .ok_or_else(|| format!("Field '{}' not found", field_name))?;
563
564 if !field_meta.is_nested {
565 return Err(format!("Field '{}' is not a nested structure", field_name));
566 }
567
568 let nested_getter = field_meta
569 .nested_getter
570 .as_ref()
571 .ok_or_else(|| format!("No nested getter for field '{}'", field_name))?;
572
573 let nested_any = (nested_getter)(parent_config.as_any())
574 .ok_or_else(|| format!("Cannot access nested configuration for '{}'", field_name))?;
575
576 let nested_metadata_getter = field_meta
577 .nested_metadata_getter
578 .as_ref()
579 .ok_or_else(|| format!("No nested metadata getter for field '{}'", field_name))?;
580
581 let nested_metadata = (nested_metadata_getter)();
582
583 let nested_items = Self::build_menu_items_from_any(&*nested_any, &nested_metadata);
584
585 let current_level = self.menu_stack.last().unwrap();
586 let mut new_field_path = current_level.field_path.clone();
587 new_field_path.push(field_name.to_string());
588
589 let new_level = MenuLevel {
590 items: nested_items.clone(),
591 selection: 0,
592 title: field_name.to_string(),
593 field_path: new_field_path,
594 };
595
596 self.menu_stack.push(new_level);
597 self.breadcrumb.push(field_name.to_string());
598 self.items = nested_items;
599 self.current_selection = 0;
600 self.list_state.select(Some(0));
601
602 Ok(())
603 }
604
605 fn build_menu_items_from_any(
606 nested_any: &dyn Any,
607 metadata: &[FieldMetadata],
608 ) -> Vec<MenuItem> {
609 metadata
610 .iter()
611 .map(|field| {
612 let value = (field.getter)(nested_any).unwrap_or_else(|| "N/A".to_string());
613
614 let value_display = if field.is_option {
615 if value.contains("None") {
616 "<not set>".to_string()
617 } else {
618 value.replace("Some(", "").replace(")", "")
619 }
620 } else {
621 value
622 };
623
624 MenuItem {
625 label: field.name.to_string(),
626 value: value_display,
627 is_submenu: field.is_nested,
628 is_vec_container: field.is_vec,
629 field_type: field.field_type.clone(),
630 }
631 })
632 .collect()
633 }
634
635 pub fn get_current_field_path(&self) -> Vec<String> {
636 let mut path = self
637 .menu_stack
638 .last()
639 .map(|level| level.field_path.clone())
640 .unwrap_or_default();
641
642 if let Some(item) = self.get_current_item() {
643 path.push(item.label.clone());
644 }
645
646 path
647 }
648
649 pub fn get_navigation_path(&self) -> Vec<String> {
650 self.menu_stack
651 .iter()
652 .skip(1)
653 .map(|level| level.title.clone())
654 .collect()
655 }
656
657 pub fn next(&mut self) {
658 if self.items.is_empty() {
659 return;
660 }
661 let i = match self.list_state.selected() {
662 Some(i) => (i + 1) % self.items.len(),
663 None => 0,
664 };
665 self.list_state.select(Some(i));
666 self.current_selection = i;
667 }
668
669 pub fn previous(&mut self) {
670 if self.items.is_empty() {
671 return;
672 }
673 let i = match self.list_state.selected() {
674 Some(i) => {
675 if i == 0 {
676 self.items.len() - 1
677 } else {
678 i - 1
679 }
680 }
681 None => 0,
682 };
683 self.list_state.select(Some(i));
684 self.current_selection = i;
685 }
686
687 pub fn get_current_item(&self) -> Option<&MenuItem> {
688 self.items.get(self.current_selection)
689 }
690
691 pub fn can_go_back(&self) -> bool {
692 self.menu_stack.len() > 1
693 }
694
695 pub fn go_back(&mut self) {
696 if self.can_go_back() {
697 self.menu_stack.pop();
698 self.breadcrumb.pop();
699
700 if let Some(prev_level) = self.menu_stack.last() {
701 self.items = prev_level.items.clone();
702 self.current_selection = prev_level.selection;
703 self.list_state.select(Some(self.current_selection));
704 }
705 }
706 }
707}
708
709pub fn render_menu<T: ConfigMenuTrait>(
710 frame: &mut Frame,
711 controller: &mut MenuController<T>,
712 area: Rect,
713) {
714 let chunks = Layout::default()
715 .direction(Direction::Vertical)
716 .constraints([
717 Constraint::Length(3),
718 Constraint::Min(0),
719 Constraint::Length(3),
720 Constraint::Length(3),
721 ])
722 .split(area);
723
724 let breadcrumb = controller.menu_state.breadcrumb.join(" > ");
725 let breadcrumb_widget = Paragraph::new(breadcrumb)
726 .block(Block::default().borders(Borders::ALL).title("Navigation"))
727 .style(Style::default().fg(Color::Cyan));
728 frame.render_widget(breadcrumb_widget, chunks[0]);
729
730 let items: Vec<ListItem> = controller
731 .menu_state
732 .items
733 .iter()
734 .map(|item| {
735 let indicator = if item.is_submenu {
736 " >"
737 } else if item.is_vec_container {
738 " []"
739 } else {
740 ""
741 };
742 let content = format!("{}: {}{}", item.label, item.value, indicator);
743 ListItem::new(Line::from(vec![Span::styled(
744 content,
745 Style::default().fg(Color::White),
746 )]))
747 })
748 .collect();
749
750 let items_widget = List::new(items)
751 .block(Block::default().borders(Borders::ALL).title("Settings"))
752 .highlight_style(
753 Style::default()
754 .fg(Color::Yellow)
755 .add_modifier(Modifier::BOLD),
756 )
757 .highlight_symbol(">> ");
758
759 frame.render_stateful_widget(
760 items_widget,
761 chunks[1],
762 &mut controller.menu_state.list_state,
763 );
764
765 let status_text = if controller.editing_mode {
766 format!("Editing: {}", controller.edit_buffer)
767 } else {
768 "Ready".to_string()
769 };
770
771 let status_widget = Paragraph::new(status_text)
772 .block(Block::default().borders(Borders::ALL).title("Status"))
773 .style(if controller.editing_mode {
774 Style::default().fg(Color::Green)
775 } else {
776 Style::default().fg(Color::Gray)
777 });
778 frame.render_widget(status_widget, chunks[2]);
779
780 if controller.editing_mode {
781 frame.set_cursor_position((
782 chunks[2].x + controller.edit_cursor as u16 + 10,
783 chunks[2].y + 1,
784 ));
785 }
786
787 let help_text = if controller.editing_mode {
788 "Esc: Cancel | Enter: Save | Left/Right: Move cursor | Backspace/Del: Delete"
789 } else if controller.is_current_submenu() {
790 "Up/Down: Navigate | Enter: Open submenu | Esc: Back | s: Save | q: Quit"
791 } else if controller.is_current_boolean() {
792 "Up/Down: Navigate | Enter: Toggle | Esc: Back | s: Save | r: Reload | q: Quit"
793 } else if controller.menu_state.can_go_back() {
794 "Up/Down: Navigate | Enter: Edit | Esc: Back | s: Save | r: Reload | q: Quit"
795 } else {
796 "Up/Down: Navigate | Enter: Edit | s: Save | r: Reload | q: Quit"
797 };
798
799 let help_widget = Paragraph::new(help_text)
800 .block(Block::default().borders(Borders::ALL).title("Help"))
801 .style(Style::default().fg(Color::Gray));
802 frame.render_widget(help_widget, chunks[3]);
803}