diff --git a/src/config.rs b/src/config.rs index 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, + pub background: Option, + pub cursor: Option, + pub selection_foreground: Option, + pub selection_background: Option, + /// The eight regular palette entries (indices 0-7). + pub regular: Option>, + /// The eight bright palette entries (indices 8-15). + pub bright: Option>, + pub match_background: Option, + pub match_current_background: Option, + /// Background opacity, 0.0 (transparent) - 1.0 (opaque). + pub alpha: Option, + /// Render bold text with the bright palette variant. + pub bold_as_bright: Option, +} + /// `[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.rs index 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.rs index 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.rs new file mode 100644 index 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, + pub selection_fg: Option, + 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| { + 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 { + if let Some(rest) = spec.strip_prefix("rgb:") { + let mut it = rest.split('/'); + let chan = |s: &str| -> Option { + 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.rs index 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 { + 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 { + 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 { - // 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>, /// Pending OSC 52 clipboard requests, drained by the front-end. clipboard_ops: Vec, + /// 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 ¶ms[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.rs index 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 = (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;