mirror of
https://github.com/esp-rs/esp-hal.git
synced 2025-10-02 14:44:42 +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: cargo xtask fmt-packages --check
|
||||||
|
|
||||||
# Run tests in esp-config
|
# 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 tests in esp-bootloader-esp-idf
|
||||||
- run: cd esp-bootloader-esp-idf && cargo test --features=std
|
- run: cd esp-bootloader-esp-idf && cargo test --features=std
|
||||||
|
@ -12,13 +12,29 @@ license = "MIT OR Apache-2.0"
|
|||||||
bench = false
|
bench = false
|
||||||
test = true
|
test = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "esp-config"
|
||||||
|
required-features = ["tui"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
document-features = "0.2.11"
|
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 = { version = "1.0.197", default-features = false, features = ["derive"], optional = true }
|
||||||
serde_yaml = { version = "0.9", optional = true }
|
serde_yaml = { version = "0.9", optional = true }
|
||||||
evalexpr = { version = "12.0.2", optional = true }
|
evalexpr = { version = "12.0.2", optional = true }
|
||||||
esp-metadata = { version = "0.7.0", path = "../esp-metadata", default-features = true, 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]
|
[dev-dependencies]
|
||||||
temp-env = "0.3.6"
|
temp-env = "0.3.6"
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
@ -26,3 +42,17 @@ pretty_assertions = "1.4.1"
|
|||||||
[features]
|
[features]
|
||||||
## Enable the generation and parsing of a config
|
## Enable the generation and parsing of a config
|
||||||
build = ["dep:serde", "dep:serde_yaml", "dep:evalexpr", "dep:esp-metadata"]
|
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);
|
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();
|
let mut eval_ctx = evalexpr::HashMapContext::<I128NumericTypes>::new();
|
||||||
for (k, v) in cfg.iter() {
|
for (k, v) in cfg.iter() {
|
||||||
eval_ctx
|
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})")))?;
|
.map_err(|err| Error::Parse(format!("Error setting value for {k} ({err})")))?;
|
||||||
}
|
}
|
||||||
for check in checks {
|
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())
|
.and_then(|v| v.as_boolean())
|
||||||
.map_err(|err| Error::Validation(format!("Validation error: '{check}' ({err})")))?
|
.map_err(|err| Error::Validation(format!("Validation error: '{check}' ({err})")))?
|
||||||
{
|
{
|
||||||
return Err(Error::Validation(format!("Validation error: '{check}'")));
|
return Err(Error::Validation(format!("Validation error: '{check}'")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
Ok(())
|
||||||
Ok(cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate the given YAML representation of a config definition.
|
/// Evaluate the given YAML representation of a config definition.
|
||||||
@ -444,6 +450,9 @@ pub enum DisplayHint {
|
|||||||
|
|
||||||
/// Use a hexadecimal representation
|
/// Use a hexadecimal representation
|
||||||
Hex,
|
Hex,
|
||||||
|
|
||||||
|
/// Use a octal representation
|
||||||
|
Octal,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A configuration option.
|
/// A configuration option.
|
||||||
@ -481,6 +490,11 @@ pub struct ConfigOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
fn env_var(&self, prefix: &str) -> String {
|
||||||
format!("{}{}", prefix, screaming_snake_case(&self.name))
|
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?
|
// TODO: Do we want to handle negative values for non-decimal values?
|
||||||
impl Value {
|
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 {
|
*self = match self {
|
||||||
Value::Bool(_) => match s {
|
Value::Bool(_) => match s {
|
||||||
"true" => Value::Bool(true),
|
"true" => Value::Bool(true),
|
||||||
|
@ -18,6 +18,8 @@ pub use generate::{
|
|||||||
validator::Validator,
|
validator::Validator,
|
||||||
value::Value,
|
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.
|
/// Parse the value of an environment variable as a [bool] at compile time.
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user