mirror of
https://github.com/esp-rs/esp-hal.git
synced 2025-09-27 12:20:56 +00:00
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
This commit is contained in:
parent
8c4878cb03
commit
8fffa6ccec
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
@ -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",
|
||||
]
|
||||
|
268
esp-config/src/bin/esp-config/main.rs
Normal file
268
esp-config/src/bin/esp-config/main.rs
Normal file
@ -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<PathBuf>,
|
||||
|
||||
/// Chip
|
||||
#[arg(short = 'C', long)]
|
||||
chip: Option<esp_metadata::Chip>,
|
||||
|
||||
/// Config file - using `.cargo/config.toml` by default
|
||||
#[arg(short = 'c', long)]
|
||||
config_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CrateConfig {
|
||||
name: String,
|
||||
options: Vec<ConfigItem>,
|
||||
checks: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
struct ConfigItem {
|
||||
option: ConfigOption,
|
||||
actual_value: Value,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
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<CrateConfig>,
|
||||
previous_cfg: Vec<CrateConfig>,
|
||||
cfg_file: Option<String>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
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::<DocumentMut>()?;
|
||||
|
||||
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<esp_metadata::Chip>,
|
||||
config_file: Option<&str>,
|
||||
) -> Result<Vec<CrateConfig>, Box<dyn Error>> {
|
||||
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::<DocumentMut>()?;
|
||||
|
||||
let envs: HashMap<String, String> = 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<esp_metadata::Chip> = pkg
|
||||
.features
|
||||
.iter()
|
||||
.flat_map(|f| esp_metadata::Chip::from_str(f))
|
||||
.collect::<Vec<esp_metadata::Chip>>();
|
||||
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<ConfigItem> = options
|
||||
.iter()
|
||||
.map(|cfg| {
|
||||
Ok::<ConfigItem, Box<dyn Error>>(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(|_| {
|
||||
<std::string::String as Into<Box<dyn Error>>>::into(format!(
|
||||
"Unable to parse '{val}' for option '{}'",
|
||||
&cfg.name
|
||||
))
|
||||
})?;
|
||||
parsed_val
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<ConfigItem>, Box<dyn Error>>>()?;
|
||||
|
||||
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)
|
||||
}
|
754
esp-config/src/bin/esp-config/tui.rs
Normal file
754
esp-config/src/bin/esp-config/tui.rs
Normal file
@ -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<T> = Result<T, Box<dyn Error>>;
|
||||
|
||||
pub struct Repository {
|
||||
configs: Vec<crate::CrateConfig>,
|
||||
current_crate: Option<usize>,
|
||||
}
|
||||
|
||||
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("<p>", "")
|
||||
.replace("</p>", "\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<Validator> {
|
||||
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<crate::CrateConfig>) -> Self {
|
||||
Self {
|
||||
configs: options,
|
||||
current_crate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_level(&self) -> Vec<Item> {
|
||||
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<String> {
|
||||
let level = self.current_level();
|
||||
|
||||
level.iter().map(|v| v.title(width, ui_elements)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_terminal() -> AppResult<Terminal<impl Backend>> {
|
||||
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<ListState>,
|
||||
|
||||
confirm_quit: bool,
|
||||
|
||||
editing: bool,
|
||||
textarea: TextArea<'a>,
|
||||
editing_constraints: Option<Validator>,
|
||||
input_valid: bool,
|
||||
|
||||
showing_selection_popup: bool,
|
||||
list_popup: List<'a>,
|
||||
list_popup_state: ListState,
|
||||
|
||||
show_error_message: bool,
|
||||
initial_message: Option<String>,
|
||||
|
||||
ui_elements: UiElements,
|
||||
colors: Colors,
|
||||
}
|
||||
|
||||
impl App<'_> {
|
||||
pub fn new(errors_to_show: Option<String>, 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<impl Backend>,
|
||||
) -> AppResult<Option<Vec<crate::CrateConfig>>> {
|
||||
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<impl Backend>) -> 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<String>, 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<ListItem> = 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<Paragraph<'_>> {
|
||||
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<String, Value> = 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(())
|
||||
}
|
@ -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<String>>, cfg: &HashMap<String, Value>) -> Result<(), Error> {
|
||||
if let Some(checks) = checks {
|
||||
let mut eval_ctx = evalexpr::HashMapContext::<I128NumericTypes>::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))
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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]
|
||||
|
Loading…
x
Reference in New Issue
Block a user