Tests now assert `Starting` after create and use `wait_for_ready` before checking post-handshake state. Unsupported command test expects `BadRequest` instead of a persisted Dead session. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1100 lines
45 KiB
Rust
1100 lines
45 KiB
Rust
//! Named colour roles for the TUI.
|
|
//!
|
|
//! Every colour the TUI paints on its own chrome comes from here, named for
|
|
//! what it means rather than what it looks like: the card surface, the
|
|
//! selection surface, the panel border in its focused and unfocused forms,
|
|
//! the text weights (`fg` / `muted` / `dim`), and one role per
|
|
//! [`ActivityState`]. Call sites ask the palette for a role; they never spell
|
|
//! a colour out.
|
|
//!
|
|
//! Values are [`Color::Rgb`], so contrast is a property of the palette rather
|
|
//! than of the user's terminal theme — and can therefore be asserted (see
|
|
//! [`contrast_ratio`] and this module's tests).
|
|
//!
|
|
//! Each surface carries its own activity colours. A state colour picked for
|
|
//! the dark card background is not legible on the selection background, so
|
|
//! the selected card uses the selection surface's own state colours; the
|
|
//! rail keeps carrying the state either way (docs/SIDEBAR.md).
|
|
//!
|
|
//! [`Palette::DARK`] — graphite, no accent hue — is the default.
|
|
//! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses
|
|
//! terminal-provided ANSI colours. Five house presets recolour the graphite
|
|
//! base with an accent (Ice Cyan, Periwinkle, Amber Phosphor, Ember, Orchid);
|
|
//! the remaining presets ([`Palette::presets`]) adapt well-known editor
|
|
//! themes — Solarized, Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord,
|
|
//! Dracula — with individual colours lifted or darkened where the originals
|
|
//! fall short of the contrast floors asserted below.
|
|
//!
|
|
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
|
//! pane and pass through `pane::translate_color` verbatim.
|
|
|
|
use maestro_protocol::ActivityState;
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
/// A background and the foreground roles legible on it.
|
|
///
|
|
/// The sidebar has two: the card surface every card sits on, and the
|
|
/// selection surface the highlighted card swaps in.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Surface {
|
|
/// The surface itself.
|
|
pub bg: Color,
|
|
/// Primary text on this surface.
|
|
pub fg: Color,
|
|
/// Secondary text — the blocked-work, exited, detail and command
|
|
/// fragments of a card's meta line.
|
|
pub muted: Color,
|
|
/// The quietest legible text on this surface, for decoration that must
|
|
/// not compete with content.
|
|
pub dim: Color,
|
|
/// `ActivityState::Active`.
|
|
pub active: Color,
|
|
/// `ActivityState::Idle`.
|
|
pub idle: Color,
|
|
/// `ActivityState::AwaitingInput`.
|
|
pub awaiting: Color,
|
|
/// `ActivityState::Error`.
|
|
pub error: Color,
|
|
}
|
|
|
|
impl Surface {
|
|
/// The bare surface: a background with no foreground override.
|
|
pub fn base(&self) -> Style {
|
|
Style::default().bg(self.bg)
|
|
}
|
|
|
|
/// Primary text on this surface.
|
|
pub fn text_style(&self) -> Style {
|
|
Style::default().bg(self.bg).fg(self.fg)
|
|
}
|
|
|
|
/// Secondary text on this surface.
|
|
pub fn muted_style(&self) -> Style {
|
|
Style::default().bg(self.bg).fg(self.muted)
|
|
}
|
|
|
|
/// The quietest text on this surface.
|
|
pub fn dim_style(&self) -> Style {
|
|
Style::default().bg(self.bg).fg(self.dim)
|
|
}
|
|
|
|
/// The colour carrying `state` on this surface.
|
|
pub fn activity(&self, state: ActivityState) -> Color {
|
|
match state {
|
|
ActivityState::Active => self.active,
|
|
ActivityState::Idle | ActivityState::Starting => self.idle,
|
|
ActivityState::AwaitingInput => self.awaiting,
|
|
ActivityState::Error => self.error,
|
|
}
|
|
}
|
|
|
|
/// Every foreground role on this surface, paired with its role name.
|
|
/// Used by the contrast tests, and by anything that needs to reason about
|
|
/// the surface as a whole.
|
|
pub fn roles(&self) -> [(&'static str, Color); 7] {
|
|
[
|
|
("fg", self.fg),
|
|
("muted", self.muted),
|
|
("dim", self.dim),
|
|
("active", self.active),
|
|
("idle", self.idle),
|
|
("awaiting", self.awaiting),
|
|
("error", self.error),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// The full set of colour roles the TUI paints with.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Palette {
|
|
/// The surface an unselected card sits on.
|
|
pub card: Surface,
|
|
/// The surface the highlighted card swaps in.
|
|
pub selection: Surface,
|
|
/// Background for TUI-owned chrome outside cards. Fixed-RGB chrome must
|
|
/// paint this background too; otherwise its contrast depends on the
|
|
/// operator's terminal theme.
|
|
pub chrome_bg: Color,
|
|
/// Subtle full-panel background while the sidebar owns focus.
|
|
pub sidebar_focus_bg: Color,
|
|
/// Panel border while that panel holds focus.
|
|
pub border_focused: Color,
|
|
/// Panel border while it does not.
|
|
pub border_unfocused: Color,
|
|
/// Focus affordances that are not the border itself — markers, titles and
|
|
/// the like on the panel that holds focus.
|
|
pub focus_accent: Color,
|
|
/// Sidebar group headings.
|
|
pub group_row: Color,
|
|
/// Status-bar mode indicator, normal mode.
|
|
pub mode_normal_bg: Color,
|
|
pub mode_normal_fg: Color,
|
|
/// Status-bar mode indicator, insert mode.
|
|
pub mode_insert_bg: Color,
|
|
pub mode_insert_fg: Color,
|
|
/// Error text outside a card — form errors, failed actions.
|
|
pub error_text: Color,
|
|
}
|
|
|
|
impl Palette {
|
|
/// The graphite dark palette and the default preset: pure near-neutral
|
|
/// chrome with an ink-coloured accent, so activity colours are the only
|
|
/// hue on screen.
|
|
pub const DARK: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x1d, 0x1f, 0x22),
|
|
fg: Color::Rgb(0xe6, 0xe8, 0xeb),
|
|
muted: Color::Rgb(0xb1, 0xb4, 0xb9),
|
|
dim: Color::Rgb(0x86, 0x89, 0x8e),
|
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
|
idle: Color::Rgb(0xb1, 0xb4, 0xb9),
|
|
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x3f, 0x43, 0x48),
|
|
fg: Color::Rgb(0xf0, 0xf2, 0xf5),
|
|
muted: Color::Rgb(0xce, 0xd1, 0xd5),
|
|
dim: Color::Rgb(0xa5, 0xa8, 0xab),
|
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
|
idle: Color::Rgb(0xd5, 0xd8, 0xdb),
|
|
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
|
},
|
|
chrome_bg: Color::Rgb(0x1d, 0x1f, 0x22),
|
|
sidebar_focus_bg: Color::Rgb(0x28, 0x2a, 0x2d),
|
|
border_focused: Color::Rgb(0xe2, 0xe5, 0xe8),
|
|
border_unfocused: Color::Rgb(0x72, 0x75, 0x79),
|
|
focus_accent: Color::Rgb(0xe2, 0xe5, 0xe8),
|
|
group_row: Color::Rgb(0xba, 0xbe, 0xc3),
|
|
mode_normal_bg: Color::Rgb(0xe2, 0xe5, 0xe8),
|
|
mode_normal_fg: Color::Rgb(0x17, 0x1b, 0x20),
|
|
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
mode_insert_fg: Color::Rgb(0x1d, 0x1f, 0x22),
|
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
|
};
|
|
|
|
/// A high-contrast light peer to [`Self::DARK`]: ink-on-paper graphite.
|
|
pub const LIGHT: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0xff, 0xff, 0xff),
|
|
fg: Color::Rgb(0x20, 0x24, 0x2a),
|
|
muted: Color::Rgb(0x56, 0x5b, 0x63),
|
|
dim: Color::Rgb(0x7b, 0x81, 0x89),
|
|
active: Color::Rgb(0x21, 0x7a, 0x3c),
|
|
idle: Color::Rgb(0x56, 0x5b, 0x63),
|
|
awaiting: Color::Rgb(0x8f, 0x63, 0x00),
|
|
error: Color::Rgb(0xb4, 0x23, 0x18),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0xc5, 0xc7, 0xca),
|
|
fg: Color::Rgb(0x15, 0x19, 0x1e),
|
|
muted: Color::Rgb(0x43, 0x48, 0x4f),
|
|
dim: Color::Rgb(0x5e, 0x64, 0x6b),
|
|
active: Color::Rgb(0x15, 0x5b, 0x2e),
|
|
idle: Color::Rgb(0x38, 0x3e, 0x45),
|
|
awaiting: Color::Rgb(0x69, 0x46, 0x00),
|
|
error: Color::Rgb(0x99, 0x1a, 0x12),
|
|
},
|
|
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
|
sidebar_focus_bg: Color::Rgb(0xef, 0xf0, 0xf2),
|
|
border_focused: Color::Rgb(0x36, 0x3b, 0x41),
|
|
border_unfocused: Color::Rgb(0x76, 0x7b, 0x82),
|
|
focus_accent: Color::Rgb(0x36, 0x3b, 0x41),
|
|
group_row: Color::Rgb(0x4e, 0x53, 0x5a),
|
|
mode_normal_bg: Color::Rgb(0x23, 0x27, 0x2c),
|
|
mode_normal_fg: Color::Rgb(0xf9, 0xfa, 0xfb),
|
|
mode_insert_bg: Color::Rgb(0x8f, 0x63, 0x00),
|
|
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
|
|
error_text: Color::Rgb(0xb4, 0x23, 0x18),
|
|
};
|
|
|
|
/// A palette of named ANSI colours for terminals without truecolour.
|
|
pub const ANSI: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Black,
|
|
fg: Color::White,
|
|
muted: Color::Gray,
|
|
dim: Color::DarkGray,
|
|
active: Color::Green,
|
|
idle: Color::Gray,
|
|
awaiting: Color::Yellow,
|
|
error: Color::Red,
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Blue,
|
|
fg: Color::White,
|
|
muted: Color::White,
|
|
dim: Color::LightBlue,
|
|
active: Color::LightGreen,
|
|
idle: Color::White,
|
|
awaiting: Color::LightYellow,
|
|
error: Color::LightRed,
|
|
},
|
|
chrome_bg: Color::Black,
|
|
sidebar_focus_bg: Color::DarkGray,
|
|
border_focused: Color::LightGreen,
|
|
border_unfocused: Color::DarkGray,
|
|
focus_accent: Color::LightGreen,
|
|
group_row: Color::LightCyan,
|
|
mode_normal_bg: Color::Green,
|
|
mode_normal_fg: Color::Black,
|
|
mode_insert_bg: Color::Yellow,
|
|
mode_insert_fg: Color::Black,
|
|
error_text: Color::LightRed,
|
|
};
|
|
|
|
/// Solarized Dark — Ethan Schoonover's low-contrast classic,
|
|
/// accents lifted where the originals fall short of WCAG AA.
|
|
pub const SOLARIZED_DARK: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x00, 0x2b, 0x36),
|
|
fg: Color::Rgb(0x93, 0xa1, 0xa1),
|
|
muted: Color::Rgb(0x83, 0x94, 0x96),
|
|
dim: Color::Rgb(0x65, 0x7b, 0x83),
|
|
active: Color::Rgb(0x85, 0x99, 0x00),
|
|
idle: Color::Rgb(0x83, 0x94, 0x96),
|
|
awaiting: Color::Rgb(0xb5, 0x89, 0x00),
|
|
error: Color::Rgb(0xe5, 0x68, 0x65),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x10, 0x53, 0x62),
|
|
fg: Color::Rgb(0xb6, 0xc0, 0xc0),
|
|
muted: Color::Rgb(0xb5, 0xc0, 0xc1),
|
|
dim: Color::Rgb(0x8b, 0x9f, 0xa6),
|
|
active: Color::Rgb(0xaf, 0xca, 0x00),
|
|
idle: Color::Rgb(0xb5, 0xc0, 0xc1),
|
|
awaiting: Color::Rgb(0xef, 0xb5, 0x00),
|
|
error: Color::Rgb(0xf1, 0xad, 0xab),
|
|
},
|
|
chrome_bg: Color::Rgb(0x00, 0x2b, 0x36),
|
|
sidebar_focus_bg: Color::Rgb(0x00, 0x1a, 0x21),
|
|
border_focused: Color::Rgb(0x2a, 0xa1, 0x98),
|
|
border_unfocused: Color::Rgb(0x5e, 0x76, 0x7e),
|
|
focus_accent: Color::Rgb(0x2a, 0xa1, 0x98),
|
|
group_row: Color::Rgb(0x93, 0xa1, 0xa1),
|
|
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
|
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
|
mode_insert_bg: Color::Rgb(0xb5, 0x89, 0x00),
|
|
mode_insert_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
|
error_text: Color::Rgb(0xe5, 0x68, 0x65),
|
|
};
|
|
|
|
/// Solarized Light — the warm cream-paper peer of Solarized Dark.
|
|
pub const SOLARIZED_LIGHT: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
|
fg: Color::Rgb(0x58, 0x6e, 0x75),
|
|
muted: Color::Rgb(0x5e, 0x73, 0x7a),
|
|
dim: Color::Rgb(0x7e, 0x8f, 0x92),
|
|
active: Color::Rgb(0x67, 0x76, 0x00),
|
|
idle: Color::Rgb(0x5e, 0x73, 0x7a),
|
|
awaiting: Color::Rgb(0x8d, 0x6a, 0x00),
|
|
error: Color::Rgb(0xd7, 0x27, 0x24),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0xcb, 0xbd, 0x93),
|
|
fg: Color::Rgb(0x3f, 0x4e, 0x53),
|
|
muted: Color::Rgb(0x40, 0x4e, 0x53),
|
|
dim: Color::Rgb(0x59, 0x67, 0x69),
|
|
active: Color::Rgb(0x46, 0x50, 0x00),
|
|
idle: Color::Rgb(0x40, 0x4e, 0x53),
|
|
awaiting: Color::Rgb(0x60, 0x48, 0x00),
|
|
error: Color::Rgb(0x94, 0x1b, 0x19),
|
|
},
|
|
chrome_bg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
|
sidebar_focus_bg: Color::Rgb(0xf7, 0xf4, 0xed),
|
|
border_focused: Color::Rgb(0x29, 0x9b, 0x93),
|
|
border_unfocused: Color::Rgb(0x7f, 0x8f, 0x8f),
|
|
focus_accent: Color::Rgb(0x29, 0x9b, 0x93),
|
|
group_row: Color::Rgb(0x58, 0x6e, 0x75),
|
|
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
|
|
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
|
|
mode_insert_bg: Color::Rgb(0x8d, 0x6a, 0x00),
|
|
mode_insert_fg: Color::Rgb(0xfd, 0xf6, 0xe3),
|
|
error_text: Color::Rgb(0xd7, 0x27, 0x24),
|
|
};
|
|
|
|
/// Catppuccin Mocha — soothing pastels on a soft dark base.
|
|
pub const CATPPUCCIN: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
|
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
|
muted: Color::Rgb(0xa6, 0xad, 0xc8),
|
|
dim: Color::Rgb(0x7f, 0x84, 0x9c),
|
|
active: Color::Rgb(0xa6, 0xe3, 0xa1),
|
|
idle: Color::Rgb(0xa6, 0xad, 0xc8),
|
|
awaiting: Color::Rgb(0xf9, 0xe2, 0xaf),
|
|
error: Color::Rgb(0xf3, 0x8b, 0xa8),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x45, 0x47, 0x5a),
|
|
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
|
|
muted: Color::Rgb(0xb2, 0xb8, 0xcf),
|
|
dim: Color::Rgb(0x92, 0x97, 0xab),
|
|
active: Color::Rgb(0xb5, 0xe8, 0xb1),
|
|
idle: Color::Rgb(0xb2, 0xb8, 0xcf),
|
|
awaiting: Color::Rgb(0xfa, 0xe9, 0xc2),
|
|
error: Color::Rgb(0xf5, 0xa1, 0xb8),
|
|
},
|
|
chrome_bg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
|
sidebar_focus_bg: Color::Rgb(0x2a, 0x2a, 0x3c),
|
|
border_focused: Color::Rgb(0xcb, 0xa6, 0xf7),
|
|
border_unfocused: Color::Rgb(0x6c, 0x70, 0x86),
|
|
focus_accent: Color::Rgb(0xcb, 0xa6, 0xf7),
|
|
group_row: Color::Rgb(0xb4, 0xbe, 0xfe),
|
|
mode_normal_bg: Color::Rgb(0xcb, 0xa6, 0xf7),
|
|
mode_normal_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
|
mode_insert_bg: Color::Rgb(0xf9, 0xe2, 0xaf),
|
|
mode_insert_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
|
|
error_text: Color::Rgb(0xf3, 0x8b, 0xa8),
|
|
};
|
|
|
|
/// GitHub Dark — the github.com dark default.
|
|
pub const GITHUB_DARK: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x0d, 0x11, 0x17),
|
|
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
|
muted: Color::Rgb(0x91, 0x98, 0xa1),
|
|
dim: Color::Rgb(0x6e, 0x76, 0x81),
|
|
active: Color::Rgb(0x3f, 0xb9, 0x50),
|
|
idle: Color::Rgb(0x91, 0x98, 0xa1),
|
|
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
|
|
error: Color::Rgb(0xf8, 0x51, 0x49),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x1f, 0x3a, 0x5f),
|
|
fg: Color::Rgb(0xe6, 0xed, 0xf3),
|
|
muted: Color::Rgb(0x9f, 0xa5, 0xad),
|
|
dim: Color::Rgb(0x7e, 0x86, 0x91),
|
|
active: Color::Rgb(0x40, 0xbc, 0x51),
|
|
idle: Color::Rgb(0x9f, 0xa5, 0xad),
|
|
awaiting: Color::Rgb(0xd4, 0x9b, 0x22),
|
|
error: Color::Rgb(0xfa, 0x81, 0x7b),
|
|
},
|
|
chrome_bg: Color::Rgb(0x0d, 0x11, 0x17),
|
|
sidebar_focus_bg: Color::Rgb(0x16, 0x1b, 0x26),
|
|
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
|
|
border_unfocused: Color::Rgb(0x58, 0x64, 0x70),
|
|
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
|
|
group_row: Color::Rgb(0x91, 0x98, 0xa1),
|
|
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
|
|
mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
|
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
|
|
mode_insert_fg: Color::Rgb(0x0d, 0x11, 0x17),
|
|
error_text: Color::Rgb(0xf8, 0x51, 0x49),
|
|
};
|
|
|
|
/// GitHub Light — the github.com light default.
|
|
pub const GITHUB_LIGHT: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0xff, 0xff, 0xff),
|
|
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
|
muted: Color::Rgb(0x59, 0x63, 0x6e),
|
|
dim: Color::Rgb(0x81, 0x8b, 0x98),
|
|
active: Color::Rgb(0x1a, 0x7f, 0x37),
|
|
idle: Color::Rgb(0x59, 0x63, 0x6e),
|
|
awaiting: Color::Rgb(0x9a, 0x67, 0x00),
|
|
error: Color::Rgb(0xd1, 0x24, 0x2f),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0xa5, 0xcd, 0xf0),
|
|
fg: Color::Rgb(0x1f, 0x23, 0x28),
|
|
muted: Color::Rgb(0x4b, 0x53, 0x5d),
|
|
dim: Color::Rgb(0x64, 0x6d, 0x7a),
|
|
active: Color::Rgb(0x14, 0x5f, 0x29),
|
|
idle: Color::Rgb(0x4b, 0x53, 0x5d),
|
|
awaiting: Color::Rgb(0x71, 0x4b, 0x00),
|
|
error: Color::Rgb(0xa1, 0x1c, 0x24),
|
|
},
|
|
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
|
sidebar_focus_bg: Color::Rgb(0xf0, 0xf6, 0xfc),
|
|
border_focused: Color::Rgb(0x09, 0x69, 0xda),
|
|
border_unfocused: Color::Rgb(0x89, 0x93, 0x9d),
|
|
focus_accent: Color::Rgb(0x09, 0x69, 0xda),
|
|
group_row: Color::Rgb(0x59, 0x63, 0x6e),
|
|
mode_normal_bg: Color::Rgb(0x09, 0x69, 0xda),
|
|
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
|
mode_insert_bg: Color::Rgb(0x9a, 0x67, 0x00),
|
|
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
|
|
error_text: Color::Rgb(0xd1, 0x24, 0x2f),
|
|
};
|
|
|
|
/// Tokyo Night — neon-tinted blues of the VS Code / vim favourite.
|
|
pub const TOKYO_NIGHT: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
|
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
|
muted: Color::Rgb(0xa9, 0xb1, 0xd6),
|
|
dim: Color::Rgb(0x5d, 0x67, 0x95),
|
|
active: Color::Rgb(0x9e, 0xce, 0x6a),
|
|
idle: Color::Rgb(0xa9, 0xb1, 0xd6),
|
|
awaiting: Color::Rgb(0xe0, 0xaf, 0x68),
|
|
error: Color::Rgb(0xf7, 0x76, 0x8e),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x33, 0x46, 0x7c),
|
|
fg: Color::Rgb(0xc0, 0xca, 0xf5),
|
|
muted: Color::Rgb(0xb0, 0xb8, 0xd9),
|
|
dim: Color::Rgb(0x8e, 0x96, 0xb8),
|
|
active: Color::Rgb(0xa8, 0xd3, 0x79),
|
|
idle: Color::Rgb(0xb0, 0xb8, 0xd9),
|
|
awaiting: Color::Rgb(0xe1, 0xb1, 0x6b),
|
|
error: Color::Rgb(0xf9, 0xa0, 0xb0),
|
|
},
|
|
chrome_bg: Color::Rgb(0x1a, 0x1b, 0x26),
|
|
sidebar_focus_bg: Color::Rgb(0x24, 0x28, 0x3b),
|
|
border_focused: Color::Rgb(0x7a, 0xa2, 0xf7),
|
|
border_unfocused: Color::Rgb(0x5d, 0x67, 0x95),
|
|
focus_accent: Color::Rgb(0x7a, 0xa2, 0xf7),
|
|
group_row: Color::Rgb(0xa9, 0xb1, 0xd6),
|
|
mode_normal_bg: Color::Rgb(0x7a, 0xa2, 0xf7),
|
|
mode_normal_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
|
mode_insert_bg: Color::Rgb(0xe0, 0xaf, 0x68),
|
|
mode_insert_fg: Color::Rgb(0x1a, 0x1b, 0x26),
|
|
error_text: Color::Rgb(0xf7, 0x76, 0x8e),
|
|
};
|
|
|
|
/// Gruvbox Dark — retro warm earth tones.
|
|
pub const GRUVBOX: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x28, 0x28, 0x28),
|
|
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
|
muted: Color::Rgb(0xbd, 0xae, 0x93),
|
|
dim: Color::Rgb(0x92, 0x83, 0x74),
|
|
active: Color::Rgb(0xb8, 0xbb, 0x26),
|
|
idle: Color::Rgb(0xbd, 0xae, 0x93),
|
|
awaiting: Color::Rgb(0xfa, 0xbd, 0x2f),
|
|
error: Color::Rgb(0xfb, 0x58, 0x44),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x50, 0x49, 0x45),
|
|
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
|
|
muted: Color::Rgb(0xc7, 0xbb, 0xa4),
|
|
dim: Color::Rgb(0xa4, 0x98, 0x8b),
|
|
active: Color::Rgb(0xbf, 0xc3, 0x28),
|
|
idle: Color::Rgb(0xc7, 0xbb, 0xa4),
|
|
awaiting: Color::Rgb(0xfa, 0xc3, 0x43),
|
|
error: Color::Rgb(0xfd, 0xa5, 0x9a),
|
|
},
|
|
chrome_bg: Color::Rgb(0x28, 0x28, 0x28),
|
|
sidebar_focus_bg: Color::Rgb(0x32, 0x30, 0x2f),
|
|
border_focused: Color::Rgb(0xfe, 0x80, 0x19),
|
|
border_unfocused: Color::Rgb(0x7e, 0x71, 0x67),
|
|
focus_accent: Color::Rgb(0xfe, 0x80, 0x19),
|
|
group_row: Color::Rgb(0xd5, 0xc4, 0xa1),
|
|
mode_normal_bg: Color::Rgb(0xfe, 0x80, 0x19),
|
|
mode_normal_fg: Color::Rgb(0x28, 0x28, 0x28),
|
|
mode_insert_bg: Color::Rgb(0xfa, 0xbd, 0x2f),
|
|
mode_insert_fg: Color::Rgb(0x28, 0x28, 0x28),
|
|
error_text: Color::Rgb(0xfb, 0x58, 0x44),
|
|
};
|
|
|
|
/// Nord — arctic, bluish and calm; the red lifted to stay legible.
|
|
pub const NORD: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x2e, 0x34, 0x40),
|
|
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
|
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
|
dim: Color::Rgb(0x9a, 0xa5, 0xb5),
|
|
active: Color::Rgb(0xa3, 0xbe, 0x8c),
|
|
idle: Color::Rgb(0xd8, 0xde, 0xe9),
|
|
awaiting: Color::Rgb(0xeb, 0xcb, 0x8b),
|
|
error: Color::Rgb(0xd0, 0x8b, 0x91),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x4c, 0x56, 0x6a),
|
|
fg: Color::Rgb(0xec, 0xef, 0xf4),
|
|
muted: Color::Rgb(0xd8, 0xde, 0xe9),
|
|
dim: Color::Rgb(0x9f, 0xaa, 0xb9),
|
|
active: Color::Rgb(0xc0, 0xd3, 0xb0),
|
|
idle: Color::Rgb(0xe5, 0xe9, 0xf0),
|
|
awaiting: Color::Rgb(0xee, 0xd3, 0x9c),
|
|
error: Color::Rgb(0xe7, 0xc5, 0xc8),
|
|
},
|
|
chrome_bg: Color::Rgb(0x2e, 0x34, 0x40),
|
|
sidebar_focus_bg: Color::Rgb(0x35, 0x3b, 0x49),
|
|
border_focused: Color::Rgb(0x88, 0xc0, 0xd0),
|
|
border_unfocused: Color::Rgb(0x72, 0x80, 0x9a),
|
|
focus_accent: Color::Rgb(0x88, 0xc0, 0xd0),
|
|
group_row: Color::Rgb(0xd8, 0xde, 0xe9),
|
|
mode_normal_bg: Color::Rgb(0x88, 0xc0, 0xd0),
|
|
mode_normal_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
|
mode_insert_bg: Color::Rgb(0xeb, 0xcb, 0x8b),
|
|
mode_insert_fg: Color::Rgb(0x2e, 0x34, 0x40),
|
|
error_text: Color::Rgb(0xd0, 0x8b, 0x91),
|
|
};
|
|
|
|
/// Dracula — high-saturation accents on a dark violet-grey base.
|
|
pub const DRACULA: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x28, 0x2a, 0x36),
|
|
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
|
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
|
dim: Color::Rgb(0x65, 0x74, 0xa6),
|
|
active: Color::Rgb(0x50, 0xfa, 0x7b),
|
|
idle: Color::Rgb(0xbf, 0xc3, 0xd9),
|
|
awaiting: Color::Rgb(0xf1, 0xfa, 0x8c),
|
|
error: Color::Rgb(0xff, 0x59, 0x59),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x4a, 0x4e, 0x62),
|
|
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
|
|
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
|
|
dim: Color::Rgb(0x94, 0x9f, 0xc1),
|
|
active: Color::Rgb(0x64, 0xfb, 0x8a),
|
|
idle: Color::Rgb(0xcc, 0xcf, 0xe1),
|
|
awaiting: Color::Rgb(0xf3, 0xfb, 0xa0),
|
|
error: Color::Rgb(0xff, 0xad, 0xad),
|
|
},
|
|
chrome_bg: Color::Rgb(0x28, 0x2a, 0x36),
|
|
sidebar_focus_bg: Color::Rgb(0x31, 0x33, 0x42),
|
|
border_focused: Color::Rgb(0xbd, 0x93, 0xf9),
|
|
border_unfocused: Color::Rgb(0x65, 0x74, 0xa6),
|
|
focus_accent: Color::Rgb(0xbd, 0x93, 0xf9),
|
|
group_row: Color::Rgb(0xbf, 0xc3, 0xd9),
|
|
mode_normal_bg: Color::Rgb(0xbd, 0x93, 0xf9),
|
|
mode_normal_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
|
mode_insert_bg: Color::Rgb(0xf1, 0xfa, 0x8c),
|
|
mode_insert_fg: Color::Rgb(0x28, 0x2a, 0x36),
|
|
error_text: Color::Rgb(0xff, 0x59, 0x59),
|
|
};
|
|
|
|
/// Ice Cyan — signal-cyan accent on the graphite slate base.
|
|
pub const ICE_CYAN: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x1c, 0x20, 0x25),
|
|
fg: Color::Rgb(0xe4, 0xe8, 0xed),
|
|
muted: Color::Rgb(0xaf, 0xb5, 0xbc),
|
|
dim: Color::Rgb(0x84, 0x8a, 0x91),
|
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
|
idle: Color::Rgb(0xaf, 0xb5, 0xbc),
|
|
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x24, 0x4d, 0x59),
|
|
fg: Color::Rgb(0xee, 0xf2, 0xf7),
|
|
muted: Color::Rgb(0xcd, 0xd1, 0xd7),
|
|
dim: Color::Rgb(0xa4, 0xa8, 0xae),
|
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
|
idle: Color::Rgb(0xd3, 0xd8, 0xde),
|
|
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
|
},
|
|
chrome_bg: Color::Rgb(0x1c, 0x20, 0x25),
|
|
sidebar_focus_bg: Color::Rgb(0x26, 0x2a, 0x30),
|
|
border_focused: Color::Rgb(0x61, 0xe3, 0xf1),
|
|
border_unfocused: Color::Rgb(0x70, 0x75, 0x7b),
|
|
focus_accent: Color::Rgb(0x61, 0xe3, 0xf1),
|
|
group_row: Color::Rgb(0xb8, 0xbe, 0xc6),
|
|
mode_normal_bg: Color::Rgb(0x61, 0xe3, 0xf1),
|
|
mode_normal_fg: Color::Rgb(0x0f, 0x22, 0x2b),
|
|
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
mode_insert_fg: Color::Rgb(0x1c, 0x20, 0x25),
|
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
|
};
|
|
|
|
/// Periwinkle — indigo-tinted base with a soft blue-violet accent.
|
|
pub const PERIWINKLE: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x1d, 0x1e, 0x29),
|
|
fg: Color::Rgb(0xe5, 0xe7, 0xf1),
|
|
muted: Color::Rgb(0xb1, 0xb3, 0xc2),
|
|
dim: Color::Rgb(0x86, 0x88, 0x96),
|
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
|
idle: Color::Rgb(0xb1, 0xb3, 0xc2),
|
|
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x3b, 0x43, 0x6c),
|
|
fg: Color::Rgb(0xef, 0xf1, 0xfb),
|
|
muted: Color::Rgb(0xce, 0xd0, 0xdc),
|
|
dim: Color::Rgb(0xa5, 0xa7, 0xb2),
|
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
|
idle: Color::Rgb(0xd4, 0xd7, 0xe3),
|
|
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
|
},
|
|
chrome_bg: Color::Rgb(0x1d, 0x1e, 0x29),
|
|
sidebar_focus_bg: Color::Rgb(0x27, 0x29, 0x35),
|
|
border_focused: Color::Rgb(0x97, 0xa7, 0xfd),
|
|
border_unfocused: Color::Rgb(0x71, 0x74, 0x80),
|
|
focus_accent: Color::Rgb(0x97, 0xa7, 0xfd),
|
|
group_row: Color::Rgb(0xba, 0xbd, 0xcc),
|
|
mode_normal_bg: Color::Rgb(0x97, 0xa7, 0xfd),
|
|
mode_normal_fg: Color::Rgb(0x12, 0x14, 0x28),
|
|
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
mode_insert_fg: Color::Rgb(0x1d, 0x1e, 0x29),
|
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
|
};
|
|
|
|
/// Amber Phosphor — CRT amber on warm graphite. The awaiting colour
|
|
/// shifts orange so it stays distinct from the amber accent.
|
|
pub const AMBER_PHOSPHOR: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x22, 0x1f, 0x1a),
|
|
fg: Color::Rgb(0xec, 0xe7, 0xe1),
|
|
muted: Color::Rgb(0xba, 0xb3, 0xaa),
|
|
dim: Color::Rgb(0x8f, 0x88, 0x7f),
|
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
|
idle: Color::Rgb(0xba, 0xb3, 0xaa),
|
|
awaiting: Color::Rgb(0xff, 0x9b, 0x50),
|
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x55, 0x43, 0x26),
|
|
fg: Color::Rgb(0xf6, 0xf1, 0xeb),
|
|
muted: Color::Rgb(0xd6, 0xd0, 0xc9),
|
|
dim: Color::Rgb(0xad, 0xa7, 0xa0),
|
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
|
idle: Color::Rgb(0xdd, 0xd6, 0xcf),
|
|
awaiting: Color::Rgb(0xff, 0xb9, 0x72),
|
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
|
},
|
|
chrome_bg: Color::Rgb(0x22, 0x1f, 0x1a),
|
|
sidebar_focus_bg: Color::Rgb(0x2d, 0x29, 0x25),
|
|
border_focused: Color::Rgb(0xf9, 0xbb, 0x51),
|
|
border_unfocused: Color::Rgb(0x7a, 0x73, 0x6c),
|
|
focus_accent: Color::Rgb(0xf9, 0xbb, 0x51),
|
|
group_row: Color::Rgb(0xc4, 0xbc, 0xb3),
|
|
mode_normal_bg: Color::Rgb(0xf9, 0xbb, 0x51),
|
|
mode_normal_fg: Color::Rgb(0x2f, 0x1e, 0x01),
|
|
mode_insert_bg: Color::Rgb(0xff, 0x9b, 0x50),
|
|
mode_insert_fg: Color::Rgb(0x22, 0x1f, 0x1a),
|
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
|
};
|
|
|
|
/// Ember — warm orange-red accent on a cleaner warm neutral.
|
|
pub const EMBER: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x23, 0x1e, 0x1b),
|
|
fg: Color::Rgb(0xed, 0xe6, 0xe3),
|
|
muted: Color::Rgb(0xbc, 0xb2, 0xad),
|
|
dim: Color::Rgb(0x90, 0x87, 0x82),
|
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
|
idle: Color::Rgb(0xbc, 0xb2, 0xad),
|
|
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x5d, 0x3e, 0x30),
|
|
fg: Color::Rgb(0xf7, 0xf0, 0xed),
|
|
muted: Color::Rgb(0xd7, 0xcf, 0xcb),
|
|
dim: Color::Rgb(0xae, 0xa6, 0xa2),
|
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
|
idle: Color::Rgb(0xde, 0xd5, 0xd1),
|
|
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
|
},
|
|
chrome_bg: Color::Rgb(0x23, 0x1e, 0x1b),
|
|
sidebar_focus_bg: Color::Rgb(0x2e, 0x29, 0x26),
|
|
border_focused: Color::Rgb(0xf7, 0x89, 0x55),
|
|
border_unfocused: Color::Rgb(0x7b, 0x72, 0x6e),
|
|
focus_accent: Color::Rgb(0xf7, 0x89, 0x55),
|
|
group_row: Color::Rgb(0xc6, 0xbb, 0xb6),
|
|
mode_normal_bg: Color::Rgb(0xf7, 0x89, 0x55),
|
|
mode_normal_fg: Color::Rgb(0x2a, 0x14, 0x0a),
|
|
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
mode_insert_fg: Color::Rgb(0x23, 0x1e, 0x1b),
|
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
|
};
|
|
|
|
/// Orchid — muted violet accent on a faintly violet base.
|
|
pub const ORCHID: Palette = Palette {
|
|
card: Surface {
|
|
bg: Color::Rgb(0x21, 0x1e, 0x25),
|
|
fg: Color::Rgb(0xe9, 0xe6, 0xee),
|
|
muted: Color::Rgb(0xb6, 0xb2, 0xbd),
|
|
dim: Color::Rgb(0x8b, 0x87, 0x91),
|
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
|
idle: Color::Rgb(0xb6, 0xb2, 0xbd),
|
|
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
|
},
|
|
selection: Surface {
|
|
bg: Color::Rgb(0x51, 0x3c, 0x63),
|
|
fg: Color::Rgb(0xf3, 0xf0, 0xf8),
|
|
muted: Color::Rgb(0xd3, 0xcf, 0xd8),
|
|
dim: Color::Rgb(0xa9, 0xa6, 0xaf),
|
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
|
idle: Color::Rgb(0xd9, 0xd5, 0xdf),
|
|
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
|
},
|
|
chrome_bg: Color::Rgb(0x21, 0x1e, 0x25),
|
|
sidebar_focus_bg: Color::Rgb(0x2c, 0x28, 0x30),
|
|
border_focused: Color::Rgb(0xd1, 0x9f, 0xf5),
|
|
border_unfocused: Color::Rgb(0x76, 0x72, 0x7c),
|
|
focus_accent: Color::Rgb(0xd1, 0x9f, 0xf5),
|
|
group_row: Color::Rgb(0xc0, 0xbb, 0xc7),
|
|
mode_normal_bg: Color::Rgb(0xd1, 0x9f, 0xf5),
|
|
mode_normal_fg: Color::Rgb(0x22, 0x13, 0x2c),
|
|
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
|
mode_insert_fg: Color::Rgb(0x21, 0x1e, 0x25),
|
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
|
};
|
|
|
|
/// The default preset, retained as a named compatibility constant.
|
|
pub const DEFAULT: Palette = Self::DARK;
|
|
|
|
/// All preset names and their complete palettes.
|
|
pub fn presets() -> [(&'static str, Palette); 17] {
|
|
[
|
|
("dark", Self::DARK),
|
|
("light", Self::LIGHT),
|
|
("ice-cyan", Self::ICE_CYAN),
|
|
("periwinkle", Self::PERIWINKLE),
|
|
("amber-phosphor", Self::AMBER_PHOSPHOR),
|
|
("ember", Self::EMBER),
|
|
("orchid", Self::ORCHID),
|
|
("ansi", Self::ANSI),
|
|
("solarized-dark", Self::SOLARIZED_DARK),
|
|
("solarized-light", Self::SOLARIZED_LIGHT),
|
|
("catppuccin", Self::CATPPUCCIN),
|
|
("github-dark", Self::GITHUB_DARK),
|
|
("github-light", Self::GITHUB_LIGHT),
|
|
("tokyo-night", Self::TOKYO_NIGHT),
|
|
("gruvbox", Self::GRUVBOX),
|
|
("nord", Self::NORD),
|
|
("dracula", Self::DRACULA),
|
|
]
|
|
}
|
|
|
|
/// Resolve a config name to a palette.
|
|
pub fn named(name: &str) -> Option<Palette> {
|
|
Self::presets()
|
|
.into_iter()
|
|
.find(|(n, _)| *n == name)
|
|
.map(|(_, p)| p)
|
|
}
|
|
|
|
/// Every role, including the backgrounds it is painted against.
|
|
pub fn roles(&self) -> [(&'static str, Color); 27] {
|
|
[
|
|
("card.bg", self.card.bg),
|
|
("card.fg", self.card.fg),
|
|
("card.muted", self.card.muted),
|
|
("card.dim", self.card.dim),
|
|
("card.active", self.card.active),
|
|
("card.idle", self.card.idle),
|
|
("card.awaiting", self.card.awaiting),
|
|
("card.error", self.card.error),
|
|
("selection.bg", self.selection.bg),
|
|
("selection.fg", self.selection.fg),
|
|
("selection.muted", self.selection.muted),
|
|
("selection.dim", self.selection.dim),
|
|
("selection.active", self.selection.active),
|
|
("selection.idle", self.selection.idle),
|
|
("selection.awaiting", self.selection.awaiting),
|
|
("selection.error", self.selection.error),
|
|
("chrome_bg", self.chrome_bg),
|
|
("sidebar_focus_bg", self.sidebar_focus_bg),
|
|
("border_focused", self.border_focused),
|
|
("border_unfocused", self.border_unfocused),
|
|
("focus_accent", self.focus_accent),
|
|
("group_row", self.group_row),
|
|
("mode_normal_bg", self.mode_normal_bg),
|
|
("mode_normal_fg", self.mode_normal_fg),
|
|
("mode_insert_bg", self.mode_insert_bg),
|
|
("mode_insert_fg", self.mode_insert_fg),
|
|
("error_text", self.error_text),
|
|
]
|
|
}
|
|
|
|
/// The surface a card sits on: the selection surface when it is the
|
|
/// highlighted card, the card surface otherwise.
|
|
pub fn surface(&self, selected: bool) -> &Surface {
|
|
if selected {
|
|
&self.selection
|
|
} else {
|
|
&self.card
|
|
}
|
|
}
|
|
|
|
/// Background for the card whose session the pane is showing
|
|
/// (`docs/SIDEBAR.md`). It sits partway from the card surface to the
|
|
/// selection surface, so the sidebar reads as one ladder: unfilled, half
|
|
/// filled for the session in the pane, fully filled under the cursor.
|
|
///
|
|
/// Blended rather than added as a third `Surface` — a new surface means
|
|
/// eight more colours hand-picked seventeen times — which is also why it
|
|
/// borrows the *selection* surface's foregrounds: those are the ones
|
|
/// already picked for a filled row. A preset whose colours the terminal
|
|
/// owns (`ansi`) has nothing to blend, so it keeps the card background and
|
|
/// says "in the pane" with the filled glyph alone.
|
|
pub fn pane_row_bg(&self) -> Color {
|
|
mix(self.card.bg, self.selection.bg, 0.55)
|
|
}
|
|
|
|
/// Border style for a panel, by whether it holds focus.
|
|
pub fn border_style(&self, focused: bool) -> Style {
|
|
if focused {
|
|
Style::default().bg(self.chrome_bg).fg(self.border_focused)
|
|
} else {
|
|
Style::default()
|
|
.bg(self.chrome_bg)
|
|
.fg(self.border_unfocused)
|
|
}
|
|
}
|
|
|
|
/// Style for a sidebar group heading.
|
|
pub fn group_row_style(&self) -> Style {
|
|
Style::default()
|
|
.bg(self.chrome_bg)
|
|
.fg(self.group_row)
|
|
.add_modifier(Modifier::BOLD)
|
|
}
|
|
|
|
/// Style for the status bar's mode indicator.
|
|
pub fn mode_style(&self, insert: bool) -> Style {
|
|
if insert {
|
|
Style::default()
|
|
.fg(self.mode_insert_fg)
|
|
.bg(self.mode_insert_bg)
|
|
} else {
|
|
Style::default()
|
|
.fg(self.mode_normal_fg)
|
|
.bg(self.mode_normal_bg)
|
|
}
|
|
}
|
|
|
|
/// Style for error text outside a card.
|
|
pub fn error_style(&self) -> Style {
|
|
Style::default().bg(self.chrome_bg).fg(self.error_text)
|
|
}
|
|
}
|
|
|
|
impl Default for Palette {
|
|
fn default() -> Self {
|
|
Self::DEFAULT
|
|
}
|
|
}
|
|
|
|
fn active_slot() -> &'static std::sync::RwLock<Palette> {
|
|
static ACTIVE: std::sync::OnceLock<std::sync::RwLock<Palette>> = std::sync::OnceLock::new();
|
|
ACTIVE.get_or_init(|| std::sync::RwLock::new(Palette::DEFAULT))
|
|
}
|
|
|
|
/// Set the palette used by the running TUI.
|
|
pub(crate) fn set_active(palette: Palette) {
|
|
*active_slot().write().expect("active palette lock poisoned") = palette;
|
|
}
|
|
|
|
/// The palette in force. Config-driven preset selection lands here at TUI
|
|
/// startup, before the first frame is drawn.
|
|
pub fn active() -> Palette {
|
|
*active_slot().read().expect("active palette lock poisoned")
|
|
}
|
|
|
|
/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or
|
|
/// there is nothing to blend and `from` comes back unchanged.
|
|
fn mix(from: Color, to: Color, amount: f64) -> Color {
|
|
let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else {
|
|
return from;
|
|
};
|
|
let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8;
|
|
Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb))
|
|
}
|
|
|
|
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
|
/// luminance — they are whatever the terminal theme says — so they return
|
|
/// `None` and cannot be contrast-checked.
|
|
pub fn relative_luminance(color: Color) -> Option<f64> {
|
|
let Color::Rgb(r, g, b) = color else {
|
|
return None;
|
|
};
|
|
fn channel(v: u8) -> f64 {
|
|
let v = v as f64 / 255.0;
|
|
if v <= 0.03928 {
|
|
v / 12.92
|
|
} else {
|
|
((v + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
}
|
|
Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b))
|
|
}
|
|
|
|
/// WCAG contrast ratio between two RGB roles, 1.0 (identical) to 21.0
|
|
/// (black on white). `None` if either colour is not RGB.
|
|
pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
|
let (la, lb) = (relative_luminance(a)?, relative_luminance(b)?);
|
|
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
|
Some((hi + 0.05) / (lo + 0.05))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const STATES: [ActivityState; 5] = [
|
|
ActivityState::Active,
|
|
ActivityState::Idle,
|
|
ActivityState::Starting,
|
|
ActivityState::AwaitingInput,
|
|
ActivityState::Error,
|
|
];
|
|
|
|
/// Every preset except `ansi`, whose colours the terminal owns and which
|
|
/// therefore has no fixed luminance to assert against.
|
|
fn rgb_presets() -> impl Iterator<Item = (&'static str, Palette)> {
|
|
Palette::presets()
|
|
.into_iter()
|
|
.filter(|(_, p)| relative_luminance(p.card.bg).is_some())
|
|
}
|
|
|
|
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
|
/// decoration and only has to clear the 3:1 non-text threshold.
|
|
#[test]
|
|
fn every_role_is_legible_on_its_own_surface() {
|
|
for (preset, p) in rgb_presets() {
|
|
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
|
for (role, color) in surface.roles() {
|
|
let ratio = contrast_ratio(surface.bg, color)
|
|
.unwrap_or_else(|| panic!("{preset}.{surface_name}.{role} must be RGB"));
|
|
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
|
assert!(
|
|
ratio >= floor,
|
|
"{preset}.{surface_name}.{role} contrast {ratio:.2} is below {floor}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The pane row is the middle rung: a visible fill against the card, still
|
|
/// a different surface from the cursor's, and carrying the selection
|
|
/// surface's foregrounds, which it borrows rather than shipping its own.
|
|
#[test]
|
|
fn the_pane_row_is_a_rung_between_the_card_and_the_selection() {
|
|
for (preset, p) in rgb_presets() {
|
|
let bg = p.pane_row_bg();
|
|
let from_card = contrast_ratio(p.card.bg, bg)
|
|
.unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB"));
|
|
assert!(
|
|
from_card >= 1.15,
|
|
"{preset}: pane row {from_card:.2} from the card background is not a visible fill"
|
|
);
|
|
let from_selection =
|
|
contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB");
|
|
assert!(
|
|
from_selection >= 1.15,
|
|
"{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane"
|
|
);
|
|
for (role, color) in p.selection.roles() {
|
|
let ratio = contrast_ratio(bg, color).expect("selection role must be RGB");
|
|
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
|
assert!(
|
|
ratio >= floor,
|
|
"{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A preset the terminal themes has nothing to blend from.
|
|
#[test]
|
|
fn ansi_keeps_the_card_background_for_the_pane_row() {
|
|
let p = Palette::ANSI;
|
|
assert_eq!(p.pane_row_bg(), p.card.bg);
|
|
}
|
|
|
|
#[test]
|
|
fn every_preset_supplies_every_role() {
|
|
for (name, p) in Palette::presets() {
|
|
assert_eq!(p.roles().len(), 27, "{name} role set is incomplete");
|
|
assert_eq!(Palette::named(name), Some(p));
|
|
}
|
|
assert_eq!(Palette::named("unknown"), None);
|
|
}
|
|
|
|
/// The selection background exists to be seen as a different surface, not
|
|
/// merely a slightly different shade of the card.
|
|
#[test]
|
|
fn selection_background_is_distinguishable_from_the_card_background() {
|
|
for (name, p) in rgb_presets() {
|
|
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
|
assert!(
|
|
ratio >= 1.6,
|
|
"{name} card and selection backgrounds differ by only {ratio:.2}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The bug this palette fixes: a state colour chosen for the dark card
|
|
/// background is not legible on the selection background, so the
|
|
/// selection surface carries its own — distinct — state colours.
|
|
#[test]
|
|
fn selection_state_colours_are_not_the_card_state_colours() {
|
|
for (name, p) in Palette::presets() {
|
|
for state in STATES {
|
|
assert_ne!(
|
|
p.card.activity(state),
|
|
p.selection.activity(state),
|
|
"{name} {state:?} must have a selection-specific colour"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Other panels' roles are RGB too, so a preset swap moves all of them.
|
|
/// Foregrounds are checked against the chrome background they are
|
|
/// actually painted on; they must never rely on the terminal default.
|
|
#[test]
|
|
fn chrome_roles_are_rgb_values() {
|
|
for (preset, p) in rgb_presets() {
|
|
for (name, color) in p.roles().into_iter().skip(16) {
|
|
assert!(
|
|
relative_luminance(color).is_some(),
|
|
"{preset}.{name} must be an RGB value"
|
|
);
|
|
}
|
|
// The mode indicator paints fg on bg; both pairs must stay readable.
|
|
for (name, fg, bg) in [
|
|
("normal", p.mode_normal_fg, p.mode_normal_bg),
|
|
("insert", p.mode_insert_fg, p.mode_insert_bg),
|
|
] {
|
|
let ratio = contrast_ratio(fg, bg).unwrap();
|
|
assert!(
|
|
ratio >= 4.5,
|
|
"{preset} mode {name} contrast {ratio:.2} is below 4.5"
|
|
);
|
|
}
|
|
for (name, fg, floor) in [
|
|
("border_focused", p.border_focused, 3.0),
|
|
("border_unfocused", p.border_unfocused, 3.0),
|
|
("focus_accent", p.focus_accent, 3.0),
|
|
("group_row", p.group_row, 4.5),
|
|
("error_text", p.error_text, 4.5),
|
|
] {
|
|
let ratio = contrast_ratio(fg, p.chrome_bg).unwrap();
|
|
assert!(
|
|
ratio >= floor,
|
|
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
|
);
|
|
}
|
|
for (name, fg) in [
|
|
("card.fg", p.card.fg),
|
|
("card.muted", p.card.muted),
|
|
("group_row", p.group_row),
|
|
] {
|
|
let ratio = contrast_ratio(fg, p.sidebar_focus_bg).unwrap();
|
|
assert!(
|
|
ratio >= 4.5,
|
|
"{preset}.{name} contrast {ratio:.2} is below 4.5 on sidebar_focus_bg"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn contrast_ratio_matches_known_values() {
|
|
let black = Color::Rgb(0, 0, 0);
|
|
let white = Color::Rgb(255, 255, 255);
|
|
assert!((contrast_ratio(black, white).unwrap() - 21.0).abs() < 0.01);
|
|
assert!((contrast_ratio(white, white).unwrap() - 1.0).abs() < 0.01);
|
|
assert_eq!(contrast_ratio(Color::Cyan, white), None);
|
|
}
|
|
}
|