feat(cli): enhance install and removal UX with progress visibility and theming

- Introduced visible progress stages during installation, including source resolution and artifact selection.
- Improved separation between live transcript output and final summaries, ensuring clarity.
- Removed redundant recap text from installation summaries.
- Centralized terminal styling using a configurable theme system, allowing for warm defaults and user overrides.
- Added support for hex colors and named colors in the configuration.
- Updated tests to verify new behaviors and configurations.
This commit is contained in:
stoorps 2026-03-20 19:44:04 +00:00
parent c63b2917da
commit 9d8ec1e4fd
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
17 changed files with 1277 additions and 74 deletions

View file

@ -0,0 +1,165 @@
use std::env;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub struct LoadedConfig {
pub config: AppConfig,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub struct AppConfig {
pub theme: ThemeConfig,
}
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub struct ThemeConfig {
pub heading: Option<String>,
pub accent: Option<String>,
pub muted: Option<String>,
pub label: Option<String>,
pub bullet: Option<String>,
pub success: Option<String>,
pub warning: Option<String>,
pub error: Option<String>,
pub progress_spinner: Option<String>,
pub progress_bar: Option<String>,
pub progress_bar_unfilled: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileConfig {
#[serde(default)]
theme: FileThemeConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileThemeConfig {
heading: Option<String>,
accent: Option<String>,
muted: Option<String>,
label: Option<String>,
bullet: Option<String>,
success: Option<String>,
warning: Option<String>,
error: Option<String>,
progress_spinner: Option<String>,
progress_bar: Option<String>,
progress_bar_unfilled: Option<String>,
}
impl AppConfig {
pub fn load() -> LoadedConfig {
let system_path = Some(PathBuf::from("/etc/aim/config.toml"));
let user_path = env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".config/aim/config.toml"));
Self::load_from_paths(system_path.as_deref(), user_path.as_deref())
}
pub fn load_from_paths(system_path: Option<&Path>, user_path: Option<&Path>) -> LoadedConfig {
let mut loaded = LoadedConfig::default();
if let Some(path) = system_path {
merge_file(path, &mut loaded);
}
if let Some(path) = user_path {
merge_file(path, &mut loaded);
}
loaded
}
}
fn merge_file(path: &Path, loaded: &mut LoadedConfig) {
if !path.exists() {
return;
}
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) => {
loaded
.warnings
.push(format!("failed to read {}: {error}", path.display()));
return;
}
};
let parsed: FileConfig = match toml::from_str(&contents) {
Ok(parsed) => parsed,
Err(error) => {
loaded
.warnings
.push(format!("failed to parse {}: {error}", path.display()));
return;
}
};
merge_theme(&mut loaded.config.theme, parsed.theme);
}
fn merge_theme(theme: &mut ThemeConfig, update: FileThemeConfig) {
merge_option(&mut theme.heading, update.heading);
merge_option(&mut theme.accent, update.accent);
merge_option(&mut theme.muted, update.muted);
merge_option(&mut theme.label, update.label);
merge_option(&mut theme.bullet, update.bullet);
merge_option(&mut theme.success, update.success);
merge_option(&mut theme.warning, update.warning);
merge_option(&mut theme.error, update.error);
merge_option(&mut theme.progress_spinner, update.progress_spinner);
merge_option(&mut theme.progress_bar, update.progress_bar);
merge_option(
&mut theme.progress_bar_unfilled,
update.progress_bar_unfilled,
);
}
fn merge_option(target: &mut Option<String>, update: Option<String>) {
if let Some(value) = update {
*target = Some(value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn user_config_overrides_system_theme_values() {
let dir = tempdir().unwrap();
let system_path = dir.path().join("system-config.toml");
let user_path = dir.path().join("user-config.toml");
std::fs::write(
&system_path,
"[theme]\nheading = \"amber\"\naccent = \"teal\"\n",
)
.unwrap();
std::fs::write(&user_path, "[theme]\nheading = \"#d28b26\"\n").unwrap();
let loaded = AppConfig::load_from_paths(Some(&system_path), Some(&user_path));
assert_eq!(loaded.config.theme.heading.as_deref(), Some("#d28b26"));
assert_eq!(loaded.config.theme.accent.as_deref(), Some("teal"));
assert!(loaded.warnings.is_empty());
}
#[test]
fn invalid_config_is_ignored_without_aborting_load() {
let dir = tempdir().unwrap();
let system_path = dir.path().join("system-config.toml");
std::fs::write(&system_path, "[theme\nheading = \"amber\"\n").unwrap();
let loaded = AppConfig::load_from_paths(Some(&system_path), None);
assert_eq!(loaded.config.theme.heading, None);
assert!(!loaded.warnings.is_empty());
}
}

View file

@ -1 +1,2 @@
pub mod args;
pub mod config;

View file

@ -5,7 +5,8 @@ use std::env;
use std::path::{Path, PathBuf};
use aim_core::app::add::{
AddPlan, InstalledApp, build_add_plan, install_app_with_reporter, resolve_requested_scope,
AddPlan, InstalledApp, build_add_plan_with_reporter, install_app_with_reporter,
resolve_requested_scope,
};
use aim_core::app::list::{ListRow, build_list_rows};
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage, ProgressReporter};
@ -86,7 +87,8 @@ pub fn dispatch_with_reporter(
if let Some(query) = cli.query {
let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root());
let mut plan = build_add_plan(&query)?;
let transport = aim_core::source::github::default_transport();
let mut plan = build_add_plan_with_reporter(&query, transport.as_ref(), reporter)?;
if !plan.interactions.is_empty() {
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
Some(resolved) => {

View file

@ -1,10 +1,24 @@
fn main() {
let loaded_config = aim_cli::cli::config::AppConfig::load();
aim_cli::ui::theme::set_active_theme(aim_cli::ui::theme::resolve_theme(
&loaded_config.config.theme,
));
for warning in loaded_config.warnings {
eprintln!(
"{}",
aim_cli::ui::theme::warning_text(&format!("Config warning: {warning}"))
);
}
let cli = aim_cli::parse();
let mut reporter = aim_cli::ui::progress::TerminalProgressReporter::stderr();
match aim_cli::dispatch_with_reporter(cli, &mut reporter) {
Ok(result) => {
let output = aim_cli::render(&result);
if !output.is_empty() {
if reporter.emitted_output() {
println!();
}
println!("{output}");
}
}

View file

@ -16,8 +16,13 @@ pub fn spinner_style() -> ProgressStyle {
}
pub fn byte_style() -> ProgressStyle {
ProgressStyle::with_template("{bar:32.cyan/blue} {bytes}/{total_bytes} {msg}")
.expect("byte progress template is valid")
let theme = crate::ui::theme::current_theme();
let filled = crate::ui::theme::indicatif_color_key(&theme.progress_bar);
let unfilled = crate::ui::theme::indicatif_color_key(&theme.progress_bar_unfilled);
ProgressStyle::with_template(&format!(
"{{bar:32.{filled}/{unfilled}}} {{bytes}}/{{total_bytes}} {{msg}}"
))
.expect("byte progress template is valid")
}
pub fn operation_label(kind: OperationKind) -> &'static str {
@ -44,6 +49,27 @@ pub fn stage_label(stage: OperationStage) -> &'static str {
}
}
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit_index = 0_usize;
while value >= 1024.0 && unit_index < UNITS.len() - 1 {
value /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", bytes, UNITS[unit_index])
} else {
format!("{value:.1} {}", UNITS[unit_index])
}
}
pub fn format_completed_stage_line(token: &str) -> String {
format!("{} {token}", crate::ui::theme::success(""))
}
pub fn event_message(event: &OperationEvent) -> Option<String> {
match event {
OperationEvent::Started { kind, label } => {
@ -70,6 +96,9 @@ pub struct TerminalProgressReporter {
interactive: bool,
progress_bar: Option<ProgressBar>,
byte_total: Option<u64>,
current_stage: Option<OperationStage>,
last_progress_bytes: Option<u64>,
emitted_output: bool,
}
impl TerminalProgressReporter {
@ -78,9 +107,16 @@ impl TerminalProgressReporter {
interactive: std::io::stderr().is_terminal(),
progress_bar: None,
byte_total: None,
current_stage: None,
last_progress_bytes: None,
emitted_output: false,
}
}
pub fn emitted_output(&self) -> bool {
self.emitted_output
}
fn clear_progress(&mut self) {
if let Some(progress_bar) = self.progress_bar.take() {
progress_bar.finish_and_clear();
@ -88,23 +124,52 @@ impl TerminalProgressReporter {
self.byte_total = None;
}
fn emit_completed_stage_token(&mut self) {
let token = match self.current_stage {
Some(OperationStage::DownloadArtifact) => self
.last_progress_bytes
.map(|bytes| format!("{} Downloaded", format_bytes(bytes))),
Some(OperationStage::StagePayload) => Some("Payload Staged".to_owned()),
Some(OperationStage::WriteDesktopEntry) => Some("Desktop Entry Written".to_owned()),
Some(OperationStage::ExtractIcon) => Some("Icon Extracted".to_owned()),
Some(OperationStage::RefreshIntegration) => {
Some("Desktop Integration Refreshed".to_owned())
}
Some(OperationStage::SaveRegistry) => Some("Registry Saved".to_owned()),
_ => None,
};
if let Some(token) = token {
self.clear_progress();
self.emitted_output = true;
eprintln!("{}", format_completed_stage_line(&token));
}
}
fn show_spinner(&mut self, message: String) {
if !self.interactive {
eprintln!("{message}");
self.emitted_output = true;
eprintln!("{}", crate::ui::theme::accent(&message));
return;
}
if self.byte_total.is_some() {
self.clear_progress();
}
let progress_bar = self.progress_bar.get_or_insert_with(|| {
let progress_bar = new_progress_bar(None);
progress_bar.set_style(spinner_style());
progress_bar.enable_steady_tick(Duration::from_millis(100));
progress_bar
});
progress_bar.set_message(message);
progress_bar.set_message(crate::ui::theme::accent(&message));
self.byte_total = None;
}
fn show_progress(&mut self, current: u64, total: Option<u64>) {
self.last_progress_bytes = Some(current);
if !self.interactive {
return;
}
@ -136,7 +201,14 @@ impl Default for TerminalProgressReporter {
impl ProgressReporter for TerminalProgressReporter {
fn report(&mut self, event: &OperationEvent) {
match event {
OperationEvent::Started { .. } | OperationEvent::StageChanged { .. } => {
OperationEvent::Started { .. } => {
if let Some(message) = event_message(event) {
self.show_spinner(message);
}
}
OperationEvent::StageChanged { stage, .. } => {
self.emit_completed_stage_token();
self.current_stage = Some(*stage);
if let Some(message) = event_message(event) {
self.show_spinner(message);
}
@ -145,10 +217,74 @@ impl ProgressReporter for TerminalProgressReporter {
OperationEvent::Warning { .. } | OperationEvent::Failed { .. } => {
self.clear_progress();
if let Some(message) = event_message(event) {
eprintln!("{message}");
self.emitted_output = true;
let styled = match event {
OperationEvent::Warning { .. } => crate::ui::theme::warning_text(&message),
OperationEvent::Failed { .. } => crate::ui::theme::error_text(&message),
_ => message,
};
eprintln!("{styled}");
}
}
OperationEvent::Finished { .. } => self.clear_progress(),
OperationEvent::Finished { .. } => {
self.emit_completed_stage_token();
self.current_stage = None;
self.last_progress_bytes = None;
self.clear_progress();
}
}
}
}
#[cfg(test)]
mod tests {
use super::TerminalProgressReporter;
use crate::ui::progress::{ProgressReporter, format_completed_stage_line};
use aim_core::app::progress::{OperationEvent, OperationStage};
#[test]
fn stage_change_resets_byte_progress_position() {
let mut reporter = TerminalProgressReporter {
interactive: true,
progress_bar: None,
byte_total: None,
current_stage: None,
last_progress_bytes: None,
emitted_output: false,
};
reporter.report(&OperationEvent::Progress {
current: 98,
total: Some(100),
});
let byte_position = reporter
.progress_bar
.as_ref()
.expect("progress bar created")
.position();
assert_eq!(byte_position, 98);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "staging payload".to_owned(),
});
let stage_position = reporter
.progress_bar
.as_ref()
.expect("spinner bar retained")
.position();
assert_eq!(stage_position, 0);
}
#[test]
fn completed_stage_lines_use_checklist_format() {
let line = format_completed_stage_line("Payload Staged");
assert_eq!(
line,
format!("{} Payload Staged", crate::ui::theme::success(""))
);
}
}

View file

@ -39,14 +39,10 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
.collect::<Vec<_>>();
let mut lines = vec![
crate::ui::theme::heading("Installation Summary"),
format!(
"{} {} ({})",
crate::ui::theme::label("Application"),
added.record.display_name,
added.record.stable_id,
),
format!("{} {scope}", crate::ui::theme::label("Install scope")),
crate::ui::theme::heading(&format!(
"Installed {} ({scope})",
added.record.display_name
)),
format!(
"{} {} {}",
crate::ui::theme::label("Source"),
@ -54,13 +50,22 @@ fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
added.source.locator,
),
format!(
"{} {} [{}]",
crate::ui::theme::label("Selected artifact"),
"{} {}",
crate::ui::theme::label("Artifact"),
added.selected_artifact.url,
added.selected_artifact.selection_reason,
),
];
let installed_files = install_file_paths(added);
if !installed_files.is_empty() {
lines.push(crate::ui::theme::label("Installed files"));
lines.extend(
installed_files
.iter()
.map(|path| crate::ui::theme::bullet(path)),
);
}
lines.extend(warning_lines);
lines.join("\n")
}
@ -91,14 +96,65 @@ fn render_list(rows: &[aim_core::app::list::ListRow]) -> String {
return crate::ui::theme::muted("No installed apps yet");
}
let mut output = format!("{}\n", crate::ui::theme::heading("Installed Apps"));
let name_width = rows
.iter()
.map(|row| row.display_name.len())
.max()
.unwrap_or(0)
.max("Name".len());
let version_width = rows
.iter()
.map(|row| row.version.as_deref().unwrap_or("-").len())
.max()
.unwrap_or(0)
.max("Version".len());
let mut lines = vec![crate::ui::theme::heading("Installed Apps")];
lines.push(format_list_row(
"Name",
"Version",
"Source",
name_width,
version_width,
true,
));
for row in rows {
output.push_str(&format!(
"{}\n",
crate::ui::theme::bullet(&format!("{} ({})", row.display_name, row.stable_id))
lines.push(format_list_row(
&row.display_name,
row.version.as_deref().unwrap_or("-"),
&row.source,
name_width,
version_width,
false,
));
}
output.trim_end().to_owned()
lines.join("\n")
}
fn format_list_row(
name: &str,
version: &str,
source: &str,
name_width: usize,
version_width: usize,
is_header: bool,
) -> String {
let row = format!(
"{name:<name_width$} {version:<version_width$} {source}",
name = name,
version = version,
source = source,
name_width = name_width,
version_width = version_width,
);
if is_header {
crate::ui::theme::label(&row)
} else {
row
}
}
fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String {
@ -107,18 +163,50 @@ fn render_removed_app(removed: &aim_core::app::remove::RemovalResult) -> String
.iter()
.map(|warning| format!("Warning: {warning}"))
.collect::<Vec<_>>();
let mut lines = vec![
crate::ui::theme::heading("Removal Summary"),
format!(
"{} {}",
crate::ui::theme::label("Removed app"),
removed.removed.display_name,
),
];
let mut lines = vec![crate::ui::theme::heading(&format!(
"Removed {}",
removed.removed.display_name,
))];
if !removed.removed_paths.is_empty() {
lines.push(crate::ui::theme::label("Removed files"));
lines.extend(
removed
.removed_paths
.iter()
.map(|path| crate::ui::theme::bullet(path)),
);
}
lines.extend(warning_lines);
lines.join("\n")
}
fn install_file_paths(added: &aim_core::app::add::InstalledApp) -> Vec<String> {
[
Some(
added
.install_outcome
.final_payload_path
.display()
.to_string(),
),
added
.install_outcome
.desktop_entry_path
.as_ref()
.map(|path| path.display().to_string()),
added
.install_outcome
.icon_path
.as_ref()
.map(|path| path.display().to_string()),
]
.into_iter()
.flatten()
.collect()
}
fn render_updated_apps(result: &aim_core::domain::update::UpdateExecutionResult) -> String {
let mut lines = vec![
crate::ui::theme::heading("Update Summary"),

View file

@ -1,22 +1,329 @@
use console::style;
use std::sync::OnceLock;
use console::{Style, true_colors_enabled};
use dialoguer::theme::ColorfulTheme;
use crate::cli::config::ThemeConfig;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ColorValue {
Named(String),
Rgb(u8, u8, u8),
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct StyleSpec {
pub bold: bool,
pub dim: bool,
pub foreground: Option<ColorValue>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Theme {
pub heading: StyleSpec,
pub accent: StyleSpec,
pub muted: StyleSpec,
pub label: StyleSpec,
pub bullet: StyleSpec,
pub success: StyleSpec,
pub warning: StyleSpec,
pub error: StyleSpec,
pub progress_spinner: StyleSpec,
pub progress_bar: StyleSpec,
pub progress_bar_unfilled: StyleSpec,
}
static ACTIVE_THEME: OnceLock<Theme> = OnceLock::new();
impl Default for Theme {
fn default() -> Self {
Self {
heading: parse_style_spec("bold #7c3aed").expect("valid default heading style"),
accent: parse_style_spec("#8b5cf6").expect("valid default accent style"),
muted: parse_style_spec("dim #75658a").expect("valid default muted style"),
label: parse_style_spec("bold #c4b5fd").expect("valid default label style"),
bullet: StyleSpec::default(),
success: parse_style_spec("green").expect("valid default success style"),
warning: parse_style_spec("yellow").expect("valid default warning style"),
error: parse_style_spec("red").expect("valid default error style"),
progress_spinner: parse_style_spec("#8b5cf6").expect("valid default spinner style"),
progress_bar: parse_style_spec("#8b5cf6").expect("valid default bar style"),
progress_bar_unfilled: parse_style_spec("#75658a")
.expect("valid default unfilled bar style"),
}
}
}
pub fn resolve_theme(config: &ThemeConfig) -> Theme {
let mut theme = Theme::default();
override_spec(&mut theme.heading, config.heading.as_deref());
override_spec(&mut theme.accent, config.accent.as_deref());
override_spec(&mut theme.muted, config.muted.as_deref());
override_spec(&mut theme.label, config.label.as_deref());
override_spec(&mut theme.bullet, config.bullet.as_deref());
override_spec(&mut theme.success, config.success.as_deref());
override_spec(&mut theme.warning, config.warning.as_deref());
override_spec(&mut theme.error, config.error.as_deref());
override_spec(
&mut theme.progress_spinner,
config.progress_spinner.as_deref(),
);
override_spec(&mut theme.progress_bar, config.progress_bar.as_deref());
override_spec(
&mut theme.progress_bar_unfilled,
config.progress_bar_unfilled.as_deref(),
);
theme
}
pub fn set_active_theme(theme: Theme) {
let _ = ACTIVE_THEME.set(theme);
}
pub fn current_theme() -> Theme {
ACTIVE_THEME.get().cloned().unwrap_or_default()
}
pub fn dialog_theme() -> ColorfulTheme {
ColorfulTheme::default()
}
pub fn heading(title: &str) -> String {
style(title).bold().to_string()
apply_style_spec(title, &current_theme().heading)
}
pub fn label(title: &str) -> String {
style(format!("{title}:")).bold().to_string()
apply_style_spec(&format!("{title}:"), &current_theme().label)
}
pub fn muted(message: &str) -> String {
style(message).dim().to_string()
apply_style_spec(message, &current_theme().muted)
}
pub fn bullet(message: &str) -> String {
format!("- {message}")
}
pub fn accent(message: &str) -> String {
apply_style_spec(message, &current_theme().accent)
}
pub fn success(message: &str) -> String {
apply_style_spec(message, &current_theme().success)
}
pub fn warning_text(message: &str) -> String {
apply_style_spec(message, &current_theme().warning)
}
pub fn error_text(message: &str) -> String {
apply_style_spec(message, &current_theme().error)
}
pub fn indicatif_color_key(spec: &StyleSpec) -> &'static str {
match spec.foreground.as_ref() {
Some(ColorValue::Named(name)) => match name.as_str() {
"black" | "stone" => "black",
"red" => "red",
"green" => "green",
"yellow" | "amber" | "sand" => "yellow",
"blue" => "blue",
"magenta" => "magenta",
"cyan" | "teal" => "cyan",
"white" => "white",
_ => "white",
},
Some(ColorValue::Rgb(red, green, blue)) => nearest_indicatif_color(*red, *green, *blue),
None => "white",
}
}
pub fn parse_style_spec(input: &str) -> Result<StyleSpec, String> {
let mut spec = StyleSpec::default();
for token in input.split_whitespace() {
match token {
"bold" => spec.bold = true,
"dim" => spec.dim = true,
color => spec.foreground = Some(parse_color_value(color)?),
}
}
Ok(spec)
}
pub fn apply_style_spec(message: &str, spec: &StyleSpec) -> String {
let mut style = Style::new();
if spec.bold {
style = style.bold();
}
if spec.dim {
style = style.dim();
}
if let Some(color) = &spec.foreground {
style = apply_color(style, color);
}
style.apply_to(message).to_string()
}
fn override_spec(target: &mut StyleSpec, value: Option<&str>) {
if let Some(value) = value
&& let Ok(spec) = parse_style_spec(value)
{
*target = spec;
}
}
fn parse_color_value(token: &str) -> Result<ColorValue, String> {
if let Some(hex) = token.strip_prefix('#') {
if hex.len() != 6 {
return Err(format!("invalid hex color: {token}"));
}
let red = u8::from_str_radix(&hex[0..2], 16)
.map_err(|_| format!("invalid hex color: {token}"))?;
let green = u8::from_str_radix(&hex[2..4], 16)
.map_err(|_| format!("invalid hex color: {token}"))?;
let blue = u8::from_str_radix(&hex[4..6], 16)
.map_err(|_| format!("invalid hex color: {token}"))?;
return Ok(ColorValue::Rgb(red, green, blue));
}
if is_named_color(token) {
return Ok(ColorValue::Named(token.to_owned()));
}
Err(format!("unknown color token: {token}"))
}
fn is_named_color(token: &str) -> bool {
matches!(
token,
"black"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "white"
| "amber"
| "teal"
| "sand"
| "stone"
)
}
fn apply_color(style: Style, color: &ColorValue) -> Style {
match color {
ColorValue::Named(name) => apply_named_color(style, name),
ColorValue::Rgb(red, green, blue) => {
if true_colors_enabled() {
style.true_color(*red, *green, *blue)
} else {
style.color256(rgb_to_ansi256(*red, *green, *blue))
}
}
}
}
fn apply_named_color(style: Style, name: &str) -> Style {
match name {
"black" => style.black(),
"red" => style.red(),
"green" => style.green(),
"yellow" => style.yellow(),
"blue" => style.blue(),
"magenta" => style.magenta(),
"cyan" => style.cyan(),
"white" => style.white(),
"amber" => apply_color(style, &ColorValue::Rgb(210, 139, 38)),
"teal" => apply_color(style, &ColorValue::Rgb(47, 142, 138)),
"sand" => apply_color(style, &ColorValue::Rgb(231, 197, 138)),
"stone" => apply_color(style, &ColorValue::Rgb(111, 98, 83)),
_ => style,
}
}
fn rgb_to_ansi256(red: u8, green: u8, blue: u8) -> u8 {
let red = ((red as f32 / 255.0) * 5.0).round() as u8;
let green = ((green as f32 / 255.0) * 5.0).round() as u8;
let blue = ((blue as f32 / 255.0) * 5.0).round() as u8;
16 + (36 * red) + (6 * green) + blue
}
fn nearest_indicatif_color(red: u8, green: u8, blue: u8) -> &'static str {
const COLORS: [(&str, (u8, u8, u8)); 8] = [
("black", (0, 0, 0)),
("red", (205, 49, 49)),
("green", (13, 188, 121)),
("yellow", (229, 229, 16)),
("blue", (36, 114, 200)),
("magenta", (188, 63, 188)),
("cyan", (17, 168, 205)),
("white", (229, 229, 229)),
];
COLORS
.iter()
.min_by_key(|(_, (target_red, target_green, target_blue))| {
let red_distance = red as i32 - *target_red as i32;
let green_distance = green as i32 - *target_green as i32;
let blue_distance = blue as i32 - *target_blue as i32;
red_distance * red_distance
+ green_distance * green_distance
+ blue_distance * blue_distance
})
.map(|(name, _)| *name)
.unwrap_or("white")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_named_theme_value() {
let spec = parse_style_spec("amber").unwrap();
assert_eq!(spec.foreground, Some(ColorValue::Named("amber".to_owned())));
assert!(!spec.bold);
}
#[test]
fn parses_hex_theme_value() {
let spec = parse_style_spec("#d28b26").unwrap();
assert_eq!(spec.foreground, Some(ColorValue::Rgb(210, 139, 38)));
}
#[test]
fn parses_bold_hex_theme_value() {
let spec = parse_style_spec("bold #d28b26").unwrap();
assert!(spec.bold);
assert_eq!(spec.foreground, Some(ColorValue::Rgb(210, 139, 38)));
}
#[test]
fn invalid_override_falls_back_to_default_theme() {
let theme = resolve_theme(&ThemeConfig {
heading: Some("bogus".to_owned()),
..ThemeConfig::default()
});
assert_eq!(theme.heading, Theme::default().heading);
}
#[test]
fn default_theme_uses_purple_led_palette() {
let theme = Theme::default();
assert_eq!(theme.heading, parse_style_spec("bold #7c3aed").unwrap());
assert_eq!(theme.accent, parse_style_spec("#8b5cf6").unwrap());
assert_eq!(theme.label, parse_style_spec("bold #c4b5fd").unwrap());
assert_eq!(theme.progress_spinner, parse_style_spec("#8b5cf6").unwrap());
assert_eq!(theme.progress_bar, parse_style_spec("#8b5cf6").unwrap());
assert_eq!(
theme.progress_bar_unfilled,
parse_style_spec("#75658a").unwrap()
);
}
}