diff --git a/src/grid.rs b/src/grid.rs index 27a7b85..9e50ba2 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -20,13 +20,13 @@ impl Flags { pub const BOLD: Self = Self(1 << 0); pub const DIM: Self = Self(1 << 1); pub const ITALIC: Self = Self(1 << 2); - pub const UNDERLINE: Self = Self(1 << 3); pub const BLINK: Self = Self(1 << 4); pub const REVERSE: Self = Self(1 << 5); pub const HIDDEN: Self = Self(1 << 6); pub const STRIKE: Self = Self(1 << 7); /// Trailing column of a double-width glyph; holds no character of its own. pub const WIDE_CONT: Self = Self(1 << 8); + pub const OVERLINE: Self = Self(1 << 9); pub const fn empty() -> Self { 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. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Cell { @@ -56,6 +68,9 @@ pub struct Cell { pub fg: Color, pub bg: Color, pub flags: Flags, + pub underline: Underline, + /// Underline colour; `Default` means "follow the foreground". + pub underline_color: Color, } impl Default for Cell { @@ -65,6 +80,8 @@ impl Default for Cell { fg: Color::Default, bg: Color::Default, flags: Flags::empty(), + underline: Underline::None, + underline_color: Color::Default, } } } @@ -259,11 +276,10 @@ impl Grid { } fn pen_blank(&self) -> Cell { + // A space carrying only the current background (back-colour erase). Cell { - c: ' ', - fg: Color::Default, bg: self.pen.bg, - flags: Flags::empty(), + ..Cell::default() } } diff --git a/src/render.rs b/src/render.rs index 7419a71..40d4208 100644 --- a/src/render.rs +++ b/src/render.rs @@ -6,7 +6,7 @@ //! neighbouring cell's background fill. 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`. const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6); @@ -54,6 +54,20 @@ impl Canvas<'_> { 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. fn over(&mut self, x: i32, y: i32, src: &[u8]) { let Some(i) = self.index(x, y) else { return }; @@ -104,17 +118,27 @@ impl Renderer { for y in 0..grid.rows() { for x in 0..grid.cols() { let cell = grid.cell(x, y); - if cell.flags.contains(Flags::WIDE_CONT) || cell.c == ' ' { + if cell.flags.contains(Flags::WIDE_CONT) { continue; } 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 baseline = y as i32 * m.height as i32 + m.ascent as i32; - self.draw_glyph(&mut canvas, cell.c, style, origin_x, baseline, fg); + let cell_top = y as i32 * m.height as i32; + 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, -/// bold-as-bright for the foreground, and hidden. -fn cell_colors(cell: &crate::grid::Cell, cursor: bool) -> (Rgb, Rgb) { +/// bold-as-bright, dim, and hidden. +fn cell_colors(cell: &Cell, cursor: bool) -> (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); if cell.flags.contains(Flags::REVERSE) ^ cursor { std::mem::swap(&mut fg, &mut bg); } + if cell.flags.contains(Flags::DIM) { + fg = blend_rgb(fg, bg); + } if cell.flags.contains(Flags::HIDDEN) { 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 { match color { Color::Default => default, diff --git a/src/vt.rs b/src/vt.rs index 4eea03b..ac0e34e 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -4,7 +4,7 @@ use std::io::Write as _; use vte::{Params, Perform}; -use crate::grid::{Color, Flags, Grid}; +use crate::grid::{Color, Flags, Grid, Underline}; /// G0/G1 character set designation. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -26,6 +26,18 @@ fn set_reset(on: bool) -> u8 { 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. #[derive(Debug)] pub struct Term { @@ -120,14 +132,15 @@ impl Term { 1 => pen.flags.insert(Flags::BOLD), 2 => pen.flags.insert(Flags::DIM), 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), 7 => pen.flags.insert(Flags::REVERSE), 8 => pen.flags.insert(Flags::HIDDEN), 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), - 24 => pen.flags.remove(Flags::UNDERLINE), + 24 => pen.underline = Underline::None, 25 => pen.flags.remove(Flags::BLINK), 27 => pen.flags.remove(Flags::REVERSE), 28 => pen.flags.remove(Flags::HIDDEN), @@ -136,20 +149,22 @@ impl Term { 39 => pen.fg = Color::Default, 40..=47 => pen.bg = Color::Indexed((code - 40) as u8), 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), 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8), - 38 | 48 => { + 38 | 48 | 58 => { let (color, consumed) = ext_color(&items, i); if let Some(color) = color { - if code == 38 { - pen.fg = color; - } else { - pen.bg = color; + match code { + 38 => pen.fg = color, + 48 => pen.bg = color, + _ => pen.underline_color = color, } } step = consumed; } - // 58/59 (underline colour) and others: no storage yet. + 59 => pen.underline_color = Color::Default, _ => {} } i += step; @@ -501,6 +516,19 @@ mod tests { 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] fn title_stack_push_pop() { let mut t = Term::new(20, 4);