From 2fbb43317c026aae44d5addb18570dc0da8a4ef4 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 23:09:52 +0200 Subject: [PATCH] feat(renderer): port the generic renderer, renderer thread and state Ports renderer/{generic,cell,row,link,State,Overlay,image,Thread}.zig: - generic.rs builds GPU cell buffers from terminal RenderState each frame: backgrounds, shaped text runs with glyph atlases and nerd font constraints, all cursor styles, decorations via sprites, selection and search colors, min-contrast, bold/faint/blink handling, preedit, padding extension, background image, custom shader uniforms, link hover underlines, scrollbar and health reporting. - The renderer thread updates CPU-side state only and asks the host to redraw; the app runtime calls draw_frame from the GLArea render signal (the GL context lives on the GTK thread, as in Ghostty). - ghostty_renderer::state::{State, SharedState} is the canonical shared renderer state (termio's interim copy will switch to it). Deviations: overlay uses a direct RGBA rasterizer instead of z2d; bg images decode with png/jpeg-decoder instead of wuffs; zero-length link matches advance by one char (Zig would loop forever). Kitty image rendering is implemented in image::State but not yet called from generic::update_kitty (raced with the terminal-side wiring). Test Plan: - cargo test -p ghostty-renderer (69 unit + 9 headless EGL tests that render a Terminal through the full pipeline and check pixels) --- crates/ghostty-renderer/Cargo.toml | 10 + crates/ghostty-renderer/src/cell.rs | 515 +++ crates/ghostty-renderer/src/cursor.rs | 150 +- crates/ghostty-renderer/src/generic.rs | 3414 +++++++++++++++++ crates/ghostty-renderer/src/image.rs | 1162 ++++++ crates/ghostty-renderer/src/lib.rs | 34 +- crates/ghostty-renderer/src/link.rs | 285 ++ crates/ghostty-renderer/src/message.rs | 52 +- crates/ghostty-renderer/src/opengl/mod.rs | 24 +- crates/ghostty-renderer/src/options.rs | 12 +- crates/ghostty-renderer/src/overlay.rs | 466 +++ crates/ghostty-renderer/src/row.rs | 143 + crates/ghostty-renderer/src/state.rs | 210 + crates/ghostty-renderer/src/thread.rs | 563 +++ crates/ghostty-renderer/tests/egl_headless.rs | 458 +++ docs/rewrite/notes/ghostty-renderer-R2.md | 121 + 16 files changed, 7491 insertions(+), 128 deletions(-) create mode 100644 crates/ghostty-renderer/src/cell.rs create mode 100644 crates/ghostty-renderer/src/generic.rs create mode 100644 crates/ghostty-renderer/src/image.rs create mode 100644 crates/ghostty-renderer/src/link.rs create mode 100644 crates/ghostty-renderer/src/overlay.rs create mode 100644 crates/ghostty-renderer/src/row.rs create mode 100644 crates/ghostty-renderer/src/state.rs create mode 100644 crates/ghostty-renderer/src/thread.rs create mode 100644 docs/rewrite/notes/ghostty-renderer-R2.md diff --git a/crates/ghostty-renderer/Cargo.toml b/crates/ghostty-renderer/Cargo.toml index e7e02c6..7ba2617 100644 --- a/crates/ghostty-renderer/Cargo.toml +++ b/crates/ghostty-renderer/Cargo.toml @@ -6,11 +6,21 @@ license.workspace = true [dependencies] bytemuck = { version = "1.25.2", features = ["derive"] } +crossbeam-channel = "0.5.17" +ghostty-config.workspace = true +ghostty-datastruct.workspace = true ghostty-font.workspace = true +ghostty-input.workspace = true +ghostty-terminal.workspace = true +ghostty-unicode.workspace = true +ghostty-vt.workspace = true glow = "0.18.0" +jpeg-decoder = { version = "0.3.2", default-features = false } khronos-egl = { version = "6.0.0", features = ["dynamic"] } log = "0.4.34" naga = { version = "30.0.1", features = ["glsl-in", "glsl-out", "wgsl-in"] } +onig = "6.5.3" +png = "0.18.1" thiserror = "2.0.21" [lints.rust] diff --git a/crates/ghostty-renderer/src/cell.rs b/crates/ghostty-renderer/src/cell.rs new file mode 100644 index 0000000..02b5c31 --- /dev/null +++ b/crates/ghostty-renderer/src/cell.rs @@ -0,0 +1,515 @@ +//! GPU cell contents (`renderer/cell.zig`). +//! +//! `Contents` holds the CPU-side copy of everything the cell shaders draw: +//! one background color per grid cell plus per-row lists of foreground +//! (text, underline, strikethrough, overline) instances. + +use ghostty_terminal::page::Cell as PageCell; + +use crate::cursor::Style as CursorStyle; +use crate::opengl::shaders::{CellBg, CellText}; +use crate::size::{CellCountInt, GridSize}; + +/// The possible cell content keys that exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Bg, + Text, + Underline, + Strikethrough, + Overline, +} + +/// The contents of all the cells in the terminal. +/// +/// The goal of this data structure is to allow for efficient row-wise +/// clearing of data from the GPU buffers, to allow for row-wise dirty +/// tracking to eliminate the overhead of rebuilding the GPU buffers +/// each frame. +/// +/// Must be initialized by resizing before calling any operations. +#[derive(Debug, Default)] +pub struct Contents { + pub size: GridSize, + + /// Flat array containing cell background colors for the terminal grid. + /// + /// Indexed as `bg_cells[row * size.columns + col]`. + /// + /// Prefer accessing with [`Contents::bg_cell`] instead of directly + /// indexing in order to avoid integer size bugs. + pub bg_cells: Vec, + + /// All of the foreground cell lists (`ArrayListCollection`). + /// + /// When sized with [`Contents::resize`] the individual lists are given + /// enough room that they can hold a single row with `#cols` glyphs, + /// underlines, and strikethroughs, but they may still grow since combining + /// glyphs and multi-substitution fonts can put more glyphs in a row. + /// + /// Rows are indexed as `fg_rows[y + 1]`, because the first list in the + /// collection is reserved for the block cursor (it must be drawn first) + /// and the last for the other cursor styles (drawn last). + pub fg_rows: Vec>, +} + +impl Contents { + pub fn new() -> Contents { + Contents::default() + } + + /// Resize the cell contents for the given grid size. This will + /// always invalidate the entire cell contents. + pub fn resize(&mut self, size: GridSize) { + self.size = size; + + let cell_count = usize::from(size.columns) * usize::from(size.rows); + self.bg_cells = vec![[0, 0, 0, 0]; cell_count]; + + // The foreground lists can hold glyphs, underlines and + // strikethroughs, so we give them an initial capacity of columns * 3 + // which avoids further allocations in the vast majority of cases. + // + // We have rows + 2 lists because indexes 0 and rows + 1 are used for + // special lists containing the cursor cell which need to be first and + // last in the buffer, respectively. Those only need room for 1 item. + let rows = usize::from(size.rows); + let mut fg_rows: Vec> = Vec::with_capacity(rows + 2); + fg_rows.push(Vec::with_capacity(1)); + for _ in 0..rows { + fg_rows.push(Vec::with_capacity(usize::from(size.columns) * 3)); + } + fg_rows.push(Vec::with_capacity(1)); + self.fg_rows = fg_rows; + } + + /// Reset the cell contents to an empty state without resizing. + pub fn reset(&mut self) { + self.bg_cells.fill([0, 0, 0, 0]); + for list in &mut self.fg_rows { + list.clear(); + } + } + + /// Set the cursor value. If the value is None then the cursor is hidden. + pub fn set_cursor(&mut self, v: Option, cursor_style: Option) { + if self.size.rows == 0 { + return; + } + let last = usize::from(self.size.rows) + 1; + self.fg_rows[0].clear(); + self.fg_rows[last].clear(); + + let Some(cell) = v else { return }; + let Some(style) = cursor_style else { return }; + + match style { + // Block cursors should be drawn first + CursorStyle::Block => self.fg_rows[0].push(cell), + // Other cursor styles should be drawn last + CursorStyle::BlockHollow + | CursorStyle::Bar + | CursorStyle::Underline + | CursorStyle::Lock => self.fg_rows[last].push(cell), + } + } + + /// Returns the current cursor glyph if present, checking both cursor lists. + pub fn get_cursor_glyph(&self) -> Option { + if self.size.rows == 0 { + return None; + } + if let Some(c) = self.fg_rows[0].first() { + return Some(*c); + } + if let Some(c) = self.fg_rows[usize::from(self.size.rows) + 1].first() { + return Some(*c); + } + None + } + + /// Access a background cell. Prefer this over direct indexing of + /// `bg_cells` in order to avoid integer size bugs causing overflows. + #[inline] + pub fn bg_cell(&mut self, row: usize, col: usize) -> &mut CellBg { + let cols = usize::from(self.size.columns); + &mut self.bg_cells[row * cols + col] + } + + /// Add a cell to the appropriate list. Adding the same cell twice will + /// result in duplication in the vertex buffer. The caller should clear + /// the corresponding row with [`Contents::clear`] to remove old cells + /// first. + /// + /// `key` is only used to document the call site (Zig selects the cell + /// type at comptime); background cells are set via [`Contents::bg_cell`]. + #[inline] + pub fn add(&mut self, key: Key, cell: CellText) { + let y = usize::from(cell.grid_pos[1]); + debug_assert!(y < usize::from(self.size.rows)); + match key { + Key::Bg => unreachable!("background cells are set with bg_cell"), + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + Key::Text | Key::Underline | Key::Strikethrough | Key::Overline => { + self.fg_rows[y + 1].push(cell); + } + } + } + + /// Clear all of the cell contents for a given row. + pub fn clear(&mut self, y: CellCountInt) { + debug_assert!(y < self.size.rows); + let cols = usize::from(self.size.columns); + let start = usize::from(y) * cols; + self.bg_cells[start..start + cols].fill([0, 0, 0, 0]); + + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + self.fg_rows[usize::from(y) + 1].clear(); + } +} + +/// Returns true if a codepoint for a cell is a covering character. A covering +/// character is a character that covers the entire cell. This is used to +/// make window-padding-color=extend work better. See #2099. +pub fn is_covering(cp: u32) -> bool { + matches!( + cp, // U+2588 FULL BLOCK + 0x2588 + ) +} + +/// Returns true of the codepoint is a "symbol-like" character, which +/// for now we define as anything in a private use area, and anything +/// in several unicode blocks: +/// - Arrows +/// - Dingbats +/// - Emoticons +/// - Miscellaneous Symbols +/// - Enclosed Alphanumerics +/// - Enclosed Alphanumeric Supplement +/// - Miscellaneous Symbols and Pictographs +/// - Transport and Map Symbols +/// +/// In the future it may be prudent to expand this to encompass more +/// symbol-like characters, and/or exclude some PUA sections. +pub fn is_symbol(cp: u32) -> bool { + ghostty_unicode::symbols_table::table().get(cp) +} + +/// Returns the appropriate `constraint_width` for +/// the provided cell when rendering its glyph(s). +pub fn constraint_width(raw_slice: &[PageCell], x: usize, cols: usize) -> u8 { + let cell = raw_slice[x]; + let cp = cell.codepoint(); + + let grid_width = cell.grid_width(); + + // If the grid width of the cell is 2, the constraint + // width will always be 2, so we can just return early. + if grid_width > 1 { + return grid_width; + } + + // We allow "symbol-like" glyphs to extend to 2 cells wide if there's + // space, and if the previous glyph wasn't also a symbol. So if this + // codepoint isn't a symbol then we can return the grid width. + if !is_symbol(cp) { + return grid_width; + } + + // If we are at the end of the screen it must be constrained to one cell. + if x == cols - 1 { + return 1; + } + + // If we have a previous cell and it was a symbol then we need + // to also constrain. This is so that multiple PUA glyphs align. + // This does not apply if the previous symbol is a graphics + // element such as a block element or Powerline glyph. + if x > 0 { + let prev_cp = raw_slice[x - 1].codepoint(); + if is_symbol(prev_cp) && !is_graphics_element(prev_cp) { + return 1; + } + } + + // If the next cell is whitespace, then we + // allow the glyph to be up to two cells wide. + let next_cp = raw_slice[x + 1].codepoint(); + if next_cp == 0 || is_space(next_cp) { + return 2; + } + + // Otherwise, this has to be 1 cell wide. + 1 +} + +/// Whether min contrast should be disabled for a given glyph. True +/// for graphics elements such as blocks and Powerline glyphs. +pub fn no_min_contrast(cp: u32) -> bool { + is_graphics_element(cp) +} + +// Some general spaces, others intentionally kept +// to force the font to render as a fixed width. +fn is_space(ch: u32) -> bool { + matches!( + ch, + 0x0020 // SPACE + | 0x2002 // EN SPACE + ) +} + +/// Returns true if the codepoint is used for terminal graphics, such +/// as box drawing characters, block elements, and Powerline glyphs. +fn is_graphics_element(ch: u32) -> bool { + is_box_drawing(ch) || is_block_element(ch) || is_legacy_computing(ch) || is_powerline(ch) +} + +// Returns true if the codepoint is a box drawing character. +fn is_box_drawing(ch: u32) -> bool { + matches!(ch, 0x2500..=0x257F) +} + +// Returns true if the codepoint is a block element. +fn is_block_element(ch: u32) -> bool { + matches!(ch, 0x2580..=0x259F) +} + +// Returns true if the codepoint is in a Symbols for Legacy +// Computing block, including supplements. +fn is_legacy_computing(ch: u32) -> bool { + matches!( + ch, + 0x1FB00..=0x1FBFF + | 0x1CC00..=0x1CEBF // Supplement introduced in Unicode 16.0 + ) +} + +// Returns true if the codepoint is a part of the Powerline range. +fn is_powerline(ch: u32) -> bool { + matches!(ch, 0xE0B0..=0xE0D7) +} + +#[cfg(test)] +mod tests { + use super::*; + use ghostty_terminal::render::RenderState; + use ghostty_terminal::terminal::{Options, Terminal}; + + fn fg(x: u16, y: u16) -> CellText { + CellText { + atlas: 0, + grid_pos: [x, y], + color: [0, 0, 0, 1], + ..CellText::default() + } + } + + #[test] + fn contents() { + let rows = 10; + let cols = 10; + + let mut c = Contents::new(); + c.resize(GridSize { + rows, + columns: cols, + }); + + // We should start off empty after resizing. + for y in 0..usize::from(rows) { + assert_eq!(c.fg_rows[y + 1].len(), 0); + for x in 0..usize::from(cols) { + assert_eq!([0, 0, 0, 0], *c.bg_cell(y, x)); + } + } + // And the cursor row should have a capacity of 1 and also be empty. + assert!(c.fg_rows[0].capacity() >= 1); + assert_eq!(c.fg_rows[0].len(), 0); + + // Add some contents. + let bg_cell: CellBg = [0, 0, 0, 1]; + let fg_cell = fg(4, 1); + *c.bg_cell(1, 4) = bg_cell; + c.add(Key::Text, fg_cell); + assert_eq!(bg_cell, *c.bg_cell(1, 4)); + // The fg row index is offset by 1 because of the cursor list. + assert_eq!(fg_cell, c.fg_rows[2][0]); + + // And we should be able to clear it. + c.clear(1); + for y in 0..usize::from(rows) { + assert_eq!(c.fg_rows[y + 1].len(), 0); + for x in 0..usize::from(cols) { + assert_eq!([0, 0, 0, 0], *c.bg_cell(y, x)); + } + } + + // Add a block cursor. + let cursor_cell = CellText { + bools: CellText::IS_CURSOR_GLYPH, + ..fg(2, 3) + }; + c.set_cursor(Some(cursor_cell), Some(CursorStyle::Block)); + assert_eq!(cursor_cell, c.fg_rows[0][0]); + assert_eq!(cursor_cell, c.get_cursor_glyph().unwrap()); + + // And remove it. + c.set_cursor(None, None); + assert_eq!(0, c.fg_rows[0].len()); + assert!(c.get_cursor_glyph().is_none()); + + // Add a hollow cursor. + c.set_cursor(Some(cursor_cell), Some(CursorStyle::BlockHollow)); + assert_eq!(cursor_cell, c.fg_rows[usize::from(rows) + 1][0]); + assert_eq!(cursor_cell, c.get_cursor_glyph().unwrap()); + } + + #[test] + fn contents_clear_retains_other_content() { + let mut c = Contents::new(); + c.resize(GridSize { + rows: 10, + columns: 10, + }); + + // bg and fg cells in row 1 + let bg_cell_1: CellBg = [0, 0, 0, 1]; + let fg_cell_1 = fg(4, 1); + *c.bg_cell(1, 4) = bg_cell_1; + c.add(Key::Text, fg_cell_1); + // bg and fg cells in row 2 + let bg_cell_2: CellBg = [0, 0, 0, 1]; + let fg_cell_2 = fg(4, 2); + *c.bg_cell(2, 4) = bg_cell_2; + c.add(Key::Text, fg_cell_2); + + // Clear row 1, this should leave row 2 untouched + c.clear(1); + + // Row 2 should still contain its cells. + assert_eq!(bg_cell_2, *c.bg_cell(2, 4)); + // Fg row index is +1 because of cursor list at start + assert_eq!(fg_cell_2, c.fg_rows[3][0]); + } + + #[test] + fn contents_clear_last_added_content() { + let mut c = Contents::new(); + c.resize(GridSize { + rows: 10, + columns: 10, + }); + + let bg_cell_1: CellBg = [0, 0, 0, 1]; + let fg_cell_1 = fg(4, 1); + *c.bg_cell(1, 4) = bg_cell_1; + c.add(Key::Text, fg_cell_1); + let bg_cell_2: CellBg = [0, 0, 0, 1]; + let fg_cell_2 = fg(4, 2); + *c.bg_cell(2, 4) = bg_cell_2; + c.add(Key::Text, fg_cell_2); + + // Clear row 2, this should leave row 1 untouched + c.clear(2); + + // Row 1 should still contain its cells. + assert_eq!(bg_cell_1, *c.bg_cell(1, 4)); + assert_eq!(fg_cell_1, c.fg_rows[2][0]); + } + + #[test] + fn contents_with_zero_sized_screen() { + let mut c = Contents::new(); + c.set_cursor(None, None); + assert!(c.get_cursor_glyph().is_none()); + } + + #[test] + fn contents_reset_clears_everything() { + let mut c = Contents::new(); + c.resize(GridSize { + rows: 3, + columns: 3, + }); + *c.bg_cell(1, 1) = [1, 2, 3, 4]; + c.add(Key::Underline, fg(1, 1)); + c.set_cursor(Some(fg(0, 0)), Some(CursorStyle::Bar)); + c.reset(); + assert_eq!(*c.bg_cell(1, 1), [0, 0, 0, 0]); + assert!(c.fg_rows.iter().all(Vec::is_empty)); + } + + fn feed(t: &mut Terminal, s: &str) { + let mut st = t.vt_stream(); + st.next_slice(s.as_bytes()).unwrap(); + } + + /// Reset, write `s`, update the render state and return the + /// constraint width at each requested x. + fn widths(t: &mut Terminal, state: &mut RenderState, s: &str, xs: &[usize]) -> Vec { + t.full_reset(); + feed(t, s); + state.update(t).unwrap(); + xs.iter() + .map(|&x| constraint_width(&state.row_data[0].cells.raw, x, usize::from(state.cols))) + .collect() + } + + #[test] + fn cell_constraint_widths() { + let mut t = Terminal::new(Options::new(4, 1)).unwrap(); + let mut state = RenderState::new(); + let pua = "\u{e8ef}"; + + // for each case, the numbers in the comment denote expected + // constraint widths for the symbol-containing cells + + // symbol->nothing: 2 + assert_eq!(widths(&mut t, &mut state, pua, &[0]), [2]); + // symbol->character: 1 + assert_eq!(widths(&mut t, &mut state, "\u{e8ef}z", &[0]), [1]); + // symbol->space: 2 + assert_eq!(widths(&mut t, &mut state, "\u{e8ef} z", &[0]), [2]); + // symbol->no-break space: 1 + assert_eq!(widths(&mut t, &mut state, "\u{e8ef}\u{00a0}z", &[0]), [1]); + // symbol->end of row: 1 + assert_eq!(widths(&mut t, &mut state, " \u{e8ef}", &[3]), [1]); + // character->symbol: 2 + assert_eq!(widths(&mut t, &mut state, "z\u{e8ef}", &[1]), [2]); + // symbol->symbol: 1,1 + assert_eq!( + widths(&mut t, &mut state, "\u{e8ef}\u{e8ef}", &[0, 1]), + [1, 1] + ); + // symbol->space->symbol: 2,2 + assert_eq!( + widths(&mut t, &mut state, "\u{e8ef} \u{e8ef}", &[0, 2]), + [2, 2] + ); + // symbol->powerline: 1 + assert_eq!(widths(&mut t, &mut state, "\u{e8ef}\u{e0b0}", &[0]), [1]); + // powerline->symbol: 2 + assert_eq!(widths(&mut t, &mut state, "\u{e0b2}\u{e8ef}", &[1]), [2]); + // powerline->nothing: 2 + assert_eq!(widths(&mut t, &mut state, "\u{e0b2}", &[0]), [2]); + // powerline->space: 2 + assert_eq!(widths(&mut t, &mut state, "\u{e0b2} z", &[0]), [2]); + } + + #[test] + fn covering_and_min_contrast_helpers() { + assert!(is_covering(0x2588)); + assert!(!is_covering(u32::from('a'))); + assert!(no_min_contrast(0x2500)); + assert!(no_min_contrast(0x2580)); + assert!(no_min_contrast(0xE0B0)); + assert!(no_min_contrast(0x1FB00)); + assert!(!no_min_contrast(u32::from('a'))); + } +} diff --git a/crates/ghostty-renderer/src/cursor.rs b/crates/ghostty-renderer/src/cursor.rs index 2b7bed1..64e8d1b 100644 --- a/crates/ghostty-renderer/src/cursor.rs +++ b/crates/ghostty-renderer/src/cursor.rs @@ -1,33 +1,9 @@ //! Cursor style decision logic (`renderer/cursor.zig`). -/// Terminal cursor styles (`terminal.CursorStyle`). -// TODO(integration): replace with ghostty_terminal's CursorStyle. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum TerminalCursorStyle { - Bar, - #[default] - Block, - BlockHollow, - Underline, -} +use ghostty_terminal::render::RenderState; -/// The subset of `terminal.RenderState.cursor` that the cursor style -/// decision needs. -// TODO(integration): replace with `&ghostty_terminal::RenderState` (use its -// `cursor` field) once the terminal crate is a dependency. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RenderStateCursor { - /// Viewport position of the cursor (`None` if it isn't in the viewport). - pub viewport: Option<(u16, u16)>, - /// Whether the terminal is at a password prompt. - pub password_input: bool, - /// Cursor visibility via terminal mode. - pub visible: bool, - /// Whether the cursor is blinking. - pub blinking: bool, - /// The visual style requested by the terminal. - pub visual_style: TerminalCursorStyle, -} +/// Terminal cursor styles (`terminal.CursorStyle`). +pub type TerminalCursorStyle = ghostty_terminal::cursor::Style; /// Available cursor styles for drawing that renderers must support. /// This is a superset of terminal cursor styles since the renderer supports @@ -65,13 +41,13 @@ pub struct StyleOptions { /// Returns the cursor style to use for the current render state or None /// if a cursor should not be rendered at all. -pub fn style(state: &RenderStateCursor, opts: StyleOptions) -> Option