beer/src/theme.rs
NotAShelf 0738ce3b6f
config: default cursor style/blink and visual bell
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ibd512084374fe4723ee267a916187af56a6a6964
2026-06-26 10:21:40 +03:00

256 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Runtime colours: the foreground/background, the 256-entry palette, and the
//! selection/search/cursor accents. Seeded from config and mutated at runtime
//! by OSC colour escapes (4/104, 10/11, 17/19, and their resets).
use crate::config::Colors as ColorConfig;
/// An 8-bit-per-channel RGB colour.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Rgb(pub u8, pub u8, pub u8);
/// Where a `Color::Default` resolves to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Plane {
Fg,
Bg,
}
/// The active colour scheme.
#[derive(Clone, Debug)]
pub struct Theme {
pub fg: Rgb,
pub bg: Rgb,
/// Cursor body colour; `None` follows the cell under the cursor.
pub cursor: Option<Rgb>,
pub selection_fg: Option<Rgb>,
pub selection_bg: Rgb,
pub match_bg: Rgb,
pub current_match_bg: Rgb,
pub search_bar_bg: Rgb,
pub palette: [Rgb; 256],
pub bold_as_bright: bool,
/// Background opacity, 0 (transparent) - 255 (opaque).
pub alpha: u8,
/// Configured defaults, restored by OSC reset escapes (110/111/104).
default_fg: Rgb,
default_bg: Rgb,
default_palette: [Rgb; 256],
}
impl Default for Theme {
fn default() -> Self {
let palette = default_palette();
Self {
fg: DEFAULT_FG,
bg: DEFAULT_BG,
cursor: None,
selection_fg: None,
selection_bg: SELECTION_BG,
match_bg: MATCH_BG,
current_match_bg: CURRENT_MATCH_BG,
search_bar_bg: SEARCH_BAR_BG,
palette,
bold_as_bright: true,
alpha: 255,
default_fg: DEFAULT_FG,
default_bg: DEFAULT_BG,
default_palette: palette,
}
}
}
impl Theme {
/// Build a theme from the `[colors]` config table, falling back to the
/// built-in defaults for any unset entry.
pub fn from_config(c: &ColorConfig) -> Self {
let mut theme = Self::default();
let set = |dst: &mut Rgb, spec: &Option<String>| {
if let Some(rgb) = spec.as_deref().and_then(parse_color) {
*dst = rgb;
}
};
set(&mut theme.fg, &c.foreground);
set(&mut theme.bg, &c.background);
set(&mut theme.selection_bg, &c.selection_background);
set(&mut theme.match_bg, &c.match_background);
set(&mut theme.current_match_bg, &c.match_current_background);
theme.cursor = c.cursor.as_deref().and_then(parse_color);
theme.selection_fg = c.selection_foreground.as_deref().and_then(parse_color);
if let Some(regular) = &c.regular {
apply_palette(&mut theme.palette, 0, regular);
}
if let Some(bright) = &c.bright {
apply_palette(&mut theme.palette, 8, bright);
}
if let Some(a) = c.alpha {
theme.alpha = (a.clamp(0.0, 1.0) * 255.0).round() as u8;
}
if let Some(b) = c.bold_as_bright {
theme.bold_as_bright = b;
}
// Remember the resolved scheme so OSC resets return here, not to the
// compiled-in defaults.
theme.default_fg = theme.fg;
theme.default_bg = theme.bg;
theme.default_palette = theme.palette;
theme
}
/// Resolve a cell colour to RGB on the given plane, applying bold-as-bright
/// to the low 8 palette indices.
pub fn resolve(&self, color: crate::grid::Color, plane: Plane, bold: bool) -> Rgb {
use crate::grid::Color;
match color {
Color::Default => match plane {
Plane::Fg => self.fg,
Plane::Bg => self.bg,
},
Color::Indexed(i) => {
let idx = if bold && self.bold_as_bright && i < 8 {
i + 8
} else {
i
};
self.palette[idx as usize]
}
Color::Rgb(r, g, b) => Rgb(r, g, b),
}
}
pub fn set_palette(&mut self, index: u8, rgb: Rgb) {
self.palette[index as usize] = rgb;
}
/// Reset palette entry `index` to its configured value.
pub fn reset_palette_index(&mut self, index: u8) {
self.palette[index as usize] = self.default_palette[index as usize];
}
/// Reset the whole palette to its configured values.
pub fn reset_palette(&mut self) {
self.palette = self.default_palette;
}
pub fn reset_fg(&mut self) {
self.fg = self.default_fg;
}
pub fn reset_bg(&mut self) {
self.bg = self.default_bg;
}
/// A copy with foreground and background swapped, for the visual bell flash.
pub fn inverted(&self) -> Self {
let mut t = self.clone();
std::mem::swap(&mut t.fg, &mut t.bg);
t
}
}
/// Foreground/background used for `Color::Default`.
const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);
const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);
const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);
const MATCH_BG: Rgb = Rgb(0x5a, 0x51, 0x2a);
const CURRENT_MATCH_BG: Rgb = Rgb(0xb8, 0x8a, 0x2a);
const SEARCH_BAR_BG: Rgb = Rgb(0x30, 0x30, 0x40);
/// Overwrite palette entries `[base, base+specs.len())` from hex specs.
fn apply_palette(palette: &mut [Rgb; 256], base: usize, specs: &[String]) {
for (i, spec) in specs.iter().take(8).enumerate() {
if let Some(rgb) = parse_color(spec) {
palette[base + i] = rgb;
}
}
}
/// The xterm 256-colour palette: 16 base, a 6×6×6 cube, then 24 greys.
fn default_palette() -> [Rgb; 256] {
const BASE: [Rgb; 16] = [
Rgb(0x00, 0x00, 0x00),
Rgb(0xcd, 0x00, 0x00),
Rgb(0x00, 0xcd, 0x00),
Rgb(0xcd, 0xcd, 0x00),
Rgb(0x00, 0x00, 0xee),
Rgb(0xcd, 0x00, 0xcd),
Rgb(0x00, 0xcd, 0xcd),
Rgb(0xe5, 0xe5, 0xe5),
Rgb(0x7f, 0x7f, 0x7f),
Rgb(0xff, 0x00, 0x00),
Rgb(0x00, 0xff, 0x00),
Rgb(0xff, 0xff, 0x00),
Rgb(0x5c, 0x5c, 0xff),
Rgb(0xff, 0x00, 0xff),
Rgb(0x00, 0xff, 0xff),
Rgb(0xff, 0xff, 0xff),
];
let cube = |step: u8| -> u8 { if step == 0 { 0 } else { 55 + 40 * step } };
std::array::from_fn(|i| match i {
0..=15 => BASE[i],
16..=231 => {
let i = i as u8 - 16;
Rgb(cube(i / 36), cube((i / 6) % 6), cube(i % 6))
}
_ => {
let v = 8 + 10 * (i as u8 - 232);
Rgb(v, v, v)
}
})
}
/// Parse an X11 colour spec: `rgb:rr/gg/bb` (1-4 hex digits per channel) or
/// `#rrggbb`.
pub fn parse_color(spec: &str) -> Option<Rgb> {
if let Some(rest) = spec.strip_prefix("rgb:") {
let mut it = rest.split('/');
let chan = |s: &str| -> Option<u8> {
let v = u32::from_str_radix(s, 16).ok()?;
let max = (1u32 << (4 * s.len() as u32)) - 1;
Some((v * 255 / max) as u8)
};
return Some(Rgb(chan(it.next()?)?, chan(it.next()?)?, chan(it.next()?)?));
}
let hex = spec.strip_prefix('#')?;
if hex.len() == 6 {
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
return Some(Rgb(byte(0)?, byte(2)?, byte(4)?));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_color_forms() {
assert_eq!(parse_color("#ff0080"), Some(Rgb(255, 0, 128)));
assert_eq!(parse_color("rgb:ff/00/80"), Some(Rgb(255, 0, 128)));
assert_eq!(parse_color("rgb:ffff/0000/8080"), Some(Rgb(255, 0, 128)));
assert_eq!(parse_color("nope"), None);
}
#[test]
fn palette_cube_and_grey() {
let p = default_palette();
assert_eq!(p[16], Rgb(0, 0, 0));
assert_eq!(p[231], Rgb(255, 255, 255));
assert_eq!(p[232], Rgb(8, 8, 8));
}
#[test]
fn config_overrides_and_resets() {
let cfg = ColorConfig {
background: Some("#000000".to_string()),
regular: Some(vec!["#111111".to_string()]),
..ColorConfig::default()
};
let mut theme = Theme::from_config(&cfg);
assert_eq!(theme.bg, Rgb(0, 0, 0));
assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));
// OSC reset returns to the configured value, not the compiled default.
theme.set_palette(0, Rgb(9, 9, 9));
theme.reset_palette_index(0);
assert_eq!(theme.palette[0], Rgb(0x11, 0x11, 0x11));
}
}