use anyhow::{Context, Result}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{prelude::*, widgets::*}; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, env, fs, io::{self, Stdout}, path::Path, process::Command, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] struct MesonOptionRaw { name: String, #[serde(rename = "type")] kind: String, value: serde_json::Value, section: String, description: Option, choices: Option>, } #[derive(Debug, Clone)] struct MesonOption { raw: MesonOptionRaw, new_value: serde_json::Value, dirty: bool, } impl MesonOption { fn from_raw(raw: MesonOptionRaw) -> Self { let val = raw.value.clone(); Self { raw, new_value: val, dirty: false, } } fn format_value(&self) -> String { match &self.new_value { serde_json::Value::Bool(b) => if *b { "enabled".to_string() } else { "disabled".to_string() }, serde_json::Value::String(s) => s.clone(), serde_json::Value::Number(n) => n.to_string(), serde_json::Value::Array(arr) => { let s: Vec = arr.iter().map(|v| v.as_str().unwrap_or("").to_string()).collect(); format!("[{}]", s.join(", ")) } _ => "complex".to_string(), } } fn format_choices(&self) -> String { match self.raw.kind.as_str() { "boolean" => "[enabled, disabled]".to_string(), "combo" => { if let Some(choices) = &self.raw.choices { format!("[{}]", choices.join(", ")) } else { String::new() } }, _ => { if let Some(choices) = &self.raw.choices { format!("[{}]", choices.join(", ")) } else { String::new() } } } } fn toggle_or_cycle(&mut self) -> bool { match self.raw.kind.as_str() { "boolean" => { if let serde_json::Value::Bool(b) = self.new_value { self.new_value = serde_json::Value::Bool(!b); self.dirty = self.new_value != self.raw.value; return true; } } "combo" => { if let Some(choices) = &self.raw.choices { if let serde_json::Value::String(current) = &self.new_value { if let Some(idx) = choices.iter().position(|c| c == current) { let next_idx = (idx + 1) % choices.len(); self.new_value = serde_json::Value::String(choices[next_idx].clone()); self.dirty = self.new_value != self.raw.value; return true; } } if !choices.is_empty() { self.new_value = serde_json::Value::String(choices[0].clone()); self.dirty = true; return true; } } } _ => {} } false } fn set_from_string(&mut self, input: &str) { let input = input.trim(); // TRIM WHITESPACE to prevent simple errors match self.raw.kind.as_str() { "boolean" => { // Correctly parse boolean strings back to Bool type match input.to_lowercase().as_str() { "true" | "enabled" | "yes" | "1" => self.new_value = serde_json::Value::Bool(true), "false" | "disabled" | "no" | "0" => self.new_value = serde_json::Value::Bool(false), _ => { /* invalid bool input, keep old value to prevent corruption */ } } }, "integer" => { if let Ok(val) = input.parse::() { self.new_value = serde_json::Value::Number(val.into()); } }, "array" => { let items: Vec = input .split(',') .map(|s| serde_json::Value::String(s.trim().to_string())) .collect(); self.new_value = serde_json::Value::Array(items); }, _ => { self.new_value = serde_json::Value::String(input.to_string()); } } self.dirty = self.new_value != self.raw.value; } } #[derive(Deserialize)] struct MesonInfo { meson_version: MesonVersionInfo, } #[derive(Deserialize)] struct MesonVersionInfo { full: String, } #[derive(Debug, Clone)] struct Section { name: String, collapsed: bool, options: Vec, } #[derive(Debug, Clone, Copy)] enum FlattenedItem { Header(usize), Option(usize, usize), } #[derive(PartialEq)] enum InputMode { Normal, Editing, Searching, Command, OutputViewer, } struct App { sections: Vec
, flattened_items: Vec, state: ListState, input_mode: InputMode, input_buffer: String, status_msg: String, build_dir: String, pending_g: bool, pending_z: bool, last_output: String, output_scroll: u16, system_meson_version: String, build_meson_version: Option, } impl App { fn new(build_dir: String) -> Self { let system_ver = match Command::new("meson").arg("--version").output() { Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(), Err(_) => "unknown".to_string(), }; let build_ver = Self::get_build_version(&build_dir); Self { sections: Vec::new(), flattened_items: Vec::new(), state: ListState::default(), input_mode: InputMode::Normal, input_buffer: String::new(), status_msg: "Loading...".to_string(), build_dir, pending_g: false, pending_z: false, last_output: String::new(), output_scroll: 0, system_meson_version: system_ver, build_meson_version: build_ver, } } fn get_build_version(build_dir: &str) -> Option { let path = Path::new(build_dir).join("meson-info").join("meson-info.json"); if let Ok(file) = fs::File::open(path) { let reader = io::BufReader::new(file); if let Ok(info) = serde_json::from_reader::<_, MesonInfo>(reader) { return Some(info.meson_version.full); } } None } fn is_dirty(&self) -> bool { self.sections.iter().any(|s| s.options.iter().any(|o| o.dirty)) } fn update_flattened(&mut self) { let current_selection_idx = self.state.selected().unwrap_or(0); self.flattened_items.clear(); for (sec_idx, section) in self.sections.iter().enumerate() { self.flattened_items.push(FlattenedItem::Header(sec_idx)); if !section.collapsed { for opt_idx in 0..section.options.len() { self.flattened_items.push(FlattenedItem::Option(sec_idx, opt_idx)); } } } if !self.flattened_items.is_empty() { if current_selection_idx >= self.flattened_items.len() { self.state.select(Some(self.flattened_items.len() - 1)); } } else { self.state.select(None); } } fn next(&mut self) { if self.flattened_items.is_empty() { return; } let i = match self.state.selected() { Some(i) => { if i >= self.flattened_items.len() - 1 { 0 } else { i + 1 } } None => 0, }; self.state.select(Some(i)); } fn previous(&mut self) { if self.flattened_items.is_empty() { return; } let i = match self.state.selected() { Some(i) => { if i == 0 { self.flattened_items.len() - 1 } else { i - 1 } } None => 0, }; self.state.select(Some(i)); } fn jump_top(&mut self) { if !self.flattened_items.is_empty() { self.state.select(Some(0)); } } fn jump_bottom(&mut self) { if !self.flattened_items.is_empty() { self.state.select(Some(self.flattened_items.len() - 1)); } } fn toggle_fold(&mut self) { if let Some(idx) = self.state.selected() { match self.flattened_items[idx] { FlattenedItem::Header(sec_idx) => { self.sections[sec_idx].collapsed = !self.sections[sec_idx].collapsed; self.update_flattened(); } FlattenedItem::Option(_sec_idx, _) => { } } } } fn open_fold(&mut self) { if let Some(idx) = self.state.selected() { match self.flattened_items[idx] { FlattenedItem::Header(sec_idx) => { self.sections[sec_idx].collapsed = false; self.update_flattened(); } FlattenedItem::Option(_sec_idx, _) => { } } } } fn close_fold(&mut self) { if let Some(idx) = self.state.selected() { match self.flattened_items[idx] { FlattenedItem::Header(sec_idx) => { self.sections[sec_idx].collapsed = true; self.update_flattened(); } FlattenedItem::Option(sec_idx, _) => { self.sections[sec_idx].collapsed = true; self.update_flattened(); self.state.select(Some(self.find_header_index(sec_idx))); } } } } fn open_all(&mut self) { for sec in &mut self.sections { sec.collapsed = false; } self.update_flattened(); } fn close_all(&mut self) { for sec in &mut self.sections { sec.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) } fn perform_search(&mut self) { let query = self.input_buffer.to_lowercase(); if query.is_empty() { return; } let mut search_hits: Vec<(usize, 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())); } } 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) } } } else { 0 }; let match_res = search_hits.iter() .skip(current_linear_pos + 1) .find(|(_, _, name)| name.contains(&query)) .or_else(|| { search_hits.iter() .take(current_linear_pos + 1) .find(|(_, _, name)| name.contains(&query)) }); if let Some((s_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(); if self.sections[s_idx].collapsed { self.sections[s_idx].collapsed = false; self.update_flattened(); } if let Some(idx) = self.flattened_items.iter().position(|item| { matches!(item, FlattenedItem::Option(s, o) if *s == s_idx && *o == o_idx) }) { self.state.select(Some(idx)); self.status_msg = format!("Found: '{}'", opt_name); } } else { self.status_msg = format!("Not found: '{}'", query); } } fn load_options(&mut self) -> Result<()> { let output = Command::new("meson") .arg("introspect") .arg("--buildoptions") .arg(&self.build_dir) .output() .context("Failed to execute meson introspect")?; if !output.status.success() { self.status_msg = String::from_utf8_lossy(&output.stderr).to_string(); return Ok(()); } let raw_opts: Vec = serde_json::from_slice(&output.stdout)?; let mut groups: HashMap> = HashMap::new(); for raw in raw_opts { let section = raw.section.clone(); 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)); Section { name, collapsed: false, options: opts } }).collect(); self.sections.sort_by(|a, b| { let a_is_user = a.name == "user"; let b_is_user = b.name == "user"; match (a_is_user, b_is_user) { (true, true) => a.name.cmp(&b.name), (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, (false, false) => a.name.cmp(&b.name), } }); self.update_flattened(); if !self.flattened_items.is_empty() { self.state.select(Some(0)); } let total_opts: usize = self.sections.iter().map(|s| s.options.len()).sum(); self.status_msg = format!("Loaded {} options in {} sections.", total_opts, self.sections.len()); Ok(()) } 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 { if opt.dirty { dirty_opts.push(opt); } } } if dirty_opts.is_empty() { self.status_msg = "No changes to apply.".to_string(); return Ok(true); } let mut cmd = Command::new("meson"); cmd.arg("configure").arg(&self.build_dir); for opt in dirty_opts { let val_str = match &opt.new_value { serde_json::Value::Bool(b) => if *b { "true".to_string() } else { "false".to_string() }, serde_json::Value::Array(arr) => { let s: Vec = arr.iter().map(|v| v.as_str().unwrap_or("").to_string()).collect(); s.join(",") }, serde_json::Value::String(s) => s.clone(), serde_json::Value::Number(n) => n.to_string(), _ => "".to_string(), }; cmd.arg(format!("-D{}={}", opt.raw.name, val_str)); } let output = cmd.output().context("Failed to run meson configure")?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); self.last_output = format!("Command: {:?}\n\n[STDOUT]\n{}\n\n[STDERR]\n{}", cmd, stdout, stderr); self.output_scroll = 0; if show_overlay { self.input_mode = InputMode::OutputViewer; } if output.status.success() { for section in self.sections.iter_mut() { for opt in section.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."); 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]); } } None } } fn main() -> Result<()> { let args: Vec = env::args().collect(); let build_dir = if args.len() > 1 { args[1].clone() } else { ".".to_string() }; enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let mut app = App::new(build_dir); if let Err(e) = app.load_options() { app.status_msg = format!("Error loading: {}", e); } let res = run_app(&mut terminal, &mut app); disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; if let Err(err) = res { println!("{:?}", err); } Ok(()) } fn run_app(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { loop { terminal.draw(|f| ui(f, app))?; if let Event::Key(key) = event::read()? { if key.kind != KeyEventKind::Press { continue; } match app.input_mode { InputMode::OutputViewer => { match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { app.input_mode = InputMode::Normal; }, KeyCode::Char('j') | KeyCode::Down => { app.output_scroll = app.output_scroll.saturating_add(1); }, KeyCode::Char('k') | KeyCode::Up => { app.output_scroll = app.output_scroll.saturating_sub(1); }, _ => {} } }, InputMode::Normal => match key.code { KeyCode::Char(':') => { app.input_mode = InputMode::Command; app.input_buffer.clear(); }, KeyCode::Char('q') => { if app.is_dirty() { app.status_msg = "No write since last change (use :q! to override)".to_string(); } else { return Ok(()); } }, KeyCode::Char('s') => { let _ = app.apply_changes(true); } KeyCode::Char('j') | KeyCode::Down => { app.pending_g = false; app.pending_z = false; app.next(); }, KeyCode::Char('k') | KeyCode::Up => { app.pending_g = false; app.pending_z = false; app.previous(); }, KeyCode::Char('g') => { app.pending_z = false; if app.pending_g { app.jump_top(); app.pending_g = false; } else { app.pending_g = true; } }, KeyCode::Char('G') => { app.pending_g = false; app.pending_z = false; app.jump_bottom(); }, KeyCode::Char('z') => { app.pending_g = false; if app.pending_z { app.pending_z = false; } else { app.pending_z = true; } }, KeyCode::Char('a') if app.pending_z => { app.toggle_fold(); app.pending_z = false; }, KeyCode::Char('o') if app.pending_z => { app.open_fold(); app.pending_z = false; }, KeyCode::Char('c') if app.pending_z => { app.close_fold(); app.pending_z = false; }, KeyCode::Char('R') if app.pending_z => { app.open_all(); app.pending_z = false; }, KeyCode::Char('M') if app.pending_z => { app.close_all(); app.pending_z = false; }, KeyCode::Char('/') => { app.pending_g = false; app.pending_z = false; app.input_mode = InputMode::Searching; app.input_buffer.clear(); }, 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) => { 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]; 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(), }; } } } } } _ => { app.pending_g = false; app.pending_z = false; } }, InputMode::Command => match key.code { KeyCode::Enter => { let cmd = app.input_buffer.trim().to_string(); match cmd.as_str() { "q" => { if app.is_dirty() { app.status_msg = "No write since last change (use :q! to override)".to_string(); app.input_mode = InputMode::Normal; } else { return Ok(()); } }, "q!" => return Ok(()), "w" => { let _ = app.apply_changes(true); // :w shows overlay if app.input_mode != InputMode::OutputViewer { app.input_mode = InputMode::Normal; } }, "wq" => { match app.apply_changes(false) { Ok(true) => return Ok(()), _ => { app.input_mode = InputMode::Normal; } } }, _ => { app.status_msg = format!("Not an editor command: {}", cmd); app.input_mode = InputMode::Normal; } } }, KeyCode::Esc => { app.input_mode = InputMode::Normal; app.status_msg.clear(); }, KeyCode::Backspace => { app.input_buffer.pop(); }, KeyCode::Char(c) => { app.input_buffer.push(c); }, _ => {} }, InputMode::Editing => match key.code { KeyCode::Enter => { let input = app.input_buffer.clone(); if let Some(opt) = app.get_selected_option_mut() { opt.set_from_string(&input); } app.input_mode = InputMode::Normal; } KeyCode::Esc => { app.input_mode = InputMode::Normal; } KeyCode::Backspace => { app.input_buffer.pop(); } KeyCode::Char(c) => { app.input_buffer.push(c); } _ => {} }, InputMode::Searching => match key.code { KeyCode::Enter => { app.perform_search(); app.input_mode = InputMode::Normal; } KeyCode::Esc => { app.input_mode = InputMode::Normal; app.status_msg = "Search cancelled.".to_string(); } KeyCode::Backspace => { app.input_buffer.pop(); } KeyCode::Char(c) => { app.input_buffer.push(c); } _ => {} }, } } } } fn ui(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]) .split(f.area()); let mut header_text = format!("Meson TUI Configurator (Meson v{})", app.system_meson_version); let mut header_style = Style::default().fg(Color::Green).add_modifier(Modifier::BOLD); if let Some(build_ver) = &app.build_meson_version { if build_ver != &app.system_meson_version { header_text.push_str(&format!(" - WARNING: Configured with v{}", build_ver)); header_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); } } let title = Paragraph::new(header_text) .style(header_style) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); 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()) .max() .unwrap_or(20) .max(20); let val_column_width = 25; let items: Vec = app .flattened_items .iter() .map(|item| { match item { FlattenedItem::Header(sec_idx) => { let sec = &app.sections[*sec_idx]; let icon = if sec.collapsed { "▶" } else { "▼" }; let count = sec.options.len(); 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]; 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(); } } let status = if app.pending_g { "G-" } else if app.pending_z { "Z-" } else { "" }; let info = format!("{} {} | {}", status, app.status_msg, desc); let p = Paragraph::new(info) .style(Style::default().fg(Color::Gray)) .block(Block::default().borders(Borders::ALL).title("Status (:cmd, j/k:move, z+a:fold, /:search, Enter:edit)")); f.render_widget(p, chunks[2]); } InputMode::Editing => { let p = Paragraph::new(app.input_buffer.as_str()) .style(Style::default().fg(Color::Yellow)) .block(Block::default().borders(Borders::ALL).title("Edit Value (Enter to confirm)")); f.render_widget(p, chunks[2]); } InputMode::Searching => { let p = Paragraph::new(format!("/{}", 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]); } InputMode::Command => { let p = Paragraph::new(format!(":{}", app.input_buffer)) .style(Style::default().fg(Color::White)) .block(Block::default().borders(Borders::ALL).title("Command")); f.render_widget(p, chunks[2]); } InputMode::OutputViewer => { let area = centered_rect(80, 80, f.area()); f.render_widget(Clear, area); let p = Paragraph::new(app.last_output.as_str()) .style(Style::default().fg(Color::White)) .block(Block::default().borders(Borders::ALL).title("Shell Output (Esc to close, j/k to scroll)")) .wrap(Wrap { trim: false }) .scroll((app.output_scroll, 0)); f.render_widget(p, area); } } } fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]) .split(r); Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ]) .split(popup_layout[1])[1] }