commit 38d38dc111c403406651e67aaffd0a1db588c476 Author: Emily Boudreaux Date: Sat Dec 20 13:04:15 2025 -0500 feat(hadron): initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d716efd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "hadron" +version = "0.1.0" +edition = "2024" + +[dependencies] +crossterm = "0.28" +ratatui = "0.29" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ae20dc9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,938 @@ +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] +}