forked from NotAShelf/beer
color: config-seeded theme/palette with OSC 4/10/11/17/19 and bg opacity
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ied0c27950f4ee8d5bd862c90341118826a6a6964
This commit is contained in:
parent
ccc30d1bbd
commit
c78687c0ae
6 changed files with 479 additions and 130 deletions
249
src/theme.rs
Normal file
249
src/theme.rs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//! 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue