use std::sync::RwLock;
use console::{style, Emoji, Style};
use once_cell::sync::Lazy;
use textwrap::core::display_width;
use crate::prompt::{cursor::StringCursor, interaction::State};
const S_STEP_ACTIVE: Emoji = Emoji("◆", "*");
const S_STEP_CANCEL: Emoji = Emoji("■", "x");
const S_STEP_ERROR: Emoji = Emoji("▲", "x");
const S_STEP_SUBMIT: Emoji = Emoji("◇", "o");
const S_BAR_START: Emoji = Emoji("┌", "T");
const S_BAR: Emoji = Emoji("│", "|");
const S_BAR_END: Emoji = Emoji("└", "—");
const S_RADIO_ACTIVE: Emoji = Emoji("●", ">");
const S_RADIO_INACTIVE: Emoji = Emoji("○", " ");
const S_CHECKBOX_ACTIVE: Emoji = Emoji("◻", "[•]");
const S_CHECKBOX_SELECTED: Emoji = Emoji("◼", "[+]");
const S_CHECKBOX_INACTIVE: Emoji = Emoji("◻", "[ ]");
const S_PASSWORD_MASK: Emoji = Emoji("▪", "•");
const S_BAR_H: Emoji = Emoji("─", "-");
const S_CORNER_TOP_RIGHT: Emoji = Emoji("╮", "+");
const S_CONNECT_LEFT: Emoji = Emoji("├", "+");
const S_CORNER_BOTTOM_RIGHT: Emoji = Emoji("╯", "+");
const S_INFO: Emoji = Emoji("●", "•");
const S_WARN: Emoji = Emoji("▲", "!");
const S_ERROR: Emoji = Emoji("■", "x");
const S_SPINNER: Emoji = Emoji("◒◐◓◑", "•oO0");
const S_PROGRESS: Emoji = Emoji("■□", "#-");
pub fn termwrap(text: &str, padding: u16) -> String {
let width = console::Term::stderr().size().1;
text.lines()
.map(|line| textwrap::fill(line, width.saturating_sub(padding) as usize))
.collect::<Vec<_>>()
.join("\n")
}
pub enum ThemeState {
Active,
Cancel,
Submit,
Error(String),
}
impl<T> From<&State<T>> for ThemeState {
fn from(state: &State<T>) -> Self {
match state {
State::Active => Self::Active,
State::Cancel => Self::Cancel,
State::Submit(_) => Self::Submit,
State::Error(e) => Self::Error(e.clone()),
}
}
}
pub trait Theme {
fn bar_color(&self, state: &ThemeState) -> Style {
match state {
ThemeState::Active => Style::new().cyan(),
ThemeState::Cancel => Style::new().red(),
ThemeState::Submit => Style::new().bright().black(),
ThemeState::Error(_) => Style::new().yellow(),
}
}
fn state_symbol_color(&self, state: &ThemeState) -> Style {
match state {
ThemeState::Submit => Style::new().green(),
_ => self.bar_color(state),
}
}
fn state_symbol(&self, state: &ThemeState) -> String {
let color = self.state_symbol_color(state);
match state {
ThemeState::Active => color.apply_to(S_STEP_ACTIVE),
ThemeState::Cancel => color.apply_to(S_STEP_CANCEL),
ThemeState::Submit => color.apply_to(S_STEP_SUBMIT),
ThemeState::Error(_) => color.apply_to(S_STEP_ERROR),
}
.to_string()
}
fn radio_symbol(&self, state: &ThemeState, selected: bool) -> String {
match state {
ThemeState::Active if selected => style(S_RADIO_ACTIVE).green(),
ThemeState::Active if !selected => style(S_RADIO_INACTIVE).dim(),
_ => style(Emoji("", "")),
}
.to_string()
}
fn checkbox_symbol(&self, state: &ThemeState, selected: bool, active: bool) -> String {
match state {
ThemeState::Active | ThemeState::Error(_) => {
if selected {
style(S_CHECKBOX_SELECTED).green()
} else if active && !selected {
style(S_CHECKBOX_ACTIVE).cyan()
} else if !active && !selected {
style(S_CHECKBOX_INACTIVE).dim()
} else {
style(Emoji("", ""))
}
}
_ => style(Emoji("", "")),
}
.to_string()
}
fn remark_symbol(&self) -> String {
self.bar_color(&ThemeState::Submit)
.apply_to(S_CONNECT_LEFT)
.to_string()
}
fn info_symbol(&self) -> String {
style(S_INFO).blue().to_string()
}
fn warning_symbol(&self) -> String {
style(S_WARN).yellow().to_string()
}
fn error_symbol(&self) -> String {
style(S_ERROR).red().to_string()
}
fn active_symbol(&self) -> String {
style(S_STEP_ACTIVE).green().to_string()
}
fn submit_symbol(&self) -> String {
style(S_STEP_SUBMIT).green().to_string()
}
fn checkbox_style(&self, state: &ThemeState, selected: bool, active: bool) -> Style {
match state {
ThemeState::Cancel if selected => Style::new().dim().strikethrough(),
ThemeState::Submit if selected => Style::new().dim(),
_ if !active => Style::new().dim(),
_ => Style::new(),
}
}
fn input_style(&self, state: &ThemeState) -> Style {
match state {
ThemeState::Cancel => Style::new().dim().strikethrough(),
ThemeState::Submit => Style::new().dim(),
_ => Style::new(),
}
}
fn placeholder_style(&self, state: &ThemeState) -> Style {
match state {
ThemeState::Cancel => Style::new().hidden(),
_ => Style::new().dim(),
}
}
fn cursor_with_style(&self, cursor: &StringCursor, new_style: &Style) -> String {
let (left, cursor, right) = cursor.split();
format!(
"{left}{cursor}{right}",
left = new_style.apply_to(left),
cursor = style(cursor).reverse(),
right = new_style.apply_to(right),
)
}
fn password_mask(&self) -> char {
S_PASSWORD_MASK.to_string().chars().next().unwrap()
}
fn format_intro(&self, title: &str) -> String {
let color = self.bar_color(&ThemeState::Submit);
format!(
"{start_bar} {title}\n{bar}\n",
start_bar = color.apply_to(S_BAR_START),
bar = color.apply_to(S_BAR),
)
}
fn format_outro(&self, message: &str) -> String {
let color = self.bar_color(&ThemeState::Submit);
format!(
"{bar_end} {message}\n",
bar_end = color.apply_to(S_BAR_END)
)
}
fn format_outro_cancel(&self, message: &str) -> String {
let color = self.bar_color(&ThemeState::Submit);
format!(
"{bar} {message}\n",
bar = color.apply_to(S_BAR_END),
message = style(message).red()
)
}
fn format_header(&self, state: &ThemeState, prompt: &str) -> String {
let mut lines = vec![];
for (i, line) in prompt.lines().enumerate() {
if i == 0 {
lines.push(format!(
"{state_symbol} {line}\n",
state_symbol = self.state_symbol(state)
));
} else {
lines.push(format!(
"{bar} {line}\n",
bar = self.bar_color(state).apply_to(S_BAR)
));
}
}
lines.join("")
}
fn format_footer(&self, state: &ThemeState) -> String {
self.format_footer_with_message(state, "")
}
fn format_footer_with_message(&self, state: &ThemeState, message: &str) -> String {
format!(
"{}\n", self.bar_color(state).apply_to(match state {
ThemeState::Active => format!("{S_BAR_END} {message}"),
ThemeState::Cancel => format!("{S_BAR_END} Operation cancelled."),
ThemeState::Submit => format!("{S_BAR}"),
ThemeState::Error(err) => format!("{S_BAR_END} {err}"),
})
)
}
fn format_footer_for_autocomplete(&self, state: &ThemeState, message: &str) -> String {
self.format_footer_with_message(state, message)
}
fn format_input(&self, state: &ThemeState, cursor: &StringCursor) -> String {
let new_style = &self.input_style(state);
let input = &mut match state {
ThemeState::Active | ThemeState::Error(_) => self.cursor_with_style(cursor, new_style),
_ => cursor.to_string(),
};
if input.ends_with('\n') {
input.push('\n');
}
input.lines().fold(String::new(), |acc, line| {
format!(
"{}{} {}\n",
acc,
self.bar_color(state).apply_to(S_BAR),
new_style.apply_to(line)
)
})
}
fn format_placeholder(&self, state: &ThemeState, cursor: &StringCursor) -> String {
let new_style = &self.placeholder_style(state);
let placeholder = &match state {
ThemeState::Active | ThemeState::Error(_) => self.cursor_with_style(cursor, new_style),
ThemeState::Cancel => "".to_string(),
_ => cursor.to_string(),
};
placeholder.lines().fold(String::new(), |acc, line| {
format!(
"{}{} {}\n",
acc,
self.bar_color(state).apply_to(S_BAR),
new_style.apply_to(line)
)
})
}
fn radio_item(&self, state: &ThemeState, selected: bool, label: &str, hint: &str) -> String {
match state {
ThemeState::Cancel | ThemeState::Submit if !selected => return String::new(),
_ => {}
}
let radio = self.radio_symbol(state, selected);
let input_style = &self.input_style(state);
let inactive_style = &self.placeholder_style(state);
let label = if selected {
input_style.apply_to(label)
} else {
inactive_style.apply_to(label)
}
.to_string();
let hint = match state {
ThemeState::Active | ThemeState::Error(_) if !hint.is_empty() && selected => {
inactive_style.apply_to(format!("({})", hint)).to_string()
}
_ => String::new(),
};
format!(
"{radio}{space1}{label}{space2}{hint}",
space1 = if radio.is_empty() { "" } else { " " },
space2 = if label.is_empty() { "" } else { " " }
)
}
fn format_select_item(
&self,
state: &ThemeState,
selected: bool,
label: &str,
hint: &str,
) -> String {
match state {
ThemeState::Cancel | ThemeState::Submit if !selected => return String::new(),
_ => {}
}
format!(
"{bar} {radio_item}\n",
bar = self.bar_color(state).apply_to(S_BAR),
radio_item = self.radio_item(state, selected, label, hint)
)
}
fn checkbox_item(
&self,
state: &ThemeState,
selected: bool, active: bool, label: &str,
hint: &str,
) -> String {
match state {
ThemeState::Cancel | ThemeState::Submit if !selected => return String::new(),
_ => {}
}
let checkbox = self.checkbox_symbol(state, selected, active);
let label_style = self.checkbox_style(state, selected, active);
let hint_style = self.placeholder_style(state);
let label = label_style.apply_to(label).to_string();
let hint = match state {
ThemeState::Active | ThemeState::Error(_) if !hint.is_empty() && active => {
hint_style.apply_to(format!("({})", hint)).to_string()
}
_ => String::new(),
};
format!(
"{checkbox}{space1}{label}{space2}{hint}",
space1 = if checkbox.is_empty() { "" } else { " " },
space2 = if label.is_empty() { "" } else { " " }
)
}
fn format_multiselect_item(
&self,
state: &ThemeState,
selected: bool, active: bool, label: &str,
hint: &str,
) -> String {
match state {
ThemeState::Cancel | ThemeState::Submit if !selected => return String::new(),
_ => {}
}
format!(
"{bar} {checkbox_item}\n",
bar = self.bar_color(state).apply_to(S_BAR),
checkbox_item = self.checkbox_item(state, selected, active, label, hint),
)
}
fn simple_item(
&self,
state: &ThemeState,
active: bool, label: &str,
) -> String {
let label_style = self.checkbox_style(state, false, active);
label_style.apply_to(label).to_string()
}
fn format_autocomplete_item(&self, _state: &ThemeState, active: bool, label: &str) -> String {
format!(
"{bar} {item}\n",
bar = self.bar_color(&ThemeState::Submit).apply_to(S_BAR),
item = self.simple_item(&ThemeState::Submit, active, label)
)
}
fn format_confirm(&self, state: &ThemeState, confirm: bool) -> String {
let yes = self.radio_item(state, confirm, "Yes", "");
let no = self.radio_item(state, !confirm, "No", "");
let inactive_style = &self.placeholder_style(state);
let divider = match state {
ThemeState::Active => inactive_style.apply_to(" / ").to_string(),
_ => "".to_string(),
};
format!(
"{bar} {yes}{divider}{no}\n",
bar = self.bar_color(state).apply_to(S_BAR),
)
}
fn default_progress_template(&self) -> String {
"{msg} [{elapsed_precise}] {bar:30.magenta} ({pos}/{len})".into()
}
fn default_spinner_template(&self) -> String {
"{msg}".into()
}
fn default_download_template(&self) -> String {
"{msg} [{elapsed_precise}] [{bar:30.cyan/blue}] {bytes}/{total_bytes} ({eta})".into()
}
fn format_progress_message(&self, text: &str) -> String {
let bar = self.bar_color(&ThemeState::Submit).apply_to(S_BAR);
let end = self.bar_color(&ThemeState::Submit).apply_to(S_BAR_END);
let lines: Vec<_> = text.lines().collect();
let parts: Vec<String> = lines
.iter()
.enumerate()
.map(|(i, line)| match i {
0 => line.to_string(),
_ if i < lines.len() - 1 => format!("{bar} {line}"),
_ => format!("{end} {line}"),
})
.collect();
parts.join("\n")
}
fn format_progress_start(&self, template: &str, grouped: bool, last: bool) -> String {
let space = if grouped { " " } else { " " };
self.format_progress_with_state(
&format!("{{spinner:.magenta}}{space}{template}"),
grouped,
last,
&ThemeState::Active,
)
}
fn format_progress_with_state(
&self,
msg: &str,
grouped: bool,
last: bool,
state: &ThemeState,
) -> String {
let prefix = if grouped {
self.bar_color(state).apply_to(S_BAR).to_string() + " "
} else {
match state {
ThemeState::Active => "".to_string(),
_ => self.state_symbol(state).to_string() + " ",
}
};
let suffix = if grouped && last {
format!("\n{}", self.format_footer(state)) } else if grouped && !last {
"".to_string() } else {
match state {
ThemeState::Active => "".to_string(), _ => format!("\n{}", self.bar_color(&ThemeState::Submit).apply_to(S_BAR)), }
};
if !msg.is_empty() {
format!("{prefix}{msg}{suffix}")
} else {
suffix
}
}
fn spinner_chars(&self) -> String {
S_SPINNER.to_string()
}
fn progress_chars(&self) -> String {
S_PROGRESS.to_string()
}
fn format_note_with_symbol(
&self,
is_outro: bool,
symbol: &str,
prompt: &str,
message: &str,
) -> String {
let prompt = termwrap(prompt, 7);
let message = termwrap(message, 6);
let bar_color = self.bar_color(&ThemeState::Submit);
let text_color = self.input_style(&ThemeState::Submit);
let symbol = if is_outro {
bar_color.apply_to(S_CONNECT_LEFT).to_string()
} else {
symbol.to_string()
};
let prompt_lines: Vec<&str> = prompt.lines().collect();
let message = if prompt.is_empty() {
message.to_string() + "\n"
} else {
format!("\n{message}\n")
};
let last_header_line = prompt_lines
.last()
.map(|l| format!(" {l} "))
.unwrap_or_default();
let width = message
.split('\n')
.fold(0usize, |acc, line| (display_width(line) + 2).max(acc))
.max(display_width(&last_header_line));
let mut header = prompt_lines
.iter()
.take(prompt_lines.len().saturating_sub(1))
.enumerate()
.map(|(i, line)| {
if i == 0 {
format!("{symbol} {line}\n")
} else {
format!("{bar} {line}\n", bar = bar_color.apply_to(S_BAR))
}
})
.collect::<String>();
let left_symbol = if prompt_lines.len() > 1 {
bar_color.apply_to(S_BAR).to_string()
} else {
symbol.clone()
};
header.push_str(&format!(
"{left_symbol}{last_header_line}{horizontal_bar}{corner}\n",
horizontal_bar = bar_color.apply_to(
S_BAR_H
.to_string()
.repeat(2 + width - display_width(&last_header_line))
),
corner = bar_color.apply_to(S_CORNER_TOP_RIGHT),
));
#[allow(clippy::format_collect)]
let body = message
.lines()
.map(|line| {
format!(
"{bar} {line}{spaces}{bar}\n",
bar = bar_color.apply_to(S_BAR),
line = text_color.apply_to(line),
spaces = " ".repeat(width - display_width(line))
)
})
.collect::<String>();
let footer = if is_outro {
bar_color.apply_to(format!(
"{S_BAR_END}{horizontal_bar}{S_CORNER_BOTTOM_RIGHT}\n",
horizontal_bar = S_BAR_H.to_string().repeat(width + 2),
))
} else {
bar_color.apply_to(format!(
"{S_CONNECT_LEFT}{horizontal_bar}{S_CORNER_BOTTOM_RIGHT}\n{bar}\n",
horizontal_bar = S_BAR_H.to_string().repeat(width + 2),
bar = bar_color.apply_to(S_BAR),
))
}
.to_string();
header + &body + &footer
}
fn format_note_generic(&self, is_outro: bool, prompt: &str, message: &str) -> String {
let symbol = if prompt.is_empty() {
self.remark_symbol()
} else {
self.state_symbol(&ThemeState::Submit)
};
self.format_note_with_symbol(is_outro, &symbol, prompt, message)
}
fn format_note(&self, prompt: &str, message: &str) -> String {
self.format_note_generic(false, prompt, message)
}
fn format_outro_note(&self, prompt: &str, message: &str) -> String {
self.format_note_generic(true, prompt, message)
}
fn format_log(&self, text: &str, symbol: &str) -> String {
self.format_log_with_spacing(text, symbol, true)
}
fn format_log_with_spacing(&self, text: &str, symbol: &str, spacing: bool) -> String {
let mut parts = vec![];
let chain = match spacing {
true => "\n",
false => "",
};
let mut lines = text.lines().chain(chain.lines());
if let Some(first) = lines.next() {
parts.push(format!("{symbol} {first}"));
}
for line in lines {
parts.push(format!(
"{bar} {line}",
bar = self.bar_color(&ThemeState::Submit).apply_to(S_BAR)
));
}
parts.push("".into());
parts.join("\n")
}
}
struct ClackTheme;
impl Theme for ClackTheme {}
pub(crate) static THEME: Lazy<RwLock<Box<dyn Theme + Send + Sync>>> =
Lazy::new(|| RwLock::new(Box::new(ClackTheme)));
pub fn set_theme<T: Theme + Sync + Send + 'static>(theme: T) {
*THEME.write().unwrap() = Box::new(theme);
}
pub fn reset_theme() {
*THEME.write().unwrap() = Box::new(ClackTheme);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_note() {
ClackTheme.format_note("my prompt", "my message");
}
}