From 2e82029e9a4a656798704e165d22b11b25ec3d6b Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Sun, 21 Dec 2025 15:54:42 -0500 Subject: [PATCH] feat(main): added n N and ? motions and improved user subheadings --- src/main.rs | 397 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 329 insertions(+), 68 deletions(-) diff --git a/src/main.rs b/src/main.rs index ae20dc9..f7e9e96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -151,16 +151,26 @@ struct MesonVersionInfo { } #[derive(Debug, Clone)] -struct Section { +struct SubSection { name: String, collapsed: bool, options: Vec, } +#[derive(Debug, Clone)] +struct Section { + name: String, + collapsed: bool, + root_options: Vec, + subsections: Vec, +} + #[derive(Debug, Clone, Copy)] enum FlattenedItem { - Header(usize), - Option(usize, usize), + SectionHeader(usize), + SectionOption(usize, usize), + SubSectionHeader(usize, usize), + SubSectionOption(usize, usize, usize), } @@ -173,12 +183,29 @@ enum InputMode { OutputViewer, } +#[derive(PartialEq, Clone, Copy)] +enum SearchDirection { + Forward, + Backward, +} + +impl SearchDirection { + fn opposite(&self) -> Self { + match self { + Self::Forward => Self::Backward, + Self::Backward => Self::Forward, + } + } +} + struct App { sections: Vec
, flattened_items: Vec, state: ListState, input_mode: InputMode, input_buffer: String, + last_search_query: String, + search_direction: SearchDirection, status_msg: String, build_dir: String, pending_g: bool, @@ -206,6 +233,8 @@ impl App { state: ListState::default(), input_mode: InputMode::Normal, input_buffer: String::new(), + last_search_query: String::new(), + search_direction: SearchDirection::Forward, status_msg: "Loading...".to_string(), build_dir, pending_g: false, @@ -229,7 +258,10 @@ impl App { } fn is_dirty(&self) -> bool { - self.sections.iter().any(|s| s.options.iter().any(|o| o.dirty)) + self.sections.iter().any(|s| { + s.root_options.iter().any(|o| o.dirty) || + s.subsections.iter().any(|sub| sub.options.iter().any(|o| o.dirty)) + }) } fn update_flattened(&mut self) { @@ -237,10 +269,18 @@ impl App { self.flattened_items.clear(); for (sec_idx, section) in self.sections.iter().enumerate() { - self.flattened_items.push(FlattenedItem::Header(sec_idx)); + self.flattened_items.push(FlattenedItem::SectionHeader(sec_idx)); if !section.collapsed { - for opt_idx in 0..section.options.len() { - self.flattened_items.push(FlattenedItem::Option(sec_idx, opt_idx)); + for opt_idx in 0..section.root_options.len() { + self.flattened_items.push(FlattenedItem::SectionOption(sec_idx, opt_idx)); + } + for (sub_idx, subsection) in section.subsections.iter().enumerate() { + self.flattened_items.push(FlattenedItem::SubSectionHeader(sec_idx, sub_idx)); + if !subsection.collapsed { + for opt_idx in 0..subsection.options.len() { + self.flattened_items.push(FlattenedItem::SubSectionOption(sec_idx, sub_idx, opt_idx)); + } + } } } } @@ -260,7 +300,7 @@ impl App { let i = match self.state.selected() { Some(i) => { if i >= self.flattened_items.len() - 1 { - 0 + i } else { i + 1 } @@ -275,7 +315,7 @@ impl App { let i = match self.state.selected() { Some(i) => { if i == 0 { - self.flattened_items.len() - 1 + 0 } else { i - 1 } @@ -301,12 +341,15 @@ impl App { fn toggle_fold(&mut self) { if let Some(idx) = self.state.selected() { match self.flattened_items[idx] { - FlattenedItem::Header(sec_idx) => { + FlattenedItem::SectionHeader(sec_idx) => { self.sections[sec_idx].collapsed = !self.sections[sec_idx].collapsed; self.update_flattened(); } - FlattenedItem::Option(_sec_idx, _) => { + FlattenedItem::SubSectionHeader(sec_idx, sub_idx) => { + self.sections[sec_idx].subsections[sub_idx].collapsed = !self.sections[sec_idx].subsections[sub_idx].collapsed; + self.update_flattened(); } + _ => {} } } } @@ -314,12 +357,15 @@ impl App { fn open_fold(&mut self) { if let Some(idx) = self.state.selected() { match self.flattened_items[idx] { - FlattenedItem::Header(sec_idx) => { + FlattenedItem::SectionHeader(sec_idx) => { self.sections[sec_idx].collapsed = false; self.update_flattened(); } - FlattenedItem::Option(_sec_idx, _) => { + FlattenedItem::SubSectionHeader(sec_idx, sub_idx) => { + self.sections[sec_idx].subsections[sub_idx].collapsed = false; + self.update_flattened(); } + _ => {} } } } @@ -327,15 +373,24 @@ impl App { fn close_fold(&mut self) { if let Some(idx) = self.state.selected() { match self.flattened_items[idx] { - FlattenedItem::Header(sec_idx) => { + FlattenedItem::SectionHeader(sec_idx) => { self.sections[sec_idx].collapsed = true; self.update_flattened(); } - FlattenedItem::Option(sec_idx, _) => { + FlattenedItem::SectionOption(sec_idx, _) => { self.sections[sec_idx].collapsed = true; self.update_flattened(); self.state.select(Some(self.find_header_index(sec_idx))); } + FlattenedItem::SubSectionHeader(sec_idx, sub_idx) => { + self.sections[sec_idx].subsections[sub_idx].collapsed = true; + self.update_flattened(); + } + FlattenedItem::SubSectionOption(sec_idx, sub_idx, _) => { + self.sections[sec_idx].subsections[sub_idx].collapsed = true; + self.update_flattened(); + self.state.select(Some(self.find_subsection_header_index(sec_idx, sub_idx))); + } } } } @@ -343,6 +398,9 @@ impl App { fn open_all(&mut self) { for sec in &mut self.sections { sec.collapsed = false; + for sub in &mut sec.subsections { + sub.collapsed = false; + } } self.update_flattened(); } @@ -350,62 +408,116 @@ impl App { fn close_all(&mut self) { for sec in &mut self.sections { sec.collapsed = true; + for sub in &mut sec.subsections { + sub.collapsed = true; + } } self.update_flattened(); self.jump_top(); } fn find_header_index(&self, sec_idx: usize) -> usize { - self.flattened_items.iter().position(|item| matches!(item, FlattenedItem::Header(s) if *s == sec_idx)).unwrap_or(0) + self.flattened_items.iter().position(|item| matches!(item, FlattenedItem::SectionHeader(s) if *s == sec_idx)).unwrap_or(0) + } + + fn find_subsection_header_index(&self, sec_idx: usize, sub_idx: usize) -> usize { + self.flattened_items.iter().position(|item| matches!(item, FlattenedItem::SubSectionHeader(s, sub) if *s == sec_idx && *sub == sub_idx)).unwrap_or(0) } - fn perform_search(&mut self) { - let query = self.input_buffer.to_lowercase(); + fn find_next_match(&mut self, direction: SearchDirection) { + let query = self.last_search_query.to_lowercase(); if query.is_empty() { return; } - let mut search_hits: Vec<(usize, usize, String)> = Vec::new(); + let mut search_hits: Vec<(usize, Option, usize, String)> = Vec::new(); for (s_idx, section) in self.sections.iter().enumerate() { - for (o_idx, opt) in section.options.iter().enumerate() { - search_hits.push((s_idx, o_idx, opt.raw.name.to_lowercase())); + for (o_idx, opt) in section.root_options.iter().enumerate() { + search_hits.push((s_idx, None, o_idx, opt.raw.name.to_lowercase())); + } + for (sub_idx, subsection) in section.subsections.iter().enumerate() { + for (o_idx, opt) in subsection.options.iter().enumerate() { + search_hits.push((s_idx, Some(sub_idx), o_idx, opt.raw.name.to_lowercase())); + } } } let current_flat_idx = self.state.selected().unwrap_or(0); - let current_linear_pos = if let Some(item) = self.flattened_items.get(current_flat_idx) { - match item { - FlattenedItem::Option(curr_s, curr_o) => { - search_hits.iter().position(|(s, o, _)| s == curr_s && o == curr_o).unwrap_or(0) - }, - FlattenedItem::Header(curr_s) => { - search_hits.iter().position(|(s, _, _)| s >= curr_s).unwrap_or(0) - } - } + let current_hit_index = if let Some(item) = self.flattened_items.get(current_flat_idx) { + match item { + FlattenedItem::SectionHeader(s) => { + search_hits.iter().position(|(s_idx, _, _, _)| *s_idx > *s).unwrap_or(0) + } + FlattenedItem::SectionOption(s, o) => { + search_hits.iter().position(|(s_idx, sub, o_idx, _)| *s_idx == *s && sub.is_none() && *o_idx == *o).unwrap_or(0) + } + FlattenedItem::SubSectionHeader(s, sub) => { + search_hits.iter().position(|(s_idx, sub_idx, _, _)| *s_idx == *s && *sub_idx == Some(*sub)).unwrap_or(0) + } + FlattenedItem::SubSectionOption(s, sub, o) => { + search_hits.iter().position(|(s_idx, sub_idx, o_idx, _)| *s_idx == *s && *sub_idx == Some(*sub) && *o_idx == *o).unwrap_or(0) + } + } } else { 0 }; - let match_res = search_hits.iter() - .skip(current_linear_pos + 1) - .find(|(_, _, name)| name.contains(&query)) - .or_else(|| { + let match_res = match direction { + SearchDirection::Forward => { search_hits.iter() - .take(current_linear_pos + 1) - .find(|(_, _, name)| name.contains(&query)) - }); + .skip(current_hit_index + 1) + .find(|(_, _, _, name)| name.contains(&query)) + .or_else(|| { + search_hits.iter() + .take(current_hit_index + 1) + .find(|(_, _, _, name)| name.contains(&query)) + }) + }, + SearchDirection::Backward => { + search_hits.iter() + .take(current_hit_index) + .rev() + .find(|(_, _, _, name)| name.contains(&query)) + .or_else(|| { + search_hits.iter() + .skip(current_hit_index) + .rev() + .find(|(_, _, _, name)| name.contains(&query)) + }) + } + }; - if let Some((s_idx, o_idx, _)) = match_res { + if let Some((s_idx, sub_idx, o_idx, _)) = match_res { let s_idx = *s_idx; let o_idx = *o_idx; - let opt_name = self.sections[s_idx].options[o_idx].raw.name.clone(); + + let opt_name = if let Some(sub) = sub_idx { + self.sections[s_idx].subsections[*sub].options[o_idx].raw.name.clone() + } else { + self.sections[s_idx].root_options[o_idx].raw.name.clone() + }; if self.sections[s_idx].collapsed { self.sections[s_idx].collapsed = false; - self.update_flattened(); } - + if let Some(sub) = sub_idx { + if self.sections[s_idx].subsections[*sub].collapsed { + self.sections[s_idx].subsections[*sub].collapsed = false; + } + } + self.update_flattened(); + + let target_item = if let Some(sub) = sub_idx { + FlattenedItem::SubSectionOption(s_idx, *sub, o_idx) + } else { + FlattenedItem::SectionOption(s_idx, o_idx) + }; + if let Some(idx) = self.flattened_items.iter().position(|item| { - matches!(item, FlattenedItem::Option(s, o) if *s == s_idx && *o == o_idx) + match (item, target_item) { + (FlattenedItem::SectionOption(s1, o1), FlattenedItem::SectionOption(s2, o2)) => *s1 == s2 && *o1 == o2, + (FlattenedItem::SubSectionOption(s1, sub1, o1), FlattenedItem::SubSectionOption(s2, sub2, o2)) => *s1 == s2 && *sub1 == sub2 && *o1 == o2, + _ => false + } }) { self.state.select(Some(idx)); self.status_msg = format!("Found: '{}'", opt_name); @@ -436,12 +548,40 @@ impl App { groups.entry(section).or_default().push(MesonOption::from_raw(raw)); } - self.sections = groups.into_iter().map(|(name, mut opts)| { - opts.sort_by(|a, b| a.raw.name.cmp(&b.raw.name)); + self.sections = groups.into_iter().map(|(name, opts)| { + let mut root_options = Vec::new(); + let mut subsections_map: HashMap> = HashMap::new(); + + if name == "user" { + for opt in opts { + if let Some((prefix, _)) = opt.raw.name.split_once(':') { + subsections_map.entry(prefix.to_string()).or_default().push(opt); + } else { + root_options.push(opt); + } + } + } else { + root_options = opts; + } + + root_options.sort_by(|a, b| a.raw.name.cmp(&b.raw.name)); + + let mut subsections: Vec = subsections_map.into_iter().map(|(sub_name, mut sub_opts)| { + sub_opts.sort_by(|a, b| a.raw.name.cmp(&b.raw.name)); + SubSection { + name: sub_name, + collapsed: true, + options: sub_opts, + } + }).collect(); + + subsections.sort_by(|a, b| a.name.cmp(&b.name)); + Section { name, collapsed: false, - options: opts + root_options, + subsections } }).collect(); @@ -461,7 +601,7 @@ impl App { self.state.select(Some(0)); } - let total_opts: usize = self.sections.iter().map(|s| s.options.len()).sum(); + let total_opts: usize = self.sections.iter().map(|s| s.root_options.len() + s.subsections.iter().map(|sub| sub.options.len()).sum::()).sum(); self.status_msg = format!("Loaded {} options in {} sections.", total_opts, self.sections.len()); Ok(()) } @@ -469,11 +609,18 @@ impl App { fn apply_changes(&mut self, show_overlay: bool) -> Result { let mut dirty_opts = Vec::new(); for section in &self.sections { - for opt in §ion.options { + for opt in §ion.root_options { if opt.dirty { dirty_opts.push(opt); } } + for sub in §ion.subsections { + for opt in &sub.options { + if opt.dirty { + dirty_opts.push(opt); + } + } + } } if dirty_opts.is_empty() { @@ -512,25 +659,39 @@ impl App { if output.status.success() { for section in self.sections.iter_mut() { - for opt in section.options.iter_mut() { + for opt in section.root_options.iter_mut() { if opt.dirty { opt.raw.value = opt.new_value.clone(); opt.dirty = false; } } + for sub in section.subsections.iter_mut() { + for opt in sub.options.iter_mut() { + if opt.dirty { + opt.raw.value = opt.new_value.clone(); + opt.dirty = false; + } + } + } } self.status_msg = "Configuration applied successfully.".to_string(); Ok(true) } else { - self.status_msg = format!("Error applying config. check overlay."); + self.status_msg = "Error applying config. check overlay.".to_string(); Ok(false) } } fn get_selected_option_mut(&mut self) -> Option<&mut MesonOption> { if let Some(idx) = self.state.selected() { - if let FlattenedItem::Option(sec_idx, opt_idx) = self.flattened_items[idx] { - return Some(&mut self.sections[sec_idx].options[opt_idx]); + match self.flattened_items[idx] { + FlattenedItem::SectionOption(sec_idx, opt_idx) => { + return Some(&mut self.sections[sec_idx].root_options[opt_idx]); + } + FlattenedItem::SubSectionOption(sec_idx, sub_idx, opt_idx) => { + return Some(&mut self.sections[sec_idx].subsections[sub_idx].options[opt_idx]); + } + _ => {} } } None @@ -573,10 +734,11 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) -> loop { terminal.draw(|f| ui(f, app))?; - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { continue; } + match event::read()? { + Event::Key(key) => { + if key.kind != KeyEventKind::Press { continue; } - match app.input_mode { + match app.input_mode { InputMode::OutputViewer => { match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { @@ -661,21 +823,54 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) -> KeyCode::Char('/') => { app.pending_g = false; app.pending_z = false; + app.search_direction = SearchDirection::Forward; app.input_mode = InputMode::Searching; app.input_buffer.clear(); }, + KeyCode::Char('?') => { + app.pending_g = false; + app.pending_z = false; + app.search_direction = SearchDirection::Backward; + app.input_mode = InputMode::Searching; + app.input_buffer.clear(); + }, + KeyCode::Char('n') => { + app.pending_g = false; + app.pending_z = false; + app.find_next_match(app.search_direction); + }, + KeyCode::Char('N') => { + app.pending_g = false; + app.pending_z = false; + app.find_next_match(app.search_direction.opposite()); + }, KeyCode::Enter | KeyCode::Char(' ') => { app.pending_g = false; app.pending_z = false; if let Some(idx) = app.state.selected() { match app.flattened_items[idx] { - FlattenedItem::Header(sec_idx) => { + FlattenedItem::SectionHeader(sec_idx) => { app.sections[sec_idx].collapsed = !app.sections[sec_idx].collapsed; app.update_flattened(); } - FlattenedItem::Option(sec_idx, opt_idx) => { - let opt = &mut app.sections[sec_idx].options[opt_idx]; + FlattenedItem::SubSectionHeader(sec_idx, sub_idx) => { + app.sections[sec_idx].subsections[sub_idx].collapsed = !app.sections[sec_idx].subsections[sub_idx].collapsed; + app.update_flattened(); + } + FlattenedItem::SectionOption(sec_idx, opt_idx) => { + let opt = &mut app.sections[sec_idx].root_options[opt_idx]; + if !opt.toggle_or_cycle() { + app.input_mode = InputMode::Editing; + app.input_buffer = match &opt.new_value { + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.clone(), + _ => "".to_string(), + }; + } + } + FlattenedItem::SubSectionOption(sec_idx, sub_idx, opt_idx) => { + let opt = &mut app.sections[sec_idx].subsections[sub_idx].options[opt_idx]; if !opt.toggle_or_cycle() { app.input_mode = InputMode::Editing; app.input_buffer = match &opt.new_value { @@ -759,7 +954,8 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) -> }, InputMode::Searching => match key.code { KeyCode::Enter => { - app.perform_search(); + app.last_search_query = app.input_buffer.clone(); + app.find_next_match(app.search_direction); app.input_mode = InputMode::Normal; } KeyCode::Esc => { @@ -775,6 +971,25 @@ fn run_app(terminal: &mut Terminal>, app: &mut App) -> _ => {} }, } + } + Event::Mouse(mouse) => match mouse.kind { + event::MouseEventKind::ScrollDown => match app.input_mode { + InputMode::Normal => app.next(), + InputMode::OutputViewer => { + app.output_scroll = app.output_scroll.saturating_add(1); + } + _ => {} + }, + event::MouseEventKind::ScrollUp => match app.input_mode { + InputMode::Normal => app.previous(), + InputMode::OutputViewer => { + app.output_scroll = app.output_scroll.saturating_sub(1); + } + _ => {} + }, + _ => {} + }, + _ => {} } } } @@ -806,8 +1021,10 @@ fn ui(f: &mut Frame, app: &mut App) { f.render_widget(title, chunks[0]); let max_name_width = app.sections.iter() - .flat_map(|s| s.options.iter()) - .map(|o| o.raw.name.len()) + .flat_map(|s| { + s.root_options.iter().map(|o| o.raw.name.len()) + .chain(s.subsections.iter().flat_map(|sub| sub.options.iter().map(|o| o.raw.name.len()))) + }) .max() .unwrap_or(20) .max(20); @@ -819,15 +1036,15 @@ fn ui(f: &mut Frame, app: &mut App) { .iter() .map(|item| { match item { - FlattenedItem::Header(sec_idx) => { + FlattenedItem::SectionHeader(sec_idx) => { let sec = &app.sections[*sec_idx]; let icon = if sec.collapsed { "▶" } else { "▼" }; - let count = sec.options.len(); + let count = sec.root_options.len() + sec.subsections.iter().map(|s| s.options.len()).sum::(); let content = format!("{} {} ({})", icon, sec.name.to_uppercase(), count); ListItem::new(content).style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) }, - FlattenedItem::Option(sec_idx, opt_idx) => { - let opt = &app.sections[*sec_idx].options[*opt_idx]; + FlattenedItem::SectionOption(sec_idx, opt_idx) => { + let opt = &app.sections[*sec_idx].root_options[*opt_idx]; let val_str = opt.format_value(); let dirty_str = if opt.dirty { "*" } else { " " }; let choices_str = opt.format_choices(); @@ -840,7 +1057,44 @@ fn ui(f: &mut Frame, app: &mut App) { }; let p1 = Span::styled( - format!(" {} {: { + let sub = &app.sections[*sec_idx].subsections[*sub_idx]; + let icon = if sub.collapsed { "▶" } else { "▼" }; + let count = sub.options.len(); + let content = format!(" {} {} ({})", icon, sub.name, count); + ListItem::new(content).style(Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD)) + }, + FlattenedItem::SubSectionOption(sec_idx, sub_idx, opt_idx) => { + let opt = &app.sections[*sec_idx].subsections[*sub_idx].options[*opt_idx]; + let val_str = opt.format_value(); + let dirty_str = if opt.dirty { "*" } else { " " }; + let choices_str = opt.format_choices(); + + let name_color = if opt.dirty { Color::Yellow } else { Color::White }; + let val_color = match val_str.as_str() { + "enabled" => Color::Green, + "disabled" => Color::Red, + _ => Color::Cyan, + }; + + let p1 = Span::styled( + format!(" {} {: { let mut desc = String::new(); if let Some(idx) = app.state.selected() { - if let FlattenedItem::Option(s, o) = app.flattened_items[idx] { - desc = app.sections[s].options[o].raw.description.clone().unwrap_or_default(); + match app.flattened_items[idx] { + FlattenedItem::SectionOption(s, o) => { + desc = app.sections[s].root_options[o].raw.description.clone().unwrap_or_default(); + } + FlattenedItem::SubSectionOption(s, sub, o) => { + desc = app.sections[s].subsections[sub].options[o].raw.description.clone().unwrap_or_default(); + } + _ => {} } } @@ -891,7 +1151,8 @@ fn ui(f: &mut Frame, app: &mut App) { f.render_widget(p, chunks[2]); } InputMode::Searching => { - let p = Paragraph::new(format!("/{}", app.input_buffer)) + let prompt = if app.search_direction == SearchDirection::Forward { "/" } else { "?" }; + let p = Paragraph::new(format!("{}{}", prompt, app.input_buffer)) .style(Style::default().fg(Color::LightBlue)) .block(Block::default().borders(Borders::ALL).title("Search (Enter to jump)")); f.render_widget(p, chunks[2]);