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
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod grid;
|
|||
mod input;
|
||||
mod pty;
|
||||
mod render;
|
||||
mod theme;
|
||||
mod vt;
|
||||
mod wayland;
|
||||
|
||||
|
|
|
|||
158
src/render.rs
158
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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));
|
||||
}
|
||||
}
|
||||
165
src/vt.rs
165
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 ¶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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue