brewery
notashelf /
88df7c2404ed886fa80ed702b8fd39cbd47310a7

beer

public

A terminal worth pouring time into

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

Commit 88df7c2404ed

tarball

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

unverified4 files changed+213-17
render: cursor shapes, visibility, and focus

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iad508cceb2c8417147ad71b5c1ffc4bc6a6a6964
diff --git a/src/grid.rs b/src/grid.rsindex 9e50ba2..d735107 100644--- a/src/grid.rs+++ b/src/grid.rs@@ -61,6 +61,15 @@ pub enum Underline {     Dashed, } +/// Cursor shape (DECSCUSR).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum CursorShape {+    #[default]+    Block,+    Underline,+    Beam,+}+ /// One grid cell: a character plus its rendering style. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Cell {@@ -113,6 +122,10 @@ pub struct Grid {     tabs: Vec<bool>,     /// Saved primary screen while the alternate screen is active.     alt_saved: Option<Vec<Vec<Cell>>>,+    cursor_shape: CursorShape,+    cursor_visible: bool,+    /// Cursor colour from OSC 12; `None` follows the cell under the cursor.+    cursor_color: Option<(u8, u8, u8)>, }  fn default_tabs(cols: usize) -> Vec<bool> {@@ -138,6 +151,9 @@ impl Grid {             wrap_pending: false,             tabs: default_tabs(cols),             alt_saved: None,+            cursor_shape: CursorShape::default(),+            cursor_visible: true,+            cursor_color: None,         }     } @@ -210,6 +226,30 @@ impl Grid {         self.alt_saved.is_some()     } +    pub fn set_cursor_shape(&mut self, shape: CursorShape) {+        self.cursor_shape = shape;+    }++    pub fn cursor_shape(&self) -> CursorShape {+        self.cursor_shape+    }++    pub fn set_cursor_visible(&mut self, visible: bool) {+        self.cursor_visible = visible;+    }++    pub fn cursor_visible(&self) -> bool {+        self.cursor_visible+    }++    pub fn set_cursor_color(&mut self, color: Option<(u8, u8, u8)>) {+        self.cursor_color = color;+    }++    pub fn cursor_color(&self) -> Option<(u8, u8, u8)> {+        self.cursor_color+    }+     // --- printing ---      /// Place a printable character at the cursor, honouring width and autowrap.diff --git a/src/render.rs b/src/render.rsindex 40d4208..19baab8 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::{Cell, Color, Flags, Grid, Underline};+use crate::grid::{Cell, Color, CursorShape, Flags, Grid, Underline};  /// Foreground/background used for `Color::Default`. const DEFAULT_FG: Rgb = Rgb(0xc5, 0xc8, 0xc6);@@ -94,9 +94,16 @@ impl Renderer {         self.fonts.metrics()     } -    /// Compose `grid` into `pixels` (BGRA, `width`×`height` px). The cursor cell-    /// is drawn reversed.-    pub fn render(&mut self, grid: &Grid, pixels: &mut [u8], width: usize, height: usize) {+    /// Compose `grid` into `pixels` (BGRA, `width`×`height` px). `focused`+    /// selects a solid or hollow cursor.+    pub fn render(+        &mut self,+        grid: &Grid,+        pixels: &mut [u8],+        width: usize,+        height: usize,+        focused: bool,+    ) {         let mut canvas = Canvas {             pixels,             width,@@ -105,11 +112,10 @@ impl Renderer {         canvas.fill_rect(0, 0, width as u32, height as u32, DEFAULT_BG);          let m = self.fonts.metrics();-        let cursor = grid.cursor();          for y in 0..grid.rows() {             for x in 0..grid.cols() {-                let (_, bg) = cell_colors(grid.cell(x, y), (x, y) == cursor);+                let (_, bg) = cell_colors(grid.cell(x, y));                 let (px, py) = (x as i32 * m.width as i32, y as i32 * m.height as i32);                 canvas.fill_rect(px, py, m.width, m.height, bg);             }@@ -121,18 +127,14 @@ impl Renderer {                 if cell.flags.contains(Flags::WIDE_CONT) {                     continue;                 }-                let (fg, _) = cell_colors(cell, (x, y) == cursor);+                let (fg, _) = cell_colors(cell);                 let origin_x = x as i32 * m.width as i32;                 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,+                        cell_style(cell),                         origin_x,                         cell_top + m.ascent as i32,                         fg,@@ -141,6 +143,54 @@ impl Renderer {                 draw_decorations(&mut canvas, cell, origin_x, cell_top, m, fg);             }         }++        self.draw_cursor(&mut canvas, grid, m, focused);+    }++    /// Draw the cursor: a solid block/underline/beam when focused, a hollow+    /// outline when not.+    fn draw_cursor(&mut self, canvas: &mut Canvas, grid: &Grid, m: CellMetrics, focused: bool) {+        if !grid.cursor_visible() {+            return;+        }+        let (cx, cy) = grid.cursor();+        let x0 = cx as i32 * m.width as i32;+        let top = cy as i32 * m.height as i32;+        let color = grid+            .cursor_color()+            .map_or(DEFAULT_FG, |(r, g, b)| Rgb(r, g, b));++        if !focused {+            let right = x0 + m.width as i32 - 1;+            let bottom = top + m.height as i32 - 1;+            canvas.hline(x0, top, m.width, color);+            canvas.hline(x0, bottom, m.width, color);+            canvas.fill_rect(x0, top, 1, m.height, color);+            canvas.fill_rect(right, top, 1, m.height, color);+            return;+        }++        match grid.cursor_shape() {+            CursorShape::Block => {+                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);+                    self.draw_glyph(+                        canvas,+                        cell.c,+                        cell_style(cell),+                        x0,+                        top + m.ascent as i32,+                        bg,+                    );+                }+            }+            CursorShape::Underline => {+                canvas.fill_rect(x0, top + m.height as i32 - 2, m.width, 2, color);+            }+            CursorShape::Beam => canvas.fill_rect(x0, top, 2, m.height, color),+        }     }      fn draw_glyph(@@ -188,13 +238,20 @@ impl Renderer {     } } +fn cell_style(cell: &Cell) -> Style {+    Style {+        bold: cell.flags.contains(Flags::BOLD),+        italic: cell.flags.contains(Flags::ITALIC),+    }+}+ /// Resolve a cell's (foreground, background) RGB, applying reverse video, /// bold-as-bright, dim, and hidden.-fn cell_colors(cell: &Cell, cursor: bool) -> (Rgb, Rgb) {+fn cell_colors(cell: &Cell) -> (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 {+    if cell.flags.contains(Flags::REVERSE) {         std::mem::swap(&mut fg, &mut bg);     }     if cell.flags.contains(Flags::DIM) {diff --git a/src/vt.rs b/src/vt.rsindex ac0e34e..1166164 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, Underline};+use crate::grid::{Color, CursorShape, Flags, Grid, Underline};  /// G0/G1 character set designation. #[derive(Clone, Copy, PartialEq, Eq, Debug)]@@ -26,6 +26,31 @@ fn set_reset(on: bool) -> u8 {     if on { 1 } else { 2 } } +/// 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) {@@ -108,6 +133,7 @@ impl Term {                     }                 }                 (false, 4) => self.grid.set_insert(on),+                (true, 25) => self.grid.set_cursor_visible(on),                 // App-cursor/bracketed-paste/mouse/sync modes affect input and                 // rendering, which arrive with the keyboard and renderer.                 _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),@@ -400,6 +426,13 @@ impl Perform for Term {                 _ => DaLevel::Primary,             }),             'q' if intermediates.first() == Some(&b'>') => self.report_version(),+            'q' if intermediates.first() == Some(&b' ') => {+                self.grid.set_cursor_shape(match raw(params, 0) {+                    3 | 4 => CursorShape::Underline,+                    5 | 6 => CursorShape::Beam,+                    _ => CursorShape::Block,+                });+            }             'p' if intermediates.contains(&b'$') => self.report_mode(params, private),             'n' => self.device_status(params),             's' => self.grid.save_cursor(),@@ -445,6 +478,12 @@ impl Perform for Term {                     self.title = Some(String::from_utf8_lossy(text).into_owned());                 }             }+            // 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)));+            }+            Some(&n) if n == b"112" => self.grid.set_cursor_color(None),             _ => {}         }     }@@ -530,6 +569,57 @@ mod tests {     }      #[test]+    fn decscusr_and_cursor_visibility() {+        let mut t = Term::new(20, 1);+        feed(&mut t, b"\x1b[4 q");+        assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);+        feed(&mut t, b"\x1b[6 q");+        assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);+        feed(&mut t, b"\x1b[0 q");+        assert_eq!(t.grid().cursor_shape(), CursorShape::Block);++        feed(&mut t, b"\x1b[?25l");+        assert!(!t.grid().cursor_visible());+        feed(&mut t, b"\x1b[?25h");+        assert!(t.grid().cursor_visible());+    }++    #[test]+    fn osc12_sets_and_resets_cursor_color() {+        let mut t = Term::new(20, 1);+        feed(&mut t, b"\x1b]12;#ff0000\x07");+        assert_eq!(t.grid().cursor_color(), Some((255, 0, 0)));+        feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07");+        assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff)));+        feed(&mut t, b"\x1b]112\x07");+        assert_eq!(t.grid().cursor_color(), None);+    }++    #[test]+    fn decscusr_and_cursor_color() {+        use crate::grid::CursorShape;+        let mut t = Term::new(20, 1);+        feed(&mut t, b"\x1b[5 q"); // blinking bar+        assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);+        feed(&mut t, b"\x1b[4 q"); // steady underline+        assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);+        feed(&mut t, b"\x1b]12;#ff3030\x07");+        assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30)));+        feed(&mut t, b"\x1b]112\x07");+        assert_eq!(t.grid().cursor_color(), None);+        feed(&mut t, b"\x1b[?25l"); // hide cursor+        assert!(!t.grid().cursor_visible());+    }++    #[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);+    }++    #[test]     fn title_stack_push_pop() {         let mut t = Term::new(20, 4);         feed(&mut t, b"\x1b]0;first\x07");diff --git a/src/wayland.rs b/src/wayland.rsindex dd4f8f7..d308171 100644--- a/src/wayland.rs+++ b/src/wayland.rs@@ -128,6 +128,7 @@ pub fn run() -> anyhow::Result<ExitCode> {         width: DEFAULT_W,         height: DEFAULT_H,         dirty: false,+        focused: true,         exit: false,         exit_code: ExitCode::SUCCESS,     };@@ -182,6 +183,8 @@ struct App {     height: u32,     /// The grid changed and the window needs repainting.     dirty: bool,+    /// Whether the toplevel currently has keyboard focus (drives the cursor).+    focused: bool,     exit: bool,     /// Exit code to return, taken from the shell when it exits.     exit_code: ExitCode,@@ -252,8 +255,13 @@ impl App {                 }             }; -        self.renderer-            .render(self.term.grid(), canvas, w as usize, h as usize);+        self.renderer.render(+            self.term.grid(),+            canvas,+            w as usize,+            h as usize,+            self.focused,+        );          let surface = self.window.wl_surface();         if let Err(err) = buffer.attach_to(surface) {@@ -322,6 +330,7 @@ impl WindowHandler for App {             self.width = w.get();             self.height = h.get();         }+        self.focused = configure.is_activated();         self.resize_grid();         self.draw();     }