use anyhow::Result;
use std::collections::HashMap;
use std::io;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use env_logger::{Builder, Env, Logger};
use esp_generate::{
append_list_as_sentence,
config::{ActiveConfiguration, Relationships, flatten_options},
template::GeneratorOptionItem,
};
use esp_metadata::Chip;
use log::{Level, LevelFilter, Log, Metadata, Record, SetLoggerError};
use ratatui::crossterm::{
ExecutableCommand,
event::{Event, KeyCode, KeyEventKind},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{prelude::*, style::palette::tailwind, widgets::*};
static DEFER_WARNS: AtomicBool = AtomicBool::new(false);
static WARN_BUFFER: Mutex<Vec<String>> = Mutex::new(Vec::new());
pub fn setup_logger() -> Result<(), SetLoggerError> {
let logger = Builder::from_env(Env::default().default_filter_or(LevelFilter::Info.as_str()))
.format_target(false)
.build();
let max_level = logger.filter();
let result = log::set_boxed_logger(Box::new(DeferringLogger { inner: logger }));
if result.is_ok() {
log::set_max_level(max_level);
}
result
}
pub struct Repository {
pub config: ActiveConfiguration,
path: Vec<usize>,
method_option_selected: HashMap<String, Vec<String>>,
method_option_unselected: HashMap<String, Vec<String>>,
}
impl Repository {
pub fn new(chip: Chip, options: Vec<GeneratorOptionItem>, selected: &[String]) -> Self {
let flat_options = flatten_options(&options);
Self {
config: ActiveConfiguration {
chip,
selected: selected
.iter()
.flat_map(|option| flat_options.iter().position(|o| &o.name == option))
.collect(),
flat_options,
options,
},
path: Vec::new(),
method_option_selected: HashMap::new(),
method_option_unselected: HashMap::new(),
}
}
fn remember_method_selection(&mut self, method_option: &str, method_option_selected: bool) {
let snapshot: Vec<String> = self
.config
.selected
.iter()
.map(|idx| &self.config.flat_options[*idx])
.filter(|option| {
ActiveConfiguration::option_is_method_specific_for_state(
option,
method_option,
method_option_selected,
)
})
.map(|option| option.name.clone())
.collect();
if method_option_selected {
self.method_option_selected
.insert(method_option.to_string(), snapshot);
} else {
self.method_option_unselected
.insert(method_option.to_string(), snapshot);
}
}
fn clear_method_specific_selection(
&mut self,
method_option: &str,
method_option_selected: bool,
) {
self.config.selected.retain(|idx| {
let option = &self.config.flat_options[*idx];
!ActiveConfiguration::option_is_method_specific_for_state(
option,
method_option,
method_option_selected,
)
});
}
fn restore_method_selection(&mut self, method_option: &str, method_option_selected: bool) {
let to_restore = if method_option_selected {
self.method_option_selected
.get(method_option)
.cloned()
.unwrap_or_default()
} else {
self.method_option_unselected
.get(method_option)
.cloned()
.unwrap_or_default()
};
for option_name in to_restore {
self.config.select(&option_name);
}
}
fn switch_method_option(&mut self, method_option: &str) {
let currently_selected = self.config.is_selected(method_option);
self.remember_method_selection(method_option, currently_selected);
self.clear_method_specific_selection(method_option, currently_selected);
if currently_selected {
if let Some(i) = self.config.selected_index(method_option) {
self.config.selected.swap_remove(i);
}
} else {
self.config.select(method_option);
}
self.restore_method_selection(method_option, !currently_selected);
}
fn selected_toolchain(&self) -> Option<String> {
self.config.selected.iter().find_map(|idx| {
let option = &self.config.flat_options[*idx];
if option.selection_group == "toolchain" {
Some(option.name.clone())
} else {
None
}
})
}
fn current_level(&self) -> &[GeneratorOptionItem] {
let mut current: &[GeneratorOptionItem] = &self.config.options;
for &index in &self.path {
current = match ¤t[index] {
GeneratorOptionItem::Category(category) => category.options.as_slice(),
GeneratorOptionItem::Option(_) => unreachable!(),
}
}
current
}
fn is_in_category(&self, name: &str) -> bool {
if self.path.is_empty() {
return false;
}
let mut current: &[GeneratorOptionItem] = &self.config.options;
let mut last: Option<&GeneratorOptionItem> = None;
for &index in &self.path {
last = current.get(index);
current = match last {
Some(GeneratorOptionItem::Category(category)) => category.options.as_slice(),
Some(GeneratorOptionItem::Option(_)) | None => return false,
};
}
matches!(
last,
Some(GeneratorOptionItem::Category(category)) if category.name == name
)
}
fn current_level_is_active(&self) -> bool {
let mut current: &[GeneratorOptionItem] = &self.config.options;
for &index in &self.path {
if !self.config.is_active(¤t[index]) {
return false;
}
current = match ¤t[index] {
GeneratorOptionItem::Category(category) => category.options.as_slice(),
GeneratorOptionItem::Option(_) => unreachable!(),
}
}
true
}
fn is_item_actionable(&self, item: &GeneratorOptionItem) -> bool {
match item {
GeneratorOptionItem::Category(_) => self.config.is_active(item),
GeneratorOptionItem::Option(option) => {
self.config.is_option_active(option)
|| self.config.can_switch_method_option(&option.name)
}
}
}
fn enter_group(&mut self, index: usize) {
self.path.push(index);
}
fn toggle_current(&mut self, index: usize) {
if !self.current_level_is_active() {
return;
}
let GeneratorOptionItem::Option(ref option) = self.current_level()[index] else {
ratatui::restore();
unreachable!();
};
let option_name = option.name.clone();
if self.config.can_switch_method_option(&option_name) {
self.switch_method_option(&option_name);
return;
}
let is_active = self.config.is_option_active(option);
if !is_active {
return;
}
if let Some(i) = self.config.selected_index(&option_name) {
if self.config.can_be_disabled(&option_name) {
self.config.selected.swap_remove(i);
}
} else {
self.config.select(&option_name);
}
}
fn is_option(&self, index: usize) -> bool {
matches!(self.current_level()[index], GeneratorOptionItem::Option(_))
}
fn up(&mut self) {
self.path.pop();
}
fn current_level_desc(&self, width: u16, style: &UiElements) -> Vec<(bool, String)> {
let level = self.current_level();
let level_active = self.current_level_is_active();
level
.iter()
.map(|v| {
let name = if let GeneratorOptionItem::Option(_) = v {
v.name()
} else {
""
};
let indicator = if self
.config
.selected
.iter()
.any(|o| self.config.flat_options[*o].name == v.name())
&& level_active
{
style.selected
} else if v.is_category() {
style.category
} else {
style.unselected
};
let padding = (width as usize).saturating_sub(v.title().len() + 4);
(
level_active && self.is_item_actionable(v),
format!(
" {} {}{:>padding$}",
indicator,
v.title(),
name,
padding = padding,
),
)
})
.collect()
}
}
pub fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode()?;
io::stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend)?;
enable_deferred_logging();
Ok(terminal)
}
pub fn restore_terminal() -> Result<()> {
disable_raw_mode()?;
io::stdout().execute(LeaveAlternateScreen)?;
flush_deferred_logs();
Ok(())
}
fn enable_deferred_logging() {
let mut guard = WARN_BUFFER.lock().unwrap();
guard.clear();
DEFER_WARNS.store(true, Ordering::Relaxed);
}
fn flush_deferred_logs() {
let mut guard = WARN_BUFFER.lock().unwrap();
DEFER_WARNS.store(false, Ordering::Relaxed);
let msgs = std::mem::take(&mut *guard);
drop(guard);
for msg in msgs {
log::warn!("{msg}");
}
}
struct DeferringLogger {
inner: Logger,
}
impl Log for DeferringLogger {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
self.inner.enabled(metadata)
}
fn log(&self, record: &Record<'_>) {
if DEFER_WARNS.load(Ordering::Relaxed)
&& record.level() == Level::Warn
&& self.inner.matches(record)
{
WARN_BUFFER
.lock()
.unwrap()
.push(format!("{}", record.args()));
return;
}
self.inner.log(record);
}
fn flush(&self) {
self.inner.flush();
}
}
struct UiElements {
selected: &'static str,
unselected: &'static str,
category: &'static str,
}
struct Colors {
app_background: Color,
header_bg: Color,
normal_row_color: Color,
help_row_color: Color,
disabled_style_fg: Color,
text_color: Color,
selected_active_style: Style,
selected_inactive_style: Style,
}
impl Colors {
const RGB: Self = Self {
app_background: tailwind::SLATE.c950,
header_bg: tailwind::BLUE.c950,
normal_row_color: tailwind::SLATE.c950,
help_row_color: tailwind::SLATE.c800,
disabled_style_fg: tailwind::GRAY.c600,
text_color: tailwind::SLATE.c200,
selected_active_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(tailwind::SLATE.c200)
.bg(tailwind::BLUE.c950),
selected_inactive_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(tailwind::SLATE.c400)
.bg(tailwind::GRAY.c800),
};
const ANSI: Self = Self {
app_background: Color::Black,
header_bg: Color::DarkGray,
normal_row_color: Color::Black,
help_row_color: Color::DarkGray,
disabled_style_fg: Color::DarkGray,
text_color: Color::Gray,
selected_active_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(Color::White)
.bg(Color::Blue),
selected_inactive_style: Style::new()
.add_modifier(Modifier::BOLD)
.fg(Color::DarkGray)
.bg(Color::LightBlue),
};
}
impl UiElements {
const FANCY: Self = Self {
selected: "✅",
unselected: " ",
category: "▶️",
};
const FALLBACK: Self = Self {
selected: "*",
unselected: " ",
category: ">",
};
}
pub enum AppResult {
Continue,
Quit,
Save,
}
pub struct App {
state: Vec<ListState>,
pub repository: Repository,
confirm_quit: bool,
ui_elements: UiElements,
colors: Colors,
toolchains_loading: bool,
}
impl App {
pub fn new(repository: Repository) -> Self {
let mut initial_state = ListState::default();
initial_state.select(Some(0));
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),
};
Self {
repository,
state: vec![initial_state],
confirm_quit: false,
ui_elements,
colors,
toolchains_loading: false,
}
}
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();
}
}
pub fn set_toolchains_loading(&mut self, loading: bool) {
self.toolchains_loading = loading;
}
pub fn selected_options(&self) -> Vec<String> {
self.repository
.config
.selected
.iter()
.map(|idx| self.repository.config.flat_options[*idx].name.clone())
.collect()
}
}
#[cfg(test)]
mod test {
use super::Repository;
use esp_generate::template::{GeneratorOption, GeneratorOptionItem};
use esp_metadata::Chip;
fn option(name: &str, requires: &[&str]) -> GeneratorOptionItem {
GeneratorOptionItem::Option(GeneratorOption {
name: name.to_string(),
display_name: name.to_string(),
selection_group: String::new(),
help: String::new(),
requires: requires.iter().map(|r| r.to_string()).collect(),
chips: Vec::new(),
})
}
#[test]
fn switching_method_option_restores_previous_method_specific_selections() {
let options = vec![
option("method", &[]),
option("method-selected-a", &["method"]),
option("method-selected-b", &["method"]),
option("method-unselected-a", &["!method"]),
option("method-unselected-b", &["!method"]),
option("defmt", &[]),
];
let mut repository = Repository::new(
Chip::Esp32,
options,
&[
"method".to_string(),
"method-selected-a".to_string(),
"defmt".to_string(),
],
);
repository.switch_method_option("method");
assert!(!repository.config.is_selected("method"));
assert!(!repository.config.is_selected("method-selected-a"));
assert!(repository.config.is_selected("defmt"));
repository.config.select("method-unselected-a");
repository.switch_method_option("method");
assert!(repository.config.is_selected("method"));
assert!(repository.config.is_selected("method-selected-a"));
assert!(!repository.config.is_selected("method-unselected-a"));
assert!(repository.config.is_selected("defmt"));
repository.switch_method_option("method");
assert!(!repository.config.is_selected("method"));
assert!(repository.config.is_selected("method-unselected-a"));
}
#[test]
fn switching_back_without_new_intermediate_selections_restores_previous_side() {
let options = vec![
option("method", &[]),
option("method-selected-a", &["method"]),
option("method-unselected-a", &["!method"]),
];
let mut repository =
Repository::new(Chip::Esp32, options, &["method-unselected-a".to_string()]);
repository.toggle_current(0);
assert!(repository.config.is_selected("method"));
assert!(!repository.config.is_selected("method-unselected-a"));
repository.toggle_current(0);
assert!(!repository.config.is_selected("method"));
assert!(repository.config.is_selected("method-unselected-a"));
}
}
impl App {
pub fn handle_event(&mut self, event: Event) -> Result<AppResult> {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
use KeyCode::*;
if self.confirm_quit {
match key.code {
Char('y') | Char('Y') => return Ok(AppResult::Quit),
_ => self.confirm_quit = false,
}
return Ok(AppResult::Continue);
}
match key.code {
Char('q') => self.confirm_quit = true,
Char('s') | Char('S') => {
return Ok(AppResult::Save);
}
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.toolchains_loading && self.repository.is_in_category("toolchain") {
return Ok(AppResult::Continue);
}
if self.repository.is_option(selected) {
self.repository.toggle_current(selected);
} else if !self.repository.current_level()[selected]
.options()
.is_empty()
{
self.repository.enter_group(self.selected());
self.enter_menu();
}
}
Char('j') | Down => self.select_next(),
Char('k') | Up => self.select_previous(),
_ => {}
}
}
}
Ok(AppResult::Continue)
}
pub fn draw(&mut self, terminal: &mut Terminal<impl Backend>) -> Result<()> {
terminal.draw(|f| {
f.render_widget(self, f.area());
})?;
Ok(())
}
}
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);
}
}
impl App {
fn render_title(&self, area: Rect, buf: &mut Buffer) {
let mut title = String::from("esp-generate");
if let Some(tc) = self.repository.selected_toolchain() {
use std::fmt::Write;
let _ = write!(&mut title, " | Toolchain: {tc}");
} else {
use std::fmt::Write;
let _ = write!(&mut title, " | Toolchain: template defaults");
}
Paragraph::new(title)
.bold()
.centered()
.fg(self.colors.text_color)
.bg(self.colors.app_background)
.render(area, buf);
}
fn render_item(&mut self, area: Rect, buf: &mut Buffer) {
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);
let outer_area = area;
let inner_area = outer_block.inner(outer_area);
outer_block.render(outer_area, buf);
let items: Vec<ListItem> = self
.repository
.current_level_desc(area.width, &self.ui_elements)
.into_iter()
.map(|(enabled, value)| {
ListItem::new(value).style(if enabled {
Style::default()
} else {
Style::default().fg(self.colors.disabled_style_fg)
})
})
.collect();
if let Some(current_state) = self.state.last_mut() {
let current_item_active = if let Some(current) =
current_state.selected().and_then(|idx| {
self.repository
.current_level()
.get(idx.min(items.len() - 1))
}) {
self.repository.current_level_is_active()
&& self.repository.is_item_actionable(current)
} else {
false
};
let items = List::new(items)
.block(inner_block)
.highlight_style(if current_item_active {
self.colors.selected_active_style
} else {
self.colors.selected_inactive_style
})
.highlight_spacing(HighlightSpacing::Always);
StatefulWidget::render(items, inner_area, buf, current_state);
} else {
ratatui::restore();
panic!("menu state not found!")
}
}
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 mut relationships = self.repository.config.collect_relationships(option);
if let GeneratorOptionItem::Option(option) = option {
if self
.repository
.config
.can_switch_method_option(&option.name)
{
relationships.disabled_by.clear();
}
}
let Relationships {
requires,
required_by,
disabled_by,
} = relationships;
let help_text = option.help();
let help_text = append_list_as_sentence(help_text, "Requires", &requires);
let help_text = append_list_as_sentence(&help_text, "Required by", &required_by);
let help_text = append_list_as_sentence(&help_text, "Disabled by", &disabled_by);
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 {
"Use ↓↑ to move, ESC/← to go up, → to go deeper or change the value, s/S to save and generate, ESC/q to cancel"
};
let text = if self.toolchains_loading {
format!("{text} | Scanning installed toolchains…")
} else {
text.to_string()
};
Paragraph::new(text)
.centered()
.fg(self.colors.text_color)
.bg(self.colors.app_background)
.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);
}
}