feat(hadron): initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
11
Cargo.toml
Normal file
11
Cargo.toml
Normal file
@@ -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"
|
||||
938
src/main.rs
Normal file
938
src/main.rs
Normal file
@@ -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<String>,
|
||||
choices: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<String> = 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::<i64>() {
|
||||
self.new_value = serde_json::Value::Number(val.into());
|
||||
}
|
||||
},
|
||||
"array" => {
|
||||
let items: Vec<serde_json::Value> = 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<MesonOption>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FlattenedItem {
|
||||
Header(usize),
|
||||
Option(usize, usize),
|
||||
}
|
||||
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum InputMode {
|
||||
Normal,
|
||||
Editing,
|
||||
Searching,
|
||||
Command,
|
||||
OutputViewer,
|
||||
}
|
||||
|
||||
struct App {
|
||||
sections: Vec<Section>,
|
||||
flattened_items: Vec<FlattenedItem>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<MesonOptionRaw> = serde_json::from_slice(&output.stdout)?;
|
||||
|
||||
let mut groups: HashMap<String, Vec<MesonOption>> = 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<bool> {
|
||||
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<String> = 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<String> = 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<CrosstermBackend<Stdout>>, 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<ListItem> = 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!(" {} {:<w$} : ", dirty_str, opt.raw.name, w=max_name_width),
|
||||
Style::default().fg(name_color)
|
||||
);
|
||||
|
||||
let p2 = Span::styled(
|
||||
format!("{:<w$} ", val_str, w=val_column_width),
|
||||
Style::default().fg(val_color)
|
||||
);
|
||||
|
||||
let p3 = Span::styled(
|
||||
choices_str,
|
||||
Style::default().fg(Color::LightMagenta)
|
||||
);
|
||||
|
||||
ListItem::new(Line::from(vec![p1, p2, p3]))
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items)
|
||||
.block(Block::default().borders(Borders::LEFT | Borders::RIGHT))
|
||||
.highlight_style(Style::default().bg(Color::Rgb(50, 50, 50)))
|
||||
.highlight_symbol(" ");
|
||||
|
||||
f.render_stateful_widget(list, chunks[1], &mut app.state);
|
||||
|
||||
match app.input_mode {
|
||||
InputMode::Normal => {
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user