diff --git a/src/app.rs b/src/app.rs index 142cd4c..9a701c5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use crate::output::Logger; use crate::state::StateStore; use crate::theme::{self, Theme}; use anyhow::{anyhow, Context, Result}; +use std::cell::Cell; use std::io::{IsTerminal, Write}; use std::path::PathBuf; @@ -20,8 +21,9 @@ pub struct App { pub catalog: Catalog, pub state: StateStore, pub log: Logger, - /// Active output color theme. - pub theme: &'static Theme, + /// Active output color theme (switchable at runtime with the REPL + /// 'theme ' command). + theme: Cell<&'static Theme>, } #[derive(Debug, Clone)] @@ -90,7 +92,7 @@ impl App { catalog, state, log, - theme, + theme: Cell::new(theme), }) } @@ -111,7 +113,12 @@ impl App { } pub fn theme(&self) -> &'static Theme { - self.theme + self.theme.get() + } + + /// Switch the active theme (used by the REPL 'theme ' command). + pub fn set_theme(&self, theme: &'static Theme) { + self.theme.set(theme); } /// Ask the user for confirmation on stderr. Accepts y/yes/o/oui. diff --git a/src/help.rs b/src/help.rs index 5e9cb22..cffe5a4 100644 --- a/src/help.rs +++ b/src/help.rs @@ -508,6 +508,23 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "Show version and build information.", code: "version" }, ], }, + HelpSpec { + name: "theme", + category: "Shell", + usage: "theme {flags} [name]", + about: "Show the color themes, or switch the active one inside the shell.", + search_terms: &["color", "colors", "palette"], + flags: &[], + subcommands: &[], + parameters: &[ + HelpParam { name: "name", typ: "string", desc: "Theme to switch to (default, ocean, sunset, forest, dracula, mono); omit to list them" }, + ], + io: None, + examples: &[ + HelpExample { desc: "List the available themes.", code: "theme" }, + HelpExample { desc: "Switch to the ocean theme.", code: "theme ocean" }, + ], + }, HelpSpec { name: "ls", category: "Shell", @@ -751,8 +768,15 @@ fn io_table(input: &str, output: &str, theme: &Theme, color: bool) -> String { line } }; + let bar = |s: &str| -> String { + if color { + theme.frame(s) + } else { + s.to_string() + } + }; let row = |cells: &[&str], bold: bool| -> String { - let mut line = String::from("│"); + let mut line = bar("│"); for (i, c) in cells.iter().enumerate() { line.push(' '); let text = if bold { @@ -767,7 +791,7 @@ fn io_table(input: &str, output: &str, theme: &Theme, color: bool) -> String { line.push_str(&text); line.push_str(&" ".repeat(widths[i] - c.chars().count())); line.push(' '); - line.push('│'); + line.push_str(&bar("│")); } line }; diff --git a/src/output.rs b/src/output.rs index b6d7078..5f514ff 100644 --- a/src/output.rs +++ b/src/output.rs @@ -213,6 +213,11 @@ pub fn kv_table( s.to_string() } }; + let bar = if color { + theme.frame("│") + } else { + "│".to_string() + }; let mut out = String::new(); out.push_str(&border('╭', '┬', '╮')); out.push('\n'); @@ -220,7 +225,7 @@ pub fn kv_table( let key = paint(k, color, crate::theme::Theme::key); let val = paint(v, color, crate::theme::Theme::val); out.push_str(&format!( - "│ {} │ {} │\n", + "{bar} {} {bar} {} {bar}\n", pad(key.as_str(), key_w), pad(val.as_str(), val_w) )); diff --git a/src/repl.rs b/src/repl.rs index 950500b..4621905 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -50,8 +50,9 @@ pub struct AmCompleter { /// Short descriptions shown next to candidates in the Tab menu /// (Nushell-style). descriptions: BTreeMap, - /// Active color theme (used by the boxed menu highlight). - theme: &'static crate::theme::Theme, + /// Active color theme (used by the boxed menu highlight; follows the + /// app theme when it is switched with the REPL 'theme ' command). + theme: std::cell::Cell<&'static crate::theme::Theme>, } /// One-line descriptions for the Tab completion menu. @@ -75,6 +76,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("export", "backup config + state"), ("import", "restore a backup"), ("shell", "show or switch the system shell"), + ("theme", "show or switch the color theme"), ("ls", "list files and directories (Nushell-style)"), ("dir", "list files and directories (same as ls)"), ("cd", "change directory — Tab completes folders"), @@ -88,12 +90,17 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ impl AmCompleter { pub fn new(app: &App) -> Self { - let mut completer = + let completer = Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups); - completer.theme = app.theme(); + completer.theme.set(app.theme()); completer } + /// Point the menu at a new theme (after 'theme ' in the REPL). + pub fn set_theme(&self, theme: &'static crate::theme::Theme) { + self.theme.set(theme); + } + pub fn from_catalog( catalog: &crate::catalog::Catalog, aliases: &BTreeMap, @@ -126,12 +133,12 @@ impl AmCompleter { descriptions.insert(a.name.clone(), truncate_desc(&desc)); } Self { - theme: crate::theme::default_theme(), + theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ "list", "status", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", - "self-update", "self-uninstall", "export", "import", "shell", "ls", "dir", - "cd", "ps", "where", "get", "help", "version", "exit", + "self-update", "self-uninstall", "export", "import", "shell", "theme", + "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", ], config_sub: vec!["show", "path", "edit", "validate", "add"], shells: vec!["bash", "zsh", "fish", "powershell", "elvish"], @@ -178,6 +185,12 @@ impl AmCompleter { "shell" => { pool = self.gateway_shells.iter().map(|s| s.to_string()).collect(); } + "theme" => { + pool = crate::theme::names() + .iter() + .map(|s| s.to_string()) + .collect(); + } "cd" | "chdir" => { // Complete directories only — dive one level per Tab. let cwd = current_dir(); @@ -340,18 +353,21 @@ impl Highlighter for AmCompleter { use std::borrow::Cow; let _ = completion; match candidate.chars().next() { - Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.frame(candidate)), + Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.get().frame(candidate)), Some('│') => { let cells: Vec<&str> = candidate.split('│').collect(); if cells.len() >= 4 { if cells[1].trim() == "name" && cells[2].trim() == "description" { - Cow::Owned(self.theme.hdr(candidate)) + Cow::Owned(self.theme.get().hdr(candidate)) } else { + let bar = self.theme.get().frame("│"); Cow::Owned(format!( - "│{}│{}│{}", - self.theme.acc(cells[1]), + "{}{}{}{}{}", + bar, + self.theme.get().acc(cells[1]), + bar, cells[2], - cells[3] + bar )) } } else { @@ -459,8 +475,6 @@ pub fn banner_box( ) -> String { let w = BOX_WIDTH; let mut rows: Vec = Vec::new(); - let inner = |content: String| format!("│ {} │", pad_visible(content, w)); - let blank = || inner(" ".to_string()); let frame = |s: &str| -> String { if color { theme.frame(s) @@ -468,6 +482,15 @@ pub fn banner_box( s.to_string() } }; + let inner = |content: String| { + format!( + "{} {} {}", + frame("│"), + pad_visible(content, w), + frame("│") + ) + }; + let blank = || inner(" ".to_string()); // Top border with the centered header (title in the theme header color, // border in the theme frame color). @@ -574,7 +597,7 @@ pub fn banner_box( .to_string(), )); rows.push(inner( - " shell ls · cd · ps · where · get · shell · help · version · exit".to_string(), + " shell ls · cd · ps · where · get · shell · theme · help · version · exit".to_string(), )); // Stats footer. @@ -894,6 +917,11 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result { let t = Instant::now(); let result = handle_input(app, session, &mut last, &trimmed); last_duration = Some(t.elapsed()); + // 'theme ' may have switched the app theme: repoint the + // completion menu at the new palette. + if let Some(h) = rl.helper_mut() { + h.set_theme(app.theme()); + } match result { Ok(true) => break, Ok(false) => {} @@ -958,6 +986,10 @@ fn handle_input( crate::help::print_version(app); return Ok(false); } + "theme" | "/theme" => { + theme_command(app, None); + return Ok(false); + } _ => {} } // Slash commands (Hermes-style). @@ -968,6 +1000,7 @@ fn handle_input( }; match cmd { "shell" => shell_command(session, arg), + "theme" => theme_command(app, arg), "help" => match arg { Some(topic) => { if let Err(e) = crate::help::print_command(app, topic) { @@ -1040,6 +1073,11 @@ fn handle_line( crate::help::print_version(app); return Ok(false); } + // theme | theme : list the themes or switch the active one. + if cmd == "theme" { + theme_command(app, rest.first().map(|s| s.as_str())); + return Ok(false); + } // Session directory change: kept in the am process, not a child shell. if matches!(cmd, "cd" | "chdir") { @@ -1205,7 +1243,7 @@ fn handle_line( fn is_shell_command(word: &str) -> bool { matches!( word, - "ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "help" | "version" | "exit" + "ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "theme" | "help" | "version" | "exit" ) } @@ -1368,6 +1406,59 @@ fn shell_command(session: &mut ShellSession, arg: Option<&str>) { } } +/// 'theme' in the REPL: list the themes, or switch with 'theme '. +fn theme_command(app: &App, arg: Option<&str>) { + match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => print_theme_status(app), + Some(name) => match crate::theme::find(name) { + Some(t) => { + app.set_theme(t); + let color = app.color(); + println!( + "{} {}", + if color { t.hdr("theme:") } else { "theme:".to_string() }, + format!("{} — {}", t.acc(t.name), t.title) + ); + } + None => app.log.error(format!( + "unknown theme '{name}' — available: {}", + crate::theme::names().join(", ") + )), + }, + } +} + +/// List the available themes, highlighting the active one. +fn print_theme_status(app: &App) { + let theme = app.theme(); + let color = app.color(); + let hdr = |s: &str| -> String { + if color { + theme.hdr(s) + } else { + s.to_string() + } + }; + println!("{}", hdr("Theme")); + println!( + " ◉ {} — {}", + theme.acc(theme.name), + theme.title + ); + let others: Vec<&str> = crate::theme::THEMES + .iter() + .map(|t| t.name) + .filter(|n| *n != theme.name) + .collect(); + println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · ")))); + println!( + " {}", + theme.dimmed( + "'theme ' to switch · '--theme ' or settings.theme to change the default" + ) + ); +} + fn print_shell_status(session: &ShellSession) { let others: Vec<&str> = session .available diff --git a/src/tables.rs b/src/tables.rs index 4a26c38..efc701b 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -153,8 +153,17 @@ impl DataTable { line.push(right); line }; + let bar = |s: &str| -> String { + if !color { + return s.to_string(); + } + match theme { + Some(t) => t.frame(s), + None => s.to_string(), + } + }; let row_line = |row: &[String], header: bool| -> String { - let mut line = String::from("│"); + let mut line = bar("│"); for (i, c) in row.iter().enumerate().take(all_widths.len()) { line.push(' '); let cell = fit_cell(c, all_widths[i]); @@ -168,7 +177,7 @@ impl DataTable { }; line.push_str(&rendered); line.push(' '); - line.push('│'); + line.push_str(&bar("│")); } line };