From 8fffa6ccec8dae7321a077da2031927df0943cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Quentin?= Date: Tue, 1 Jul 2025 17:14:26 +0200 Subject: [PATCH] esp-config TUI (#3649) * Introduce esp-config TUI * Validate more * Improve chip-selection, support alternate config.toml files * Minor improvements * Clippy --fix * Do crate-level checks on every change * de-dup * Address review comments * Address review comments * Remove the safety-belt --- .github/workflows/ci.yml | 2 +- esp-config/Cargo.toml | 30 + esp-config/src/bin/esp-config/main.rs | 268 +++++++++ esp-config/src/bin/esp-config/tui.rs | 754 ++++++++++++++++++++++++++ esp-config/src/generate/mod.rs | 24 +- esp-config/src/generate/value.rs | 3 +- esp-config/src/lib.rs | 2 + 7 files changed, 1076 insertions(+), 7 deletions(-) create mode 100644 esp-config/src/bin/esp-config/main.rs create mode 100644 esp-config/src/bin/esp-config/tui.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f06df5fe3..f4f147929 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,7 +169,7 @@ jobs: - run: cargo xtask fmt-packages --check # Run tests in esp-config - - run: cd esp-config && cargo test --features build + - run: cd esp-config && cargo test --features build,tui # Run tests in esp-bootloader-esp-idf - run: cd esp-bootloader-esp-idf && cargo test --features=std diff --git a/esp-config/Cargo.toml b/esp-config/Cargo.toml index 43b15bf6c..3f9d3d02a 100644 --- a/esp-config/Cargo.toml +++ b/esp-config/Cargo.toml @@ -12,13 +12,29 @@ license = "MIT OR Apache-2.0" bench = false test = true +[[bin]] +name = "esp-config" +required-features = ["tui"] + [dependencies] document-features = "0.2.11" + +# used by the `build` and `tui` feature serde = { version = "1.0.197", default-features = false, features = ["derive"], optional = true } serde_yaml = { version = "0.9", optional = true } evalexpr = { version = "12.0.2", optional = true } esp-metadata = { version = "0.7.0", path = "../esp-metadata", default-features = true, optional = true } +# used by the `tui` feature +clap = { version = "4.5.32", features = ["derive"], optional = true } +crossterm = { version = "0.28.1", optional = true } +env_logger = { version = "0.11.7", optional = true } +log = { version = "0.4.26", optional = true } +ratatui = { version = "0.29.0", features = ["crossterm", "unstable"], optional = true } +toml_edit = { version = "0.22.26", optional = true } +tui-textarea = { version = "0.7.0", optional = true } +cargo_metadata = { version = "0.19.2", optional = true } + [dev-dependencies] temp-env = "0.3.6" pretty_assertions = "1.4.1" @@ -26,3 +42,17 @@ pretty_assertions = "1.4.1" [features] ## Enable the generation and parsing of a config build = ["dep:serde", "dep:serde_yaml", "dep:evalexpr", "dep:esp-metadata"] + +## The TUI +tui = [ + "dep:clap", + "dep:crossterm", + "dep:env_logger", + "dep:log", + "dep:ratatui", + "dep:toml_edit", + "dep:tui-textarea", + "dep:cargo_metadata", + "esp-metadata?/clap", + "build", +] diff --git a/esp-config/src/bin/esp-config/main.rs b/esp-config/src/bin/esp-config/main.rs new file mode 100644 index 000000000..f1ae441be --- /dev/null +++ b/esp-config/src/bin/esp-config/main.rs @@ -0,0 +1,268 @@ +use std::{ + collections::HashMap, + error::Error, + path::{Path, PathBuf}, + str::FromStr, +}; + +use clap::Parser; +use env_logger::{Builder, Env}; +use esp_config::{ConfigOption, Value}; +use serde::Deserialize; +use toml_edit::{DocumentMut, Formatted, Item, Table}; + +mod tui; + +const DEFAULT_CONFIG_PATH: &str = ".cargo/config.toml"; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Root of the project + #[arg(short = 'P', long)] + path: Option, + + /// Chip + #[arg(short = 'C', long)] + chip: Option, + + /// Config file - using `.cargo/config.toml` by default + #[arg(short = 'c', long)] + config_file: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CrateConfig { + name: String, + options: Vec, + checks: Option>, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +struct ConfigItem { + option: ConfigOption, + actual_value: Value, +} + +fn main() -> Result<(), Box> { + Builder::from_env(Env::default().default_filter_or(log::LevelFilter::Info.as_str())) + .format_target(false) + .init(); + + let args = Args::parse(); + + let work_dir = args.path.clone().unwrap_or(".".into()); + + let config_file_path = + work_dir.join(args.config_file.as_deref().unwrap_or(DEFAULT_CONFIG_PATH)); + if !config_file_path.exists() { + return Err(format!( + "Config file {} does not exist or is not readable.", + config_file_path.display() + ) + .into()); + } + + let configs = parse_configs(&work_dir, args.chip, args.config_file.as_deref())?; + let initial_configs = configs.clone(); + let previous_config = initial_configs.clone(); + + let repository = tui::Repository::new(configs.clone()); + + // TUI stuff ahead + let terminal = tui::init_terminal()?; + + // create app and run it + let updated_cfg = tui::App::new(None, repository).run(terminal)?; + + tui::restore_terminal()?; + + // done with the TUI + if let Some(updated_cfg) = updated_cfg { + apply_config( + &work_dir, + updated_cfg, + previous_config, + args.config_file.clone(), + )?; + } + + Ok(()) +} + +fn apply_config( + path: &Path, + updated_cfg: Vec, + previous_cfg: Vec, + cfg_file: Option, +) -> Result<(), Box> { + let config_toml_path = path.join(cfg_file.as_deref().unwrap_or(DEFAULT_CONFIG_PATH)); + + let mut config = std::fs::read_to_string(&config_toml_path)? + .as_str() + .parse::()?; + + if !config.contains_key("env") { + config.insert("env", Item::Table(Table::new())); + } + + let envs = config.get_mut("env").unwrap().as_table_mut().unwrap(); + + for cfg in updated_cfg { + let previous_crate_cfg = previous_cfg.iter().find(|c| c.name == cfg.name); + + for option in cfg.options { + let previous_option = previous_crate_cfg.and_then(|c| { + c.options + .iter() + .find(|o| o.option.name == option.option.name) + }); + let key = option.option.full_env_var(&cfg.name); + + // avoid updating unchanged options to keep the comments (if any) + if Some(&option.actual_value) != previous_option.map(|option| &option.actual_value) { + if option.actual_value != option.option.default_value { + let value = toml_edit::Value::String(Formatted::new(format!( + "{}", + option.actual_value + ))); + + envs.insert(&key, Item::Value(value)); + } else { + envs.remove(&key); + } + } + } + } + + std::fs::write(&config_toml_path, config.to_string().as_bytes())?; + + Ok(()) +} + +fn parse_configs( + path: &Path, + chip_from_args: Option, + config_file: Option<&str>, +) -> Result, Box> { + let config_toml_path = path.join(config_file.as_deref().unwrap_or(DEFAULT_CONFIG_PATH)); + let config_toml_content = std::fs::read_to_string(config_toml_path)?; + let config_toml = config_toml_content.as_str().parse::()?; + + let envs: HashMap = config_toml + .get("env") + .and_then(Item::as_table) + .map(|table| { + table + .iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.to_string(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + // Get the metadata of the project to + // - discover configurable crates + // - get the active features on crates (e.g. to guess the chip the project is + // targeting) + // this might fetch the dependencies from registries and/or git repositories + // so this + // - might take a few seconds (while it's usually very quick) + // - might need an internet connection to succeed + + let meta = cargo_metadata::MetadataCommand::new() + .current_dir(path) + .verbose(true) // entertain the user by showing what exactly is going on + .exec() + // with verbose output the error we get doesn't contain anything useful or interesting + .map_err(|_| "`cargo metadata` failed for your project. Make sure it's buildable.")?; + + // try to guess the chip from the metadata by looking at an active chip feature + // for esp-hal + let chip_from_meta = || { + let mut chip = None; + for pkg in &meta.root_package().unwrap().dependencies { + if pkg.name == "esp-hal" { + let possible_chip_feature_matches: Vec = pkg + .features + .iter() + .flat_map(|f| esp_metadata::Chip::from_str(f)) + .collect::>(); + chip = possible_chip_feature_matches.first().cloned(); + } + } + chip + }; + + // the "ESP_CONFIG_CHIP" hint env-var if present + let chip_from_config = || { + envs.get("ESP_CONFIG_CHIP") + .and_then(|chip_str| clap::ValueEnum::from_str(chip_str, true).ok()) + }; + + // - if given as a parameter, use it + // - if there is a hint in the config.toml, use it + // - if we can infer it from metadata, use it + // otherwise, fail + let chip = chip_from_args + .or_else(chip_from_config) + .or_else(chip_from_meta); + + if chip.is_none() { + return Err("No chip given or inferred. Try using the `--chip` argument.".into()); + } + + let mut configs = Vec::new(); + let chip = esp_metadata::Config::for_chip(chip.as_ref().unwrap()); + let features = vec![]; + for krate in meta.packages { + let maybe_cfg = krate.manifest_path.parent().unwrap().join("esp_config.yml"); + if maybe_cfg.exists() { + let yaml = std::fs::read_to_string(&maybe_cfg)?; + let (cfg, options) = + esp_config::evaluate_yaml_config(&yaml, Some(chip.clone()), features.clone(), true) + .map_err(|e| { + format!( + "Error evaluating YAML config for crate {}: {}", + krate.name, e + ) + })?; + + let crate_name = cfg.krate.clone(); + + let options: Vec = options + .iter() + .map(|cfg| { + Ok::>(ConfigItem { + option: cfg.clone(), + actual_value: { + let key = cfg.full_env_var(&crate_name); + let def_val = &cfg.default_value.to_string(); + let val = envs.get(&key).unwrap_or(def_val); + + let mut parsed_val = cfg.default_value.clone(); + parsed_val.parse_in_place(val).map_err(|_| { + >>::into(format!( + "Unable to parse '{val}' for option '{}'", + &cfg.name + )) + })?; + parsed_val + }, + }) + }) + .collect::, Box>>()?; + + configs.push(CrateConfig { + name: crate_name.clone(), + checks: cfg.checks.clone(), + options, + }); + } + } + + if configs.is_empty() { + return Err("No config files found.".into()); + } + + Ok(configs) +} diff --git a/esp-config/src/bin/esp-config/tui.rs b/esp-config/src/bin/esp-config/tui.rs new file mode 100644 index 000000000..a04440a1f --- /dev/null +++ b/esp-config/src/bin/esp-config/tui.rs @@ -0,0 +1,754 @@ +use std::{collections::HashMap, error::Error, io}; + +use crossterm::{ + ExecutableCommand, + event::{self, Event, KeyCode, KeyEventKind}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use esp_config::{DisplayHint, Stability, Validator, Value}; +use ratatui::{prelude::*, style::palette::tailwind, widgets::*}; +use tui_textarea::{CursorMove, TextArea}; + +use crate::CrateConfig; + +type AppResult = Result>; + +pub struct Repository { + configs: Vec, + current_crate: Option, +} + +enum Item { + TopLevel(String), + CrateLevel(crate::ConfigItem), +} + +impl Item { + fn title(&self, _width: u16, ui_elements: &UiElements) -> String { + match self { + Item::TopLevel(crate_name) => crate_name.clone(), + Item::CrateLevel(config_option) => { + let display_value = format_using_display_hint( + &config_option.actual_value, + &config_option.option.display_hint, + ); + let default_indicator = + if config_option.actual_value == config_option.option.default_value { + ui_elements.default_value + } else { + "" + }; + + let unstable_indicator = if config_option.option.stability == Stability::Unstable { + ui_elements.unstable + } else { + "" + }; + + format!( + "{} ({}{}){}", + config_option.option.name, display_value, default_indicator, unstable_indicator + ) + } + } + } + + fn help_text(&self) -> String { + match self { + Item::TopLevel(crate_name) => format!("The `{crate_name}` crate"), + Item::CrateLevel(config_option) => config_option.option.description.clone(), + } + .replace("

", "") + .replace("

", "\n") + .to_string() + } + + fn value(&self) -> Value { + match self { + Item::TopLevel(_) => unreachable!(), + Item::CrateLevel(config_option) => config_option.actual_value.clone(), + } + } + + fn constraint(&self) -> Option { + match self { + Item::TopLevel(_) => unreachable!(), + Item::CrateLevel(config_option) => config_option.option.constraint.clone(), + } + } + + fn display_hint(&self) -> DisplayHint { + match self { + Item::TopLevel(_) => unreachable!(), + Item::CrateLevel(config_option) => config_option.option.display_hint.clone(), + } + } +} + +fn format_using_display_hint(value: &Value, hint: &DisplayHint) -> String { + match value { + Value::Bool(b) => b.to_string(), + Value::Integer(i) => match hint { + DisplayHint::None => format!("{}", i), + DisplayHint::Binary => format!("0b{:0b}", i), + DisplayHint::Hex => format!("0x{:x}", i), + DisplayHint::Octal => format!("0o{:o}", i), + }, + Value::String(s) => s.clone(), + } +} + +impl Repository { + pub fn new(options: Vec) -> Self { + Self { + configs: options, + current_crate: None, + } + } + + fn current_level(&self) -> Vec { + if self.current_crate.is_none() { + Vec::from_iter( + self.configs + .iter() + .map(|config| Item::TopLevel(config.name.clone())), + ) + } else { + Vec::from_iter( + self.configs[self.current_crate.unwrap()] + .options + .iter() + .map(|option| Item::CrateLevel(option.clone())), + ) + } + } + + fn enter_group(&mut self, index: usize) { + if self.current_crate.is_none() { + self.current_crate = Some(index); + } + } + + fn up(&mut self) { + if self.current_crate.is_some() { + self.current_crate = None; + } + } + + fn set_current(&mut self, index: usize, new_value: Value) -> Result<(), String> { + if self.current_crate.is_none() { + return Ok(()); + } + + let crate_config = &mut self.configs[self.current_crate.unwrap()]; + let previous = crate_config.options[index].actual_value.clone(); + crate_config.options[index].actual_value = new_value; + + let res = validate_config(crate_config); + if let Err(error) = res { + crate_config.options[index].actual_value = previous; + return Err(error.to_string()); + } + + Ok(()) + } + + // true if this is a configurable option + fn is_option(&self, _index: usize) -> bool { + self.current_crate.is_some() + } + + // What to show in the list + fn current_level_desc(&self, width: u16, ui_elements: &UiElements) -> Vec { + let level = self.current_level(); + + level.iter().map(|v| v.title(width, ui_elements)).collect() + } +} + +pub fn init_terminal() -> AppResult> { + enable_raw_mode()?; + io::stdout().execute(EnterAlternateScreen)?; + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +pub fn restore_terminal() -> AppResult<()> { + disable_raw_mode()?; + io::stdout().execute(LeaveAlternateScreen)?; + Ok(()) +} + +struct UiElements { + default_value: &'static str, + unstable: &'static str, + popup_highlight_symbol: &'static str, +} + +struct Colors { + header_bg: Color, + normal_row_color: Color, + help_row_color: Color, + text_color: Color, + + selected_active_style: Style, + edit_invalid_style: Style, + edit_valid_style: Style, + border_style: Style, + border_error_style: Style, +} + +impl Colors { + const RGB: Self = Self { + header_bg: tailwind::BLUE.c950, + normal_row_color: tailwind::SLATE.c950, + help_row_color: tailwind::SLATE.c800, + text_color: tailwind::SLATE.c200, + + selected_active_style: Style::new() + .add_modifier(Modifier::BOLD) + .fg(tailwind::SLATE.c200) + .bg(tailwind::BLUE.c950), + edit_invalid_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red), + edit_valid_style: Style::new() + .add_modifier(Modifier::BOLD) + .fg(tailwind::SLATE.c200), + border_style: Style::new() + .add_modifier(Modifier::BOLD) + .fg(Color::LightBlue), + border_error_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red), + }; + const ANSI: Self = Self { + header_bg: Color::DarkGray, + normal_row_color: Color::Black, + help_row_color: Color::DarkGray, + text_color: Color::Gray, + + selected_active_style: Style::new() + .add_modifier(Modifier::BOLD) + .fg(Color::White) + .bg(Color::Blue), + edit_invalid_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red), + edit_valid_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Gray), + border_style: Style::new() + .add_modifier(Modifier::BOLD) + .fg(Color::LightBlue), + border_error_style: Style::new().add_modifier(Modifier::BOLD).fg(Color::Red), + }; +} + +impl UiElements { + const FANCY: Self = Self { + default_value: " ⭐", + unstable: " 🚧", + popup_highlight_symbol: "▶️ ", + }; + const FALLBACK: Self = Self { + default_value: " *", + unstable: " !", + popup_highlight_symbol: "> ", + }; +} + +pub struct App<'a> { + repository: Repository, + + state: Vec, + + confirm_quit: bool, + + editing: bool, + textarea: TextArea<'a>, + editing_constraints: Option, + input_valid: bool, + + showing_selection_popup: bool, + list_popup: List<'a>, + list_popup_state: ListState, + + show_error_message: bool, + initial_message: Option, + + ui_elements: UiElements, + colors: Colors, +} + +impl App<'_> { + pub fn new(errors_to_show: Option, repository: Repository) -> Self { + let (ui_elements, colors) = match std::env::var("TERM_PROGRAM").as_deref() { + Ok("vscode") => (UiElements::FALLBACK, Colors::RGB), + Ok("Apple_Terminal") => (UiElements::FALLBACK, Colors::ANSI), + _ => (UiElements::FANCY, Colors::RGB), + }; + + let mut initial_state = ListState::default(); + initial_state.select(Some(0)); + + Self { + repository, + state: vec![initial_state], + confirm_quit: false, + editing: false, + textarea: TextArea::default(), + editing_constraints: None, + input_valid: true, + showing_selection_popup: false, + list_popup: List::default(), + list_popup_state: ListState::default(), + show_error_message: errors_to_show.is_some(), + initial_message: errors_to_show, + ui_elements, + colors, + } + } + + pub fn selected(&self) -> usize { + if let Some(current) = self.state.last() { + current.selected().unwrap_or_default() + } else { + 0 + } + } + + pub fn select_next(&mut self) { + if let Some(current) = self.state.last_mut() { + current.select_next(); + } + } + pub fn select_previous(&mut self) { + if let Some(current) = self.state.last_mut() { + current.select_previous(); + } + } + pub fn enter_menu(&mut self) { + let mut new_state = ListState::default(); + new_state.select(Some(0)); + self.state.push(new_state); + } + pub fn exit_menu(&mut self) { + if self.state.len() > 1 { + self.state.pop(); + } + } +} + +impl App<'_> { + pub fn run( + &mut self, + mut terminal: Terminal, + ) -> AppResult>> { + loop { + self.draw(&mut terminal)?; + + if let Event::Key(key) = event::read()? { + if self.editing { + match key.code { + KeyCode::Enter if key.kind == KeyEventKind::Press => { + if !self.input_valid { + continue; + } + + let selected = self.selected(); + if self.repository.is_option(selected) { + let current = self.repository.current_level()[selected].value(); + let text = self.textarea.lines().join("").to_string(); + let mut value = current.clone(); + if value.parse_in_place(&text).is_ok() { + let set_res = self.repository.set_current(selected, value); + self.handle_error(set_res); + } else { + self.handle_error(Err("Invalid value".to_string())); + } + } + + self.editing = false; + } + KeyCode::Esc => { + self.editing = false; + } + _ => { + if self.textarea.input(key) { + let selected = self.selected(); + if self.repository.is_option(selected) { + let current = self.repository.current_level()[selected].value(); + let text = self.textarea.lines().join("").to_string(); + let mut parsed_value = current.clone(); + let parse_res = parsed_value.parse_in_place(&text); + let validator_failed = if let Some(constraint) = + &self.editing_constraints + { + match parse_res { + Ok(()) => constraint.validate(&parsed_value).is_err(), + _ => false, + } + } else { + false + }; + + let invalid = parse_res.is_err() || validator_failed; + + self.textarea.set_style(if invalid { + self.colors.edit_invalid_style + } else { + self.colors.edit_valid_style + }); + self.input_valid = !invalid; + } + } + } + } + } else if self.showing_selection_popup && key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.showing_selection_popup = false; + } + KeyCode::Down | KeyCode::Char('j') => self.list_popup_state.select_next(), + KeyCode::Up | KeyCode::Char('k') => self.list_popup_state.select_previous(), + KeyCode::Enter if key.kind == KeyEventKind::Press => { + let selected = self.selected(); + if let Some(Validator::Enumeration(items)) = &self.repository.configs + [self.repository.current_crate.unwrap()] + .options[selected] + .option + .constraint + { + let set_res = self.repository.set_current( + selected, + Value::String( + items[self.list_popup_state.selected().unwrap()].clone(), + ), + ); + self.handle_error(set_res); + } + self.showing_selection_popup = false; + } + _ => (), + } + } else if self.show_error_message { + match key.code { + KeyCode::Enter if key.kind == KeyEventKind::Press => { + self.show_error_message = false; + } + _ => (), + } + } else if key.kind == KeyEventKind::Press { + use KeyCode::*; + + if self.confirm_quit { + match key.code { + Char('y') | Char('Y') => return Ok(None), + _ => self.confirm_quit = false, + } + continue; + } + + match key.code { + Char('q') => self.confirm_quit = true, + Char('s') | Char('S') => return Ok(Some(self.repository.configs.clone())), + Esc => { + if self.state.len() == 1 { + self.confirm_quit = true; + } else { + self.repository.up(); + self.exit_menu(); + } + } + Char('h') | Left => { + self.repository.up(); + self.exit_menu(); + } + Char('l') | Char(' ') | Right | Enter => { + let selected = self.selected(); + if self.repository.is_option(selected) { + let current = self.repository.current_level()[selected].value(); + let constraint = + self.repository.current_level()[selected].constraint(); + + match current { + Value::Bool(value) => { + let set_res = self + .repository + .set_current(selected, Value::Bool(!value)); + self.handle_error(set_res); + } + Value::Integer(_) => { + let display_value = format_using_display_hint( + ¤t, + &self.repository.current_level()[selected] + .display_hint(), + ); + self.textarea = + make_text_area(&display_value, &self.colors); + self.editing_constraints = constraint; + self.editing = true; + } + Value::String(s) => match constraint { + Some(Validator::Enumeration(items)) => { + let selected_option = + items.iter().position(|v| *v == s); + self.list_popup = + make_popup(items, &self.ui_elements, &self.colors); + self.list_popup_state = ListState::default(); + self.list_popup_state.select(selected_option); + self.showing_selection_popup = true; + } + _ => { + self.textarea = make_text_area(&s, &self.colors); + self.editing_constraints = None; + self.editing = true; + } + }, + } + } else { + self.repository.enter_group(self.selected()); + self.enter_menu(); + } + } + Char('j') | Down => { + self.select_next(); + } + Char('k') | Up => { + self.select_previous(); + } + _ => {} + } + } + } + } + } + + fn draw(&mut self, terminal: &mut Terminal) -> AppResult<()> { + terminal.draw(|f| { + f.render_widget(self, f.area()); + })?; + + Ok(()) + } + + fn handle_error(&mut self, result: Result<(), String>) { + if let Err(error) = result { + self.show_error_message = true; + self.initial_message = Some(error); + } + } +} + +fn make_text_area<'a>(s: &str, colors: &Colors) -> TextArea<'a> { + let mut text_area = TextArea::new(vec![s.to_string()]); + text_area.set_block( + Block::default() + .borders(Borders::ALL) + .border_style(colors.border_style) + .title("Input"), + ); + text_area.set_style(colors.edit_valid_style); + text_area.set_cursor_line_style(Style::default()); + text_area.move_cursor(CursorMove::End); + text_area +} + +fn make_popup<'a>(items: Vec, ui_elements: &UiElements, colors: &Colors) -> List<'a> { + List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(colors.border_style) + .title("Choose"), + ) + .highlight_style(colors.selected_active_style) + .highlight_symbol(ui_elements.popup_highlight_symbol) + .repeat_highlight_symbol(true) +} + +impl Widget for &mut App<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let vertical = Layout::vertical([ + Constraint::Length(2), + Constraint::Fill(1), + Constraint::Length(self.help_lines(area)), + Constraint::Length(self.footer_lines(area)), + ]); + let [header_area, rest_area, help_area, footer_area] = vertical.areas(area); + + self.render_title(header_area, buf); + self.render_item(rest_area, buf); + self.render_help(help_area, buf); + self.render_footer(footer_area, buf); + + if self.editing { + let area = Rect { + x: 5, + y: area.height / 2 - 2, + width: area.width - 10, + height: 3, + }; + + ratatui::widgets::Clear.render(area, buf); + self.textarea.render(area, buf); + } + + if self.showing_selection_popup { + let area = Rect { + x: 5, + y: area.height / 2 - 3, + width: area.width - 10, + height: 6, + }; + + ratatui::widgets::Clear.render(area, buf); + StatefulWidget::render(&self.list_popup, area, buf, &mut self.list_popup_state); + } + + if self.show_error_message { + let area = Rect { + x: 5, + y: area.height / 2 - 5, + width: area.width - 10, + height: 5, + }; + + let block = Paragraph::new(self.initial_message.as_ref().unwrap().clone()) + .style(self.colors.edit_invalid_style) + .block( + Block::bordered() + .title("Validation Error") + .style(self.colors.border_error_style) + .padding(Padding::uniform(1)), + ); + + ratatui::widgets::Clear.render(area, buf); + block.render(area, buf); + } + } +} + +impl App<'_> { + fn render_title(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new("esp-config") + .bold() + .centered() + .render(area, buf); + } + + fn render_item(&mut self, area: Rect, buf: &mut Buffer) { + // We create two blocks, one is for the header (outer) and the other is for the + // list (inner). + let outer_block = Block::default() + .borders(Borders::NONE) + .fg(self.colors.text_color) + .bg(self.colors.header_bg) + .title_alignment(Alignment::Center); + let inner_block = Block::default() + .borders(Borders::NONE) + .fg(self.colors.text_color) + .bg(self.colors.normal_row_color); + + // We get the inner area from outer_block. We'll use this area later to render + // the table. + let outer_area = area; + let inner_area = outer_block.inner(outer_area); + + // We can render the header in outer_area. + outer_block.render(outer_area, buf); + + // Iterate through all elements in the `items` and stylize them. + let items: Vec = self + .repository + .current_level_desc(area.width, &self.ui_elements) + .into_iter() + .map(|value| ListItem::new(value).style(Style::default())) + .collect(); + + // We can now render the item list + // (look carefully, we are using StatefulWidget's render.) + // ratatui::widgets::StatefulWidget::render as stateful_render + let current_state = self + .state + .last_mut() + .expect("State should always have at least one element"); + let list_widget = List::new(items) + .block(inner_block) + .highlight_style(self.colors.selected_active_style) + .highlight_spacing(HighlightSpacing::Always); + StatefulWidget::render(list_widget, inner_area, buf, current_state); + } + + fn help_paragraph(&self) -> Option> { + let selected = self + .selected() + .min(self.repository.current_level().len() - 1); + let option = &self.repository.current_level()[selected]; + let help_text = option.help_text(); + if help_text.is_empty() { + return None; + } + + let help_block = Block::default() + .borders(Borders::NONE) + .fg(self.colors.text_color) + .bg(self.colors.help_row_color); + + Some( + Paragraph::new(help_text) + .centered() + .wrap(Wrap { trim: false }) + .block(help_block), + ) + } + + fn help_lines(&self, area: Rect) -> u16 { + if let Some(paragraph) = self.help_paragraph() { + paragraph.line_count(area.width) as u16 + } else { + 0 + } + } + + fn render_help(&self, area: Rect, buf: &mut Buffer) { + if let Some(paragraph) = self.help_paragraph() { + paragraph.render(area, buf); + } + } + + fn footer_paragraph(&self) -> Paragraph<'_> { + let text = if self.confirm_quit { + "Are you sure you want to quit? (y/N)" + } else if self.editing { + "ENTER to confirm, ESC to cancel" + } else if self.showing_selection_popup { + "Use ↓↑ to move, ENTER to confirm, ESC to cancel" + } else if self.show_error_message { + "ENTER to confirm" + } else { + "Use ↓↑ to move, ESC/← to go up, → to go deeper or change the value, s/S to save and generate, ESC/q to cancel" + }; + + Paragraph::new(text).centered().wrap(Wrap { trim: false }) + } + + fn footer_lines(&self, area: Rect) -> u16 { + self.footer_paragraph().line_count(area.width) as u16 + } + + fn render_footer(&self, area: Rect, buf: &mut Buffer) { + self.footer_paragraph().render(area, buf); + } +} + +pub(super) fn validate_config(config: &CrateConfig) -> Result<(), String> { + let cfg: HashMap = config + .options + .iter() + .map(|option| { + ( + option.option.full_env_var(&config.name), + option.actual_value.clone(), + ) + }) + .collect(); + if let Err(error) = esp_config::do_checks(config.checks.as_ref(), &cfg) { + return Err(error.to_string()); + } + Ok(()) +} diff --git a/esp-config/src/generate/mod.rs b/esp-config/src/generate/mod.rs index a24fc1085..fa3c13a10 100644 --- a/esp-config/src/generate/mod.rs +++ b/esp-config/src/generate/mod.rs @@ -150,7 +150,14 @@ pub fn generate_config_from_yaml_definition( let cfg = generate_config(&config.krate, &options, enable_unstable, emit_md_tables); - if let Some(checks) = config.checks { + do_checks(config.checks.as_ref(), &cfg)?; + + Ok(cfg) +} + +/// Check the given actual values by applying checking the given checks +pub fn do_checks(checks: Option<&Vec>, cfg: &HashMap) -> Result<(), Error> { + if let Some(checks) = checks { let mut eval_ctx = evalexpr::HashMapContext::::new(); for (k, v) in cfg.iter() { eval_ctx @@ -165,16 +172,15 @@ pub fn generate_config_from_yaml_definition( .map_err(|err| Error::Parse(format!("Error setting value for {k} ({err})")))?; } for check in checks { - if !evalexpr::eval_with_context(&check, &eval_ctx) + if !evalexpr::eval_with_context(check, &eval_ctx) .and_then(|v| v.as_boolean()) .map_err(|err| Error::Validation(format!("Validation error: '{check}' ({err})")))? { return Err(Error::Validation(format!("Validation error: '{check}'"))); } } - } - - Ok(cfg) + }; + Ok(()) } /// Evaluate the given YAML representation of a config definition. @@ -444,6 +450,9 @@ pub enum DisplayHint { /// Use a hexadecimal representation Hex, + + /// Use a octal representation + Octal, } /// A configuration option. @@ -481,6 +490,11 @@ pub struct ConfigOption { } impl ConfigOption { + /// Get the corresponding ENV_VAR name given the crate-name + pub fn full_env_var(&self, crate_name: &str) -> String { + self.env_var(&format!("{}_CONFIG_", screaming_snake_case(crate_name))) + } + fn env_var(&self, prefix: &str) -> String { format!("{}{}", prefix, screaming_snake_case(&self.name)) } diff --git a/esp-config/src/generate/value.rs b/esp-config/src/generate/value.rs index 98912011d..a6266c97f 100644 --- a/esp-config/src/generate/value.rs +++ b/esp-config/src/generate/value.rs @@ -81,7 +81,8 @@ impl<'de> Deserialize<'de> for Value { // TODO: Do we want to handle negative values for non-decimal values? impl Value { - pub(crate) fn parse_in_place(&mut self, s: &str) -> Result<(), Error> { + /// Try to parse the given String + pub fn parse_in_place(&mut self, s: &str) -> Result<(), Error> { *self = match self { Value::Bool(_) => match s { "true" => Value::Bool(true), diff --git a/esp-config/src/lib.rs b/esp-config/src/lib.rs index 2195069b0..755981faf 100644 --- a/esp-config/src/lib.rs +++ b/esp-config/src/lib.rs @@ -18,6 +18,8 @@ pub use generate::{ validator::Validator, value::Value, }; +#[cfg(feature = "tui")] +pub use generate::{do_checks, evaluate_yaml_config}; /// Parse the value of an environment variable as a [bool] at compile time. #[macro_export]