use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::BorderType;
use super::DisplayLabelKind;
use crate::app::ProgressMode;
use crate::model::MessageStatus;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Theme {
Dark,
Light,
Mono,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
Focused,
Covered,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub bg: Color,
pub fg: Color,
pub border: Color,
pub accent: Color,
pub heading: Color,
pub key: Color,
pub hot: Color,
pub muted: Color,
pub field_bg: Color,
pub select_bg: Color,
pub select_fg: Color,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Surface {
Colour(Palette),
Mono(Focus),
}
const MUTED_ON_TERMINAL: Color = Color::Rgb(136, 138, 133);
const BAR_BG: Color = Color::Rgb(211, 211, 211);
const BAR_FG: Color = Color::Rgb(105, 105, 105);
const ARCHIVED_FG: Color = Color::Rgb(0, 139, 139);
const LABEL_SPECIAL_BG: Color = Color::Rgb(64, 64, 64);
const LABEL_SPECIAL_FG: Color = Color::White;
const LABEL_DEFAULT_BG: Color = Color::Rgb(224, 224, 224);
const LABEL_DEFAULT_FG: Color = Color::Black;
const INK: Palette = Palette {
bg: Color::Black,
fg: Color::White,
border: Color::Rgb(211, 211, 211),
accent: Color::Cyan,
heading: Color::LightBlue,
key: Color::Yellow,
hot: Color::LightRed,
muted: Color::Rgb(136, 138, 133),
field_bg: Color::DarkGray,
select_bg: Color::Cyan,
select_fg: Color::Black,
};
const PAPER: Palette = Palette {
bg: Color::Rgb(211, 211, 211),
fg: Color::Black,
border: Color::Rgb(105, 105, 105),
accent: Color::Rgb(0, 92, 94),
heading: Color::Rgb(32, 74, 135),
key: Color::Rgb(143, 89, 2),
hot: Color::Rgb(164, 0, 0),
muted: Color::Rgb(85, 87, 83),
field_bg: Color::Rgb(186, 189, 182),
select_bg: Color::Rgb(0, 92, 94),
select_fg: Color::Rgb(238, 238, 236),
};
const SLATE: Palette = Palette {
bg: Color::Rgb(72, 72, 72),
fg: Color::Rgb(238, 238, 236),
border: Color::Rgb(136, 138, 133),
accent: Color::LightCyan,
heading: Color::LightBlue,
key: Color::LightYellow,
hot: Color::LightRed,
muted: Color::Rgb(186, 189, 182),
field_bg: Color::Rgb(95, 95, 95),
select_bg: Color::Cyan,
select_fg: Color::Black,
};
impl Theme {
pub const fn surface(self, focus: Focus) -> Surface {
match (self, focus) {
(Self::Light, Focus::Focused) => Surface::Colour(INK),
(Self::Light, Focus::Covered) => Surface::Colour(PAPER),
(Self::Dark, Focus::Focused) => Surface::Colour(PAPER),
(Self::Dark, Focus::Covered) => Surface::Colour(SLATE),
(Self::Mono, focus) => Surface::Mono(focus),
}
}
pub const fn focused(self) -> Surface {
self.surface(Focus::Focused)
}
pub const fn covered(self) -> Surface {
self.surface(Focus::Covered)
}
const fn is_mono(self) -> bool {
matches!(self, Self::Mono)
}
pub fn bar_style(self) -> Style {
if self.is_mono() {
Style::new().add_modifier(Modifier::REVERSED)
} else {
Style::new().bg(BAR_BG).fg(BAR_FG)
}
}
pub fn hint_style(self) -> Style {
if self.is_mono() {
Style::new().add_modifier(Modifier::DIM)
} else {
Style::new().fg(MUTED_ON_TERMINAL)
}
}
pub fn selection_style(self, active: bool) -> Style {
match (self.is_mono(), active) {
(true, true) => Style::new().add_modifier(Modifier::REVERSED),
(true, false) => Style::new().add_modifier(Modifier::REVERSED | Modifier::DIM),
(false, true) => Style::new().add_modifier(Modifier::REVERSED),
(false, false) => Style::new().fg(BAR_FG).bg(BAR_BG),
}
}
pub fn row_style(self, status: MessageStatus, starred: bool) -> Style {
let mono = self.is_mono();
let mut style = match status {
MessageStatus::New if mono => Style::new().add_modifier(Modifier::BOLD),
MessageStatus::New => Style::new().fg(Color::Red),
MessageStatus::Archived if mono => Style::new().add_modifier(Modifier::ITALIC),
MessageStatus::Archived => Style::new().fg(ARCHIVED_FG).add_modifier(Modifier::ITALIC),
MessageStatus::Deleted => {
Style::new().add_modifier(Modifier::CROSSED_OUT | Modifier::DIM)
}
MessageStatus::PendingInbox => Style::new().add_modifier(Modifier::DIM),
MessageStatus::Spam if mono => {
Style::new().add_modifier(Modifier::DIM | Modifier::ITALIC)
}
MessageStatus::Spam => Style::new().fg(Color::Magenta).add_modifier(Modifier::DIM),
MessageStatus::Read => Style::new(),
};
if starred {
style = style.add_modifier(Modifier::BOLD);
}
style
}
pub fn chip_style(self, kind: DisplayLabelKind, highlighted: bool) -> Style {
if highlighted {
return Style::new();
}
match (self.is_mono(), kind) {
(true, DisplayLabelKind::Special) => Style::new().add_modifier(Modifier::REVERSED),
(true, DisplayLabelKind::Normal) => Style::new().add_modifier(Modifier::DIM),
(false, DisplayLabelKind::Special) => {
Style::new().fg(LABEL_SPECIAL_FG).bg(LABEL_SPECIAL_BG)
}
(false, DisplayLabelKind::Normal) => {
Style::new().fg(LABEL_DEFAULT_FG).bg(LABEL_DEFAULT_BG)
}
}
}
pub fn indicator_style(self, mode: ProgressMode) -> Style {
match (self.is_mono(), mode) {
(true, ProgressMode::Write) => Style::new().add_modifier(Modifier::BOLD),
(true, ProgressMode::Read) => Style::new().add_modifier(Modifier::REVERSED),
(false, ProgressMode::Write) => Style::new().fg(Color::White).bg(Color::Red),
(false, ProgressMode::Read) => Style::new().fg(Color::Red).bg(BAR_BG),
}
}
pub fn indicator_fill_style(self, mode: ProgressMode) -> Style {
match (self.is_mono(), mode) {
(true, ProgressMode::Write) => Style::new(),
(true, ProgressMode::Read) => Style::new().add_modifier(Modifier::REVERSED),
(false, ProgressMode::Write) => Style::new().bg(Color::Red),
(false, ProgressMode::Read) => Style::new().bg(BAR_BG),
}
}
}
impl Surface {
pub fn style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().bg(palette.bg).fg(palette.fg),
Self::Mono(Focus::Focused) => Style::new(),
Self::Mono(Focus::Covered) => Style::new().add_modifier(Modifier::DIM),
}
}
pub fn border_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().fg(palette.border),
Self::Mono(_) => Style::new(),
}
}
pub fn border_type(self) -> BorderType {
match self {
Self::Mono(Focus::Focused) => BorderType::Thick,
Self::Colour(_) | Self::Mono(Focus::Covered) => BorderType::Plain,
}
}
pub fn text_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().fg(palette.fg),
Self::Mono(_) => Style::new(),
}
}
pub fn label_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().fg(palette.accent).add_modifier(Modifier::BOLD),
Self::Mono(_) => Style::new().add_modifier(Modifier::BOLD),
}
}
pub fn warning_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().fg(palette.key),
Self::Mono(_) => Style::new().add_modifier(Modifier::BOLD),
}
}
pub fn hot_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().fg(palette.hot).add_modifier(Modifier::BOLD),
Self::Mono(_) => Style::new().add_modifier(Modifier::BOLD),
}
}
pub fn muted_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().fg(palette.muted),
Self::Mono(_) => Style::new().add_modifier(Modifier::DIM),
}
}
pub fn select_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new()
.fg(palette.select_fg)
.bg(palette.select_bg)
.add_modifier(Modifier::BOLD),
Self::Mono(_) => Style::new().add_modifier(Modifier::REVERSED),
}
}
pub fn marked_style(self) -> Style {
match self {
Self::Colour(palette) => Style::new().bg(palette.field_bg),
Self::Mono(_) => Style::new().add_modifier(Modifier::REVERSED | Modifier::DIM),
}
}
pub fn field_style(self) -> Style {
match self {
Self::Colour(palette) => self.style().bg(palette.field_bg),
Self::Mono(_) => self.style().add_modifier(Modifier::UNDERLINED),
}
}
pub fn body_style(self) -> Style {
match self {
Self::Colour(palette) => self
.style()
.fg(palette.key)
.bg(palette.field_bg)
.add_modifier(Modifier::BOLD),
Self::Mono(_) => self.style().add_modifier(Modifier::BOLD),
}
}
pub fn title(self, name: &str) -> Line<'static> {
let style = match self {
Self::Colour(palette) => Style::new().fg(palette.heading),
Self::Mono(_) => Style::new(),
};
Line::styled(format!(" {name} "), style.add_modifier(Modifier::BOLD))
}
pub fn key_hints(self, hints: &[(&str, &str)]) -> Line<'static> {
let key_style = self.hot_style();
let separator_style = match self {
Self::Colour(_) => self.border_style(),
Self::Mono(_) => Style::new().add_modifier(Modifier::DIM),
};
let action_style = self.text_style();
let mut spans = Vec::with_capacity(hints.len() * 4);
for (key, action) in hints {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled((*key).to_string(), key_style));
spans.push(Span::styled(":", separator_style));
spans.push(Span::styled((*action).to_string(), action_style));
}
Line::from(spans)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn every_style(surface: Surface) -> Vec<(&'static str, Style)> {
vec![
("style", surface.style()),
("border", surface.border_style()),
("text", surface.text_style()),
("label", surface.label_style()),
("warning", surface.warning_style()),
("hot", surface.hot_style()),
("muted", surface.muted_style()),
("select", surface.select_style()),
("marked", surface.marked_style()),
("field", surface.field_style()),
("body", surface.body_style()),
("title", surface.title("Compose").spans[0].style),
(
"hint key",
surface.key_hints(&[("Esc", "Cancel")]).spans[0].style,
),
(
"hint separator",
surface.key_hints(&[("Esc", "Cancel")]).spans[1].style,
),
(
"hint action",
surface.key_hints(&[("Esc", "Cancel")]).spans[2].style,
),
]
}
fn every_chrome_style(theme: Theme) -> Vec<(&'static str, Style)> {
let mut styles = vec![
("bar", theme.bar_style()),
("hint", theme.hint_style()),
("selection", theme.selection_style(true)),
("inactive selection", theme.selection_style(false)),
(
"write indicator",
theme.indicator_style(ProgressMode::Write),
),
("read indicator", theme.indicator_style(ProgressMode::Read)),
(
"write indicator fill",
theme.indicator_fill_style(ProgressMode::Write),
),
(
"read indicator fill",
theme.indicator_fill_style(ProgressMode::Read),
),
(
"special label",
theme.chip_style(DisplayLabelKind::Special, false),
),
("label", theme.chip_style(DisplayLabelKind::Normal, false)),
];
for status in [
MessageStatus::New,
MessageStatus::Read,
MessageStatus::Archived,
MessageStatus::Deleted,
MessageStatus::PendingInbox,
MessageStatus::Spam,
] {
styles.push(("row", theme.row_style(status, false)));
styles.push(("starred row", theme.row_style(status, true)));
}
styles
}
#[test]
fn focus_is_visible_in_both_themes() {
for theme in [Theme::Dark, Theme::Light] {
assert_ne!(
theme.focused().style().bg,
theme.covered().style().bg,
"{theme:?}: a covered popup must not look like the focused one"
);
}
}
#[test]
fn the_focused_surface_opposes_the_terminal() {
assert_eq!(Theme::Light.focused(), Surface::Colour(INK));
assert_eq!(Theme::Dark.focused(), Surface::Colour(PAPER));
}
#[test]
fn no_colour_disappears_into_its_surface() {
for palette in [INK, PAPER, SLATE] {
for (role, colour) in [
("fg", palette.fg),
("border", palette.border),
("accent", palette.accent),
("heading", palette.heading),
("key", palette.key),
("hot", palette.hot),
("muted", palette.muted),
("field_bg", palette.field_bg),
("select_bg", palette.select_bg),
] {
assert_ne!(colour, palette.bg, "{role} vanishes into the background");
}
}
}
#[test]
fn the_monochrome_theme_names_no_colour() {
for focus in [Focus::Focused, Focus::Covered] {
for (role, style) in every_style(Theme::Mono.surface(focus)) {
assert_eq!(style.fg, None, "{focus:?} {role} names a foreground");
assert_eq!(style.bg, None, "{focus:?} {role} names a background");
}
}
for (role, style) in every_chrome_style(Theme::Mono) {
assert_eq!(style.fg, None, "{role} names a foreground");
assert_eq!(style.bg, None, "{role} names a background");
}
}
#[test]
fn focus_is_visible_without_colour() {
let focused = Theme::Mono.focused();
let covered = Theme::Mono.covered();
assert_ne!(focused.style(), covered.style());
assert_ne!(focused.border_type(), covered.border_type());
}
#[test]
fn message_states_stay_apart_without_colour() {
let states = [
MessageStatus::New,
MessageStatus::Read,
MessageStatus::Archived,
MessageStatus::Deleted,
MessageStatus::PendingInbox,
MessageStatus::Spam,
];
for (index, status) in states.iter().enumerate() {
for other in &states[index + 1..] {
assert_ne!(
Theme::Mono.row_style(*status, false),
Theme::Mono.row_style(*other, false),
"{status:?} and {other:?} are drawn the same way"
);
}
}
}
}