brewery
notashelf /
c78687c0aea2db7643f6d11c38c26c28609ce053

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit c78687c0aea2

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified6 files changed+479-130
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
diff --git a/src/config.rs b/src/config.rsindex 4242dfc..cffc726 100644--- a/src/config.rs+++ b/src/config.rs@@ -12,9 +12,33 @@ use serde::Deserialize; #[serde(default, rename_all = "kebab-case")] pub struct Config {     pub main: Main,+    pub colors: Colors,     pub scrollback: Scrollback, } +/// `[colors]`: foreground/background, the 16 base palette entries, and accents.+/// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries+/// keep the built-in default.+#[derive(Debug, Clone, Default, Deserialize)]+#[serde(default, rename_all = "kebab-case")]+pub struct Colors {+    pub foreground: Option<String>,+    pub background: Option<String>,+    pub cursor: Option<String>,+    pub selection_foreground: Option<String>,+    pub selection_background: Option<String>,+    /// The eight regular palette entries (indices 0-7).+    pub regular: Option<Vec<String>>,+    /// The eight bright palette entries (indices 8-15).+    pub bright: Option<Vec<String>>,+    pub match_background: Option<String>,+    pub match_current_background: Option<String>,+    /// Background opacity, 0.0 (transparent) - 1.0 (opaque).+    pub alpha: Option<f32>,+    /// Render bold text with the bright palette variant.+    pub bold_as_bright: Option<bool>,+}+ /// `[main]`: fonts, window geometry, padding, and the terminal name. #[derive(Debug, Clone, Deserialize)] #[serde(default, rename_all = "kebab-case")]diff --git a/src/main.rs b/src/main.rsindex 5257273..7f5b108 100644--- a/src/main.rs+++ b/src/main.rs@@ -6,6 +6,7 @@ mod grid; mod input; mod pty; mod render;+mod theme; mod vt; mod wayland; diff --git a/src/render.rs b/src/render.rsindex e817a62..4f5229e 100644--- a/src/render.rs+++ b/src/render.rs@@ -6,21 +6,8 @@ //! neighbouring cell's background fill.  use crate::font::{CellMetrics, Fonts, GlyphData, Style};-use crate::grid::{Cell, Color, CursorShape, Flags, Grid, Underline};--/// Foreground/background used for `Color::Default`.-const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);-const DEFAULT_BG: Rgb = Rgb(0x18, 0x18, 0x18);-/// Background painted behind selected cells.-const SELECTION_BG: Rgb = Rgb(0x44, 0x47, 0x5a);-/// Background behind a search match, and the focused match.-const MATCH_BG: Rgb = Rgb(0x5a, 0x51, 0x2a);-const CURRENT_MATCH_BG: Rgb = Rgb(0xb8, 0x8a, 0x2a);-/// Search prompt bar drawn along the bottom row.-const SEARCH_BAR_BG: Rgb = Rgb(0x30, 0x30, 0x40);--#[derive(Clone, Copy, PartialEq, Eq)]-struct Rgb(u8, u8, u8);+use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};+use crate::theme::{Plane, Rgb, Theme};  /// A mutable view over a BGRA pixel buffer. struct Canvas<'a> {@@ -38,6 +25,12 @@ impl Canvas<'_> {     }      fn fill_rect(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb) {+        self.fill_rect_a(x0, y0, w, h, c, 0xff);+    }++    /// Fill a rectangle with colour `c` at opacity `alpha`. The shm buffer is+    /// premultiplied ARGB, so a translucent fill stores `rgb * alpha`.+    fn fill_rect_a(&mut self, x0: i32, y0: i32, w: u32, h: u32, c: Rgb, alpha: u8) {         let x_start = x0.max(0) as usize;         let x_end = ((x0 + w as i32).max(0) as usize).min(self.width);         let y_start = y0.max(0) as usize;@@ -45,7 +38,9 @@ impl Canvas<'_> {         if x_start >= x_end {             return;         }-        let bytes = [c.2, c.1, c.0, 0xff];+        let a = u32::from(alpha);+        let pm = |v: u8| ((u32::from(v) * a) / 255) as u8;+        let bytes = [pm(c.2), pm(c.1), pm(c.0), alpha];         for y in y_start..y_end {             let row =                 &mut self.pixels[(y * self.width + x_start) * 4..(y * self.width + x_end) * 4];@@ -92,6 +87,15 @@ impl Canvas<'_> {     } } +/// Per-frame constants shared by every row: the colour scheme, focus, and the+/// current blink phase.+#[derive(Clone, Copy, Debug)]+pub struct Frame<'a> {+    pub theme: &'a Theme,+    pub focused: bool,+    pub blink_on: bool,+}+ #[derive(Debug)] pub struct Renderer {     fonts: Fonts,@@ -117,10 +121,10 @@ impl Renderer {         pixels: &mut [u8],         dims: (usize, usize),         grid: &Grid,+        frame: &Frame,         y: usize,-        focused: bool,-        blink_on: bool,     ) {+        let (theme, focused, blink_on) = (frame.theme, frame.focused, frame.blink_on);         let (width, height) = dims;         let mut canvas = Canvas {             pixels,@@ -130,7 +134,7 @@ impl Renderer {         let m = self.fonts.metrics();         let cols = grid.cols();         let row_top = y as i32 * m.height as i32;-        canvas.fill_rect(0, row_top, width as u32, m.height, DEFAULT_BG);+        canvas.fill_rect_a(0, row_top, width as u32, m.height, theme.bg, theme.alpha);          // Rows come through the scrollback viewport and may be shorter than         // `cols` after a resize, so clamp with `take`.@@ -146,12 +150,12 @@ impl Renderer {         for (x, cell) in cells.iter().take(cols).enumerate() {             // Focused match > selection > other match > the cell's own bg.             let bg = match match_at(x) {-                Some(true) => CURRENT_MATCH_BG,-                _ if grid.is_selected(abs, x) => SELECTION_BG,-                Some(false) => MATCH_BG,-                None => cell_colors(cell).1,+                Some(true) => theme.current_match_bg,+                _ if grid.is_selected(abs, x) => theme.selection_bg,+                Some(false) => theme.match_bg,+                None => cell_colors(cell, theme).1,             };-            if bg != DEFAULT_BG {+            if bg != theme.bg {                 canvas.fill_rect(x as i32 * m.width as i32, row_top, m.width, m.height, bg);             }         }@@ -163,24 +167,30 @@ impl Renderer {             if cell.flags.contains(Flags::BLINK) && !blink_on {                 continue;             }-            let (fg, _) = cell_colors(cell);+            let (fg, _) = cell_colors(cell, theme);             let origin_x = x as i32 * m.width as i32;             if cell.c != ' ' {                 self.draw_glyph(&mut canvas, cell.c, cell_style(cell), origin_x, row_top, fg);             }-            draw_decorations(&mut canvas, cell, origin_x, row_top, m, fg);+            draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg);         }          // The cursor belongs to the live screen; hide it while scrolled back.         if grid.view_at_bottom() && grid.cursor().1 == y {-            self.draw_cursor(&mut canvas, grid, m, focused, blink_on);+            self.draw_cursor(&mut canvas, grid, theme, m, focused, blink_on);         }     }      /// Draw the incremental-search prompt across the bottom row, over whatever     /// grid content was there. The caller marks the bottom row dirty so this     /// repaints whenever the query or match count changes.-    pub fn render_search_bar(&mut self, pixels: &mut [u8], dims: (usize, usize), text: &str) {+    pub fn render_search_bar(+        &mut self,+        pixels: &mut [u8],+        dims: (usize, usize),+        theme: &Theme,+        text: &str,+    ) {         let (width, height) = dims;         let mut canvas = Canvas {             pixels,@@ -190,7 +200,7 @@ impl Renderer {         let m = self.fonts.metrics();         let rows = (height / m.height as usize).max(1);         let row_top = (rows - 1) as i32 * m.height as i32;-        canvas.fill_rect(0, row_top, width as u32, m.height, SEARCH_BAR_BG);+        canvas.fill_rect(0, row_top, width as u32, m.height, theme.search_bar_bg);         let style = Style {             bold: false,             italic: false,@@ -201,7 +211,7 @@ impl Renderer {                 break;             }             if c != ' ' {-                self.draw_glyph(&mut canvas, c, style, x, row_top, DEFAULT_FG);+                self.draw_glyph(&mut canvas, c, style, x, row_top, theme.fg);             }             x += m.width as i32;         }@@ -213,6 +223,7 @@ impl Renderer {         &mut self,         canvas: &mut Canvas,         grid: &Grid,+        theme: &Theme,         m: CellMetrics,         focused: bool,         blink_on: bool,@@ -223,9 +234,12 @@ impl Renderer {         let (cx, cy) = grid.cursor();         let x0 = cx as i32 * m.width as i32;         let top = cy as i32 * m.height as i32;+        // OSC 12 cursor colour wins, then the configured cursor colour, then fg.         let color = grid             .cursor_color()-            .map_or(DEFAULT_FG, |(r, g, b)| Rgb(r, g, b));+            .map(|(r, g, b)| Rgb(r, g, b))+            .or(theme.cursor)+            .unwrap_or(theme.fg);          if !focused {             let right = x0 + m.width as i32 - 1;@@ -242,7 +256,7 @@ impl Renderer {                 canvas.fill_rect(x0, top, m.width, m.height, color);                 let cell = grid.cell(cx, cy);                 if cell.c != ' ' && !cell.flags.contains(Flags::WIDE_CONT) {-                    let (_, bg) = cell_colors(cell);+                    let (_, bg) = cell_colors(cell, theme);                     self.draw_glyph(canvas, cell.c, cell_style(cell), x0, top, bg);                 }             }@@ -316,10 +330,10 @@ fn cell_style(cell: &Cell) -> Style {  /// Resolve a cell's (foreground, background) RGB, applying reverse video, /// bold-as-bright, dim, and hidden.-fn cell_colors(cell: &Cell) -> (Rgb, Rgb) {+fn cell_colors(cell: &Cell, theme: &Theme) -> (Rgb, Rgb) {     let bold = cell.flags.contains(Flags::BOLD);-    let mut fg = resolve(cell.fg, DEFAULT_FG, bold);-    let mut bg = resolve(cell.bg, DEFAULT_BG, false);+    let mut fg = theme.resolve(cell.fg, Plane::Fg, bold);+    let mut bg = theme.resolve(cell.bg, Plane::Bg, false);     if cell.flags.contains(Flags::REVERSE) {         std::mem::swap(&mut fg, &mut bg);     }@@ -339,11 +353,23 @@ fn blend_rgb(c: Rgb, toward: Rgb) -> Rgb { }  /// Draw underline, strikethrough, and overline for one cell.-fn draw_decorations(canvas: &mut Canvas, cell: &Cell, x0: i32, top: i32, m: CellMetrics, fg: Rgb) {+fn draw_decorations(+    canvas: &mut Canvas,+    cell: &Cell,+    theme: &Theme,+    x0: i32,+    top: i32,+    m: CellMetrics,+    fg: Rgb,+) {     let w = m.width;     let baseline = top + m.ascent as i32;     let uy = (baseline + 1).min(top + m.height as i32 - 1);-    let uc = resolve(cell.underline_color, fg, false);+    // A `Default` underline colour follows the cell's foreground.+    let uc = match cell.underline_color {+        crate::grid::Color::Default => fg,+        other => theme.resolve(other, Plane::Fg, false),+    };     match cell.underline {         Underline::None => {}         Underline::Single => canvas.hline(x0, uy, w, uc),@@ -377,61 +403,3 @@ fn draw_decorations(canvas: &mut Canvas, cell: &Cell, x0: i32, top: i32, m: Cell         canvas.hline(x0, top + m.ascent as i32 * 2 / 3, w, fg);     } }--fn resolve(color: Color, default: Rgb, bold: bool) -> Rgb {-    match color {-        Color::Default => default,-        Color::Indexed(i) => ansi256(if bold && i < 8 { i + 8 } else { i }),-        Color::Rgb(r, g, b) => Rgb(r, g, b),-    }-}--/// The xterm 256-colour palette: 16 base, a 6×6×6 cube, then 24 greys.-fn ansi256(i: u8) -> Rgb {-    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),-    ];-    match i {-        0..=15 => BASE[i as usize],-        16..=231 => {-            let i = i - 16;-            Rgb(cube(i / 36), cube((i / 6) % 6), cube(i % 6))-        }-        _ => {-            let v = 8 + 10 * (i - 232);-            Rgb(v, v, v)-        }-    }-}--fn cube(step: u8) -> u8 {-    if step == 0 { 0 } else { 55 + 40 * step }-}--#[cfg(test)]-mod tests {-    use super::*;--    #[test]-    fn palette_cube_and_grey() {-        assert_eq!(ansi256(16).0, 0); // cube origin is black-        let white = ansi256(231);-        assert_eq!((white.0, white.1, white.2), (255, 255, 255));-        assert_eq!(ansi256(232).0, 8); // first grey step-    }-}diff --git a/src/theme.rs b/src/theme.rsnew file mode 100644index 0000000..ac627ee--- /dev/null+++ b/src/theme.rs@@ -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));+    }+}diff --git a/src/vt.rs b/src/vt.rsindex 69f71b1..685162e 100644--- a/src/vt.rs+++ b/src/vt.rs@@ -5,6 +5,7 @@ use std::io::Write as _; use vte::{Params, Perform};  use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline};+use crate::theme::{Rgb, Theme};  /// G0/G1 character set designation. #[derive(Clone, Copy, PartialEq, Eq, Debug)]@@ -31,11 +32,36 @@ pub enum ClipboardOp {     Query { primary: bool }, } +/// Which dynamic colour an OSC 10/11/17/19 escape targets.+#[derive(Clone, Copy, Debug)]+enum Dynamic {+    Fg,+    Bg,+    SelBg,+    SelFg,+}+ /// DECRQM mode-state code: 1 = set, 2 = reset. fn set_reset(on: bool) -> u8 {     if on { 1 } else { 2 } } +/// Parse an OSC colour spec into an [`Rgb`].+fn parse_spec(spec: &[u8]) -> Option<Rgb> {+    std::str::from_utf8(spec)+        .ok()+        .and_then(crate::theme::parse_color)+}++/// Parse a decimal palette index (0-255).+fn parse_index(b: &[u8]) -> Option<u8> {+    std::str::from_utf8(b).ok()?.parse().ok()+}++fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) {+    (rgb.0, rgb.1, rgb.2)+}+ /// Select `protocol` when a mouse mode is set, else turn reporting off. fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol {     if on { protocol } else { MouseProtocol::Off }@@ -46,31 +72,6 @@ fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {     if on { encoding } else { MouseEncoding::X10 } } -/// Parse an X11 colour spec from an OSC string: `rgb:rr/gg/bb` (1-4 hex-/// digits per channel) or `#rrggbb`.-fn parse_color(spec: &[u8]) -> Option<(u8, u8, u8)> {-    let spec = std::str::from_utf8(spec).ok()?;-    if let Some(rest) = spec.strip_prefix("rgb:") {-        let mut it = rest.split('/');-        let chan = |s: &str| -> Option<u8> {-            // Normalize an n-hex-digit fraction to 8 bits (X11 rule).-            let v = u32::from_str_radix(s, 16).ok()?;-            let max = (1u32 << (4 * s.len() as u32)) - 1;-            Some((v * 255 / max) as u8)-        };-        let r = chan(it.next()?)?;-        let g = chan(it.next()?)?;-        let b = chan(it.next()?)?;-        return Some((r, g, b));-    }-    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((byte(0)?, byte(2)?, byte(4)?));-    }-    None-}- /// Map an SGR 4 param (`4` or `4:x`) to an underline style. fn underline_from(param: &[u16]) -> Underline {     match param.get(1).copied().unwrap_or(1) {@@ -97,6 +98,8 @@ pub struct Term {     xtgettcap: Option<Vec<u8>>,     /// Pending OSC 52 clipboard requests, drained by the front-end.     clipboard_ops: Vec<ClipboardOp>,+    /// The active colour scheme (seeded from config, mutated by OSC escapes).+    theme: Theme, }  impl Term {@@ -111,6 +114,7 @@ impl Term {             shift_out: false,             xtgettcap: None,             clipboard_ops: Vec::new(),+            theme: Theme::default(),         }     } @@ -119,6 +123,15 @@ impl Term {         std::mem::take(&mut self.clipboard_ops)     } +    pub fn theme(&self) -> &Theme {+        &self.theme+    }++    /// Replace the colour scheme (config load / reload).+    pub fn set_theme(&mut self, theme: Theme) {+        self.theme = theme;+    }+     /// Answer an XTGETTCAP query: for each hex-encoded capability name, reply     /// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.     fn answer_xtgettcap(&mut self, payload: &[u8]) {@@ -300,6 +313,63 @@ impl Term {         );     } +    /// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator).+    fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) {+        let Rgb(r, g, b) = rgb;+        let _ = write!(+            self.response,+            "\x1b]{code};rgb:{:04x}/{:04x}/{:04x}",+            u16::from(r) * 0x101,+            u16::from(g) * 0x101,+            u16::from(b) * 0x101,+        );+        self.response+            .extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" });+    }++    /// OSC 4: set or query palette entries, given as `index;spec` pairs.+    fn osc_palette(&mut self, params: &[&[u8]], bell: bool) {+        let mut rest = params[1..].iter();+        while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) {+            let Some(idx) = parse_index(idx_raw) else {+                continue;+            };+            if *spec == b"?" {+                let rgb = self.theme.palette[idx as usize];+                self.reply_color(&format!("4;{idx}"), rgb, bell);+            } else if let Some(rgb) = parse_spec(spec) {+                self.theme.set_palette(idx, rgb);+            }+        }+    }++    /// OSC 10/11/17/19: set or query a dynamic colour.+    fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) {+        let Some(spec) = spec else { return };+        if **spec == b"?"[..] {+            let rgb = match kind {+                Dynamic::Fg => self.theme.fg,+                Dynamic::Bg => self.theme.bg,+                Dynamic::SelBg => self.theme.selection_bg,+                Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg),+            };+            let code = match kind {+                Dynamic::Fg => "10",+                Dynamic::Bg => "11",+                Dynamic::SelBg => "17",+                Dynamic::SelFg => "19",+            };+            self.reply_color(code, rgb, bell);+        } else if let Some(rgb) = parse_spec(spec) {+            match kind {+                Dynamic::Fg => self.theme.fg = rgb,+                Dynamic::Bg => self.theme.bg = rgb,+                Dynamic::SelBg => self.theme.selection_bg = rgb,+                Dynamic::SelFg => self.theme.selection_fg = Some(rgb),+            }+        }+    }+     fn device_status(&mut self, params: &Params) {         match params.iter().next().and_then(|p| p.first().copied()) {             Some(5) => self.response.extend_from_slice(b"\x1b[0n"),@@ -567,17 +637,39 @@ impl Perform for Term {         }     } -    fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {+    fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {         match params.first() {             Some(&n) if n == b"0" || n == b"2" => {                 if let Some(text) = params.get(1) {                     self.title = Some(String::from_utf8_lossy(text).into_owned());                 }             }+            // OSC 4: set/query palette entries (pairs of index;spec).+            Some(&n) if n == b"4" => self.osc_palette(params, bell),+            // OSC 104: reset palette (all, or the listed indices).+            Some(&n) if n == b"104" => {+                if params.len() <= 1 {+                    self.theme.reset_palette();+                } else {+                    for p in &params[1..] {+                        if let Some(i) = parse_index(p) {+                            self.theme.reset_palette_index(i);+                        }+                    }+                }+            }+            // OSC 10/11: foreground / background; OSC 110/111 reset them.+            Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),+            Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),+            Some(&n) if n == b"110" => self.theme.reset_fg(),+            Some(&n) if n == b"111" => self.theme.reset_bg(),+            // OSC 17/19: selection (highlight) background / foreground.+            Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),+            Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),             // OSC 12: set cursor colour; OSC 112: reset to default.             Some(&n) if n == b"12" => {                 self.grid-                    .set_cursor_color(params.get(1).and_then(|s| parse_color(s)));+                    .set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));             }             Some(&n) if n == b"112" => self.grid.set_cursor_color(None),             // OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or@@ -826,11 +918,20 @@ mod tests {     }      #[test]-    fn parse_color_forms() {-        assert_eq!(parse_color(b"rgb:ff/00/80"), Some((255, 0, 128)));-        assert_eq!(parse_color(b"rgb:ffff/0000/8080"), Some((255, 0, 128)));-        assert_eq!(parse_color(b"#ff0080"), Some((255, 0, 128)));-        assert_eq!(parse_color(b"nonsense"), None);+    fn osc_palette_and_dynamic_colors() {+        use crate::theme::Rgb;+        let mut t = Term::new(20, 2);+        // Set palette index 1 and foreground via OSC, then query them back.+        feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\");+        assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0));+        feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\");+        assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff));+        feed(&mut t, b"\x1b]11;?\x07");+        let resp = t.take_response();+        assert!(resp.starts_with(b"\x1b]11;rgb:"));+        // Reset returns the palette entry to its default.+        feed(&mut t, b"\x1b]104;1\x1b\\");+        assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0));     }      #[test]diff --git a/src/wayland.rs b/src/wayland.rsindex 990efd8..e508dd5 100644--- a/src/wayland.rs+++ b/src/wayland.rs@@ -438,6 +438,7 @@ impl App {         }          let mut term = Term::new(cols as usize, rows as usize);+        term.set_theme(crate::theme::Theme::from_config(&self.config.colors));         let grid = term.grid_mut();         grid.set_word_delimiters(self.config.main.word_delimiters.clone());         grid.set_scrollback_cap(self.config.scrollback.lines);@@ -1054,6 +1055,7 @@ impl App {             return;         };         let grid = session.term.grid();+        let theme = session.term.theme();         let rows = grid.rows();         let mut cur: Vec<RowSnap> = (0..rows)             .map(|y| row_snap(grid, y, focused, blink_on))@@ -1124,15 +1126,19 @@ impl App {             return;         };         let dims = (w as usize, h as usize);+        let frame = crate::render::Frame {+            theme,+            focused,+            blink_on,+        };         for &y in &dirty {-            self.renderer-                .render_row(canvas, dims, grid, y, focused, blink_on);+            self.renderer.render_row(canvas, dims, grid, &frame, y);         }         // Draw the search prompt over the (now repainted) bottom row.         if let Some(text) = &bar_text             && dirty.contains(&(rows - 1))         {-            self.renderer.render_search_bar(canvas, dims, text);+            self.renderer.render_search_bar(canvas, dims, theme, text);         }         self.frames[idx].rows = cur;