render: draw underline styles, strike, overline, and dim

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0cf6a44446240a59c2fc8c6735afaf1d6a6a6964
This commit is contained in:
raf 2026-06-24 09:48:24 +03:00
commit 2afb4875be
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
3 changed files with 141 additions and 24 deletions

View file

@ -20,13 +20,13 @@ impl Flags {
pub const BOLD: Self = Self(1 << 0); pub const BOLD: Self = Self(1 << 0);
pub const DIM: Self = Self(1 << 1); pub const DIM: Self = Self(1 << 1);
pub const ITALIC: Self = Self(1 << 2); pub const ITALIC: Self = Self(1 << 2);
pub const UNDERLINE: Self = Self(1 << 3);
pub const BLINK: Self = Self(1 << 4); pub const BLINK: Self = Self(1 << 4);
pub const REVERSE: Self = Self(1 << 5); pub const REVERSE: Self = Self(1 << 5);
pub const HIDDEN: Self = Self(1 << 6); pub const HIDDEN: Self = Self(1 << 6);
pub const STRIKE: Self = Self(1 << 7); pub const STRIKE: Self = Self(1 << 7);
/// Trailing column of a double-width glyph; holds no character of its own. /// Trailing column of a double-width glyph; holds no character of its own.
pub const WIDE_CONT: Self = Self(1 << 8); pub const WIDE_CONT: Self = Self(1 << 8);
pub const OVERLINE: Self = Self(1 << 9);
pub const fn empty() -> Self { pub const fn empty() -> Self {
Self(0) Self(0)
@ -49,6 +49,18 @@ impl Flags {
} }
} }
/// Underline style (SGR 4 / 4:x / 21).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Underline {
#[default]
None,
Single,
Double,
Curly,
Dotted,
Dashed,
}
/// One grid cell: a character plus its rendering style. /// One grid cell: a character plus its rendering style.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Cell { pub struct Cell {
@ -56,6 +68,9 @@ pub struct Cell {
pub fg: Color, pub fg: Color,
pub bg: Color, pub bg: Color,
pub flags: Flags, pub flags: Flags,
pub underline: Underline,
/// Underline colour; `Default` means "follow the foreground".
pub underline_color: Color,
} }
impl Default for Cell { impl Default for Cell {
@ -65,6 +80,8 @@ impl Default for Cell {
fg: Color::Default, fg: Color::Default,
bg: Color::Default, bg: Color::Default,
flags: Flags::empty(), flags: Flags::empty(),
underline: Underline::None,
underline_color: Color::Default,
} }
} }
} }
@ -259,11 +276,10 @@ impl Grid {
} }
fn pen_blank(&self) -> Cell { fn pen_blank(&self) -> Cell {
// A space carrying only the current background (back-colour erase).
Cell { Cell {
c: ' ',
fg: Color::Default,
bg: self.pen.bg, bg: self.pen.bg,
flags: Flags::empty(), ..Cell::default()
} }
} }

View file

@ -6,7 +6,7 @@
//! neighbouring cell's background fill. //! neighbouring cell's background fill.
use crate::font::{CellMetrics, Fonts, GlyphData, Style}; use crate::font::{CellMetrics, Fonts, GlyphData, Style};
use crate::grid::{Color, Flags, Grid}; use crate::grid::{Cell, Color, Flags, Grid, Underline};
/// Foreground/background used for `Color::Default`. /// Foreground/background used for `Color::Default`.
const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6); const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);
@ -54,6 +54,20 @@ impl Canvas<'_> {
self.pixels[i + 3] = 0xff; self.pixels[i + 3] = 0xff;
} }
/// Set a single opaque pixel.
fn put(&mut self, x: i32, y: i32, c: Rgb) {
if let Some(i) = self.index(x, y) {
self.pixels[i] = c.2;
self.pixels[i + 1] = c.1;
self.pixels[i + 2] = c.0;
self.pixels[i + 3] = 0xff;
}
}
fn hline(&mut self, x0: i32, y: i32, w: u32, c: Rgb) {
self.fill_rect(x0, y, w, 1, c);
}
/// Composite one pre-multiplied BGRA source pixel over the destination. /// Composite one pre-multiplied BGRA source pixel over the destination.
fn over(&mut self, x: i32, y: i32, src: &[u8]) { fn over(&mut self, x: i32, y: i32, src: &[u8]) {
let Some(i) = self.index(x, y) else { return }; let Some(i) = self.index(x, y) else { return };
@ -104,17 +118,27 @@ impl Renderer {
for y in 0..grid.rows() { for y in 0..grid.rows() {
for x in 0..grid.cols() { for x in 0..grid.cols() {
let cell = grid.cell(x, y); let cell = grid.cell(x, y);
if cell.flags.contains(Flags::WIDE_CONT) || cell.c == ' ' { if cell.flags.contains(Flags::WIDE_CONT) {
continue; continue;
} }
let (fg, _) = cell_colors(cell, (x, y) == cursor); let (fg, _) = cell_colors(cell, (x, y) == cursor);
let style = Style {
bold: cell.flags.contains(Flags::BOLD),
italic: cell.flags.contains(Flags::ITALIC),
};
let origin_x = x as i32 * m.width as i32; let origin_x = x as i32 * m.width as i32;
let baseline = y as i32 * m.height as i32 + m.ascent as i32; let cell_top = y as i32 * m.height as i32;
self.draw_glyph(&mut canvas, cell.c, style, origin_x, baseline, fg); if cell.c != ' ' {
let style = Style {
bold: cell.flags.contains(Flags::BOLD),
italic: cell.flags.contains(Flags::ITALIC),
};
self.draw_glyph(
&mut canvas,
cell.c,
style,
origin_x,
cell_top + m.ascent as i32,
fg,
);
}
draw_decorations(&mut canvas, cell, origin_x, cell_top, m, fg);
} }
} }
} }
@ -165,20 +189,69 @@ impl Renderer {
} }
/// Resolve a cell's (foreground, background) RGB, applying reverse video, /// Resolve a cell's (foreground, background) RGB, applying reverse video,
/// bold-as-bright for the foreground, and hidden. /// bold-as-bright, dim, and hidden.
fn cell_colors(cell: &crate::grid::Cell, cursor: bool) -> (Rgb, Rgb) { fn cell_colors(cell: &Cell, cursor: bool) -> (Rgb, Rgb) {
let bold = cell.flags.contains(Flags::BOLD); let bold = cell.flags.contains(Flags::BOLD);
let mut fg = resolve(cell.fg, DEFAULT_FG, bold); let mut fg = resolve(cell.fg, DEFAULT_FG, bold);
let mut bg = resolve(cell.bg, DEFAULT_BG, false); let mut bg = resolve(cell.bg, DEFAULT_BG, false);
if cell.flags.contains(Flags::REVERSE) ^ cursor { if cell.flags.contains(Flags::REVERSE) ^ cursor {
std::mem::swap(&mut fg, &mut bg); std::mem::swap(&mut fg, &mut bg);
} }
if cell.flags.contains(Flags::DIM) {
fg = blend_rgb(fg, bg);
}
if cell.flags.contains(Flags::HIDDEN) { if cell.flags.contains(Flags::HIDDEN) {
fg = bg; fg = bg;
} }
(fg, bg) (fg, bg)
} }
/// Mix `c` two-thirds of the way from `toward`, used for the dim attribute.
fn blend_rgb(c: Rgb, toward: Rgb) -> Rgb {
let mix = |a: u8, b: u8| ((u32::from(a) * 2 + u32::from(b)) / 3) as u8;
Rgb(mix(c.0, toward.0), mix(c.1, toward.1), mix(c.2, toward.2))
}
/// Draw underline, strikethrough, and overline for one cell.
fn draw_decorations(canvas: &mut Canvas, cell: &Cell, 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);
match cell.underline {
Underline::None => {}
Underline::Single => canvas.hline(x0, uy, w, uc),
Underline::Double => {
canvas.hline(x0, uy, w, uc);
canvas.hline(x0, (uy - 2).max(top), w, uc);
}
Underline::Curly => {
for dx in 0..w as i32 {
let wobble = if (dx / 2) % 2 == 0 { 0 } else { 1 };
canvas.put(x0 + dx, uy - wobble, uc);
}
}
Underline::Dotted => {
for dx in (0..w as i32).step_by(2) {
canvas.put(x0 + dx, uy, uc);
}
}
Underline::Dashed => {
for dx in 0..w as i32 {
if (dx / 3) % 2 == 0 {
canvas.put(x0 + dx, uy, uc);
}
}
}
}
if cell.flags.contains(Flags::OVERLINE) {
canvas.hline(x0, top, w, fg);
}
if cell.flags.contains(Flags::STRIKE) {
canvas.hline(x0, top + m.ascent as i32 * 2 / 3, w, fg);
}
}
fn resolve(color: Color, default: Rgb, bold: bool) -> Rgb { fn resolve(color: Color, default: Rgb, bold: bool) -> Rgb {
match color { match color {
Color::Default => default, Color::Default => default,

View file

@ -4,7 +4,7 @@ use std::io::Write as _;
use vte::{Params, Perform}; use vte::{Params, Perform};
use crate::grid::{Color, Flags, Grid}; use crate::grid::{Color, Flags, Grid, Underline};
/// G0/G1 character set designation. /// G0/G1 character set designation.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
@ -26,6 +26,18 @@ fn set_reset(on: bool) -> u8 {
if on { 1 } else { 2 } if on { 1 } else { 2 }
} }
/// 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) {
0 => Underline::None,
2 => Underline::Double,
3 => Underline::Curly,
4 => Underline::Dotted,
5 => Underline::Dashed,
_ => Underline::Single,
}
}
/// The terminal model: a grid plus the escape-sequence state around it. /// The terminal model: a grid plus the escape-sequence state around it.
#[derive(Debug)] #[derive(Debug)]
pub struct Term { pub struct Term {
@ -120,14 +132,15 @@ impl Term {
1 => pen.flags.insert(Flags::BOLD), 1 => pen.flags.insert(Flags::BOLD),
2 => pen.flags.insert(Flags::DIM), 2 => pen.flags.insert(Flags::DIM),
3 => pen.flags.insert(Flags::ITALIC), 3 => pen.flags.insert(Flags::ITALIC),
4 => pen.flags.insert(Flags::UNDERLINE), 4 => pen.underline = underline_from(p),
5 | 6 => pen.flags.insert(Flags::BLINK), 5 | 6 => pen.flags.insert(Flags::BLINK),
7 => pen.flags.insert(Flags::REVERSE), 7 => pen.flags.insert(Flags::REVERSE),
8 => pen.flags.insert(Flags::HIDDEN), 8 => pen.flags.insert(Flags::HIDDEN),
9 => pen.flags.insert(Flags::STRIKE), 9 => pen.flags.insert(Flags::STRIKE),
21 | 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)), 21 => pen.underline = Underline::Double,
22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)),
23 => pen.flags.remove(Flags::ITALIC), 23 => pen.flags.remove(Flags::ITALIC),
24 => pen.flags.remove(Flags::UNDERLINE), 24 => pen.underline = Underline::None,
25 => pen.flags.remove(Flags::BLINK), 25 => pen.flags.remove(Flags::BLINK),
27 => pen.flags.remove(Flags::REVERSE), 27 => pen.flags.remove(Flags::REVERSE),
28 => pen.flags.remove(Flags::HIDDEN), 28 => pen.flags.remove(Flags::HIDDEN),
@ -136,20 +149,22 @@ impl Term {
39 => pen.fg = Color::Default, 39 => pen.fg = Color::Default,
40..=47 => pen.bg = Color::Indexed((code - 40) as u8), 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),
49 => pen.bg = Color::Default, 49 => pen.bg = Color::Default,
53 => pen.flags.insert(Flags::OVERLINE),
55 => pen.flags.remove(Flags::OVERLINE),
90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8), 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),
100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8), 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),
38 | 48 => { 38 | 48 | 58 => {
let (color, consumed) = ext_color(&items, i); let (color, consumed) = ext_color(&items, i);
if let Some(color) = color { if let Some(color) = color {
if code == 38 { match code {
pen.fg = color; 38 => pen.fg = color,
} else { 48 => pen.bg = color,
pen.bg = color; _ => pen.underline_color = color,
} }
} }
step = consumed; step = consumed;
} }
// 58/59 (underline colour) and others: no storage yet. 59 => pen.underline_color = Color::Default,
_ => {} _ => {}
} }
i += step; i += step;
@ -501,6 +516,19 @@ mod tests {
assert_eq!(t.take_response(), b"\x1b[?9999;0$y"); assert_eq!(t.take_response(), b"\x1b[?9999;0$y");
} }
#[test]
fn sgr_underline_styles_and_lines() {
let mut t = Term::new(20, 1);
feed(&mut t, b"\x1b[4:3;58;5;1;53mX");
let cell = t.grid().cell(0, 0);
assert_eq!(cell.underline, Underline::Curly);
assert_eq!(cell.underline_color, Color::Indexed(1));
assert!(cell.flags.contains(Flags::OVERLINE));
// 4:0 turns the underline back off.
feed(&mut t, b"\x1b[4:0mY");
assert_eq!(t.grid().cell(1, 0).underline, Underline::None);
}
#[test] #[test]
fn title_stack_push_pop() { fn title_stack_push_pop() {
let mut t = Term::new(20, 4); let mut t = Term::new(20, 4);