Skip to main content

ratatui_cfg/
lib.rs

1//! A configuration menu system for ratatui applications.
2//!
3//! This library provides automatic configuration menu generation for Rust types
4//! that implement the `ConfigMenuTrait` via the `#[derive(ConfigMenu)]` macro.
5
6pub 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}