From 88df7c2404ed886fa80ed702b8fd39cbd47310a7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 24 Jun 2026 10:04:46 +0300 Subject: [PATCH] render: cursor shapes, visibility, and focus Signed-off-by: NotAShelf Change-Id: Iad508cceb2c8417147ad71b5c1ffc4bc6a6a6964 --- src/grid.rs | 40 ++++++++++++++++++++++ src/render.rs | 85 ++++++++++++++++++++++++++++++++++++++-------- src/vt.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++++- src/wayland.rs | 13 +++++-- 4 files changed, 213 insertions(+), 17 deletions(-) diff --git a/src/grid.rs b/src/grid.rs index 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, /// Saved primary screen while the alternate screen is active. alt_saved: Option>>, + 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 { @@ -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.rs index 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.rs index 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 { + // 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), _ => {} } } @@ -529,6 +568,57 @@ mod tests { assert_eq!(t.grid().cell(1, 0).underline, Underline::None); } + #[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); diff --git a/src/wayland.rs b/src/wayland.rs index dd4f8f7..d308171 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -128,6 +128,7 @@ pub fn run() -> anyhow::Result { 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(); }