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)
This commit is contained in:
ddidderr committed 2026-10-11 23:09:52 +02:00
1 parent 687752c650
commit 871c301713
16 files changed
+7491 -128

No files matched your search

+10
View File
@@ -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]
+515
View File
@@ -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<CellBg>,
/// 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<Vec<CellText>>,
}
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<CellText>> = 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<CellText>, cursor_style: Option<CursorStyle>) {
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<CellText> {
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<u8> {
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')));
}
}
+83 -67
View File
@@ -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<Style> {
pub fn style(state: &RenderState, opts: StyleOptions) -> Option<Style> {
// Note the order of conditionals below is important. It represents
// a priority system of how we determine what state overrides cursor
// visibility and style.
// The cursor must be visible in the viewport to be rendered.
state.viewport?;
state.cursor.viewport?;
// If we are in preedit, then we always show the block cursor. We do
// this even if the cursor is explicitly not visible because it shows
@@ -81,12 +57,12 @@ pub fn style(state: &RenderStateCursor, opts: StyleOptions) -> Option<Style> {
}
// If we're at a password input its always a lock.
if state.password_input {
if state.cursor.password_input {
return Some(Style::Lock);
}
// If the cursor is explicitly not visible by terminal mode, we don't render.
if !state.visible {
if !state.cursor.visible {
return None;
}
@@ -98,31 +74,19 @@ pub fn style(state: &RenderStateCursor, opts: StyleOptions) -> Option<Style> {
// If the cursor is blinking and our blink state is not visible,
// then we don't show the cursor.
if state.blinking && !opts.blink_visible {
if state.cursor.blinking && !opts.blink_visible {
return None;
}
// Otherwise, we use whatever style the terminal wants.
Some(Style::from_terminal(state.visual_style))
Some(Style::from_terminal(state.cursor.visual_style))
}
#[cfg(test)]
mod tests {
use super::*;
// The Zig tests drive a real Terminal + RenderState; until
// ghostty-terminal is a dependency these build the cursor state directly
// with the values the Zig tests produce.
// TODO(integration): port to use Terminal/RenderState.
fn state(visible: bool, blinking: bool) -> RenderStateCursor {
RenderStateCursor {
viewport: Some((0, 0)),
password_input: false,
visible,
blinking,
visual_style: TerminalCursorStyle::Bar,
}
}
use ghostty_terminal::terminal::{Options, ScrollViewport, Terminal};
use ghostty_vt::modes::Mode;
fn o(preedit: bool, focused: bool, blink_visible: bool) -> StyleOptions {
StyleOptions {
@@ -132,57 +96,109 @@ mod tests {
}
}
fn term() -> Terminal {
Terminal::new(Options::new(10, 10)).unwrap()
}
fn update(term: &mut Terminal, state: &mut RenderState) {
state.update(term).unwrap();
}
#[test]
fn cursor_default_uses_configured_style() {
let s = state(true, true);
assert_eq!(style(&s, o(false, true, true)), Some(Style::Bar));
assert_eq!(style(&s, o(false, false, true)), Some(Style::BlockHollow));
assert_eq!(style(&s, o(false, false, false)), Some(Style::BlockHollow));
assert_eq!(style(&s, o(false, true, false)), None);
let mut term = term();
term.screen_mut().cursor.cursor_style = TerminalCursorStyle::Bar;
term.modes.set(Mode::CursorBlinking, true);
let mut state = RenderState::new();
update(&mut term, &mut state);
assert_eq!(style(&state, o(false, true, true)), Some(Style::Bar));
assert_eq!(
style(&state, o(false, false, true)),
Some(Style::BlockHollow)
);
assert_eq!(
style(&state, o(false, false, false)),
Some(Style::BlockHollow)
);
assert_eq!(style(&state, o(false, true, false)), None);
}
#[test]
fn cursor_blinking_disabled() {
let s = state(true, false);
assert_eq!(style(&s, o(false, true, true)), Some(Style::Bar));
assert_eq!(style(&s, o(false, true, false)), Some(Style::Bar));
assert_eq!(style(&s, o(false, false, true)), Some(Style::BlockHollow));
assert_eq!(style(&s, o(false, false, false)), Some(Style::BlockHollow));
let mut term = term();
term.screen_mut().cursor.cursor_style = TerminalCursorStyle::Bar;
term.modes.set(Mode::CursorBlinking, false);
let mut state = RenderState::new();
update(&mut term, &mut state);
assert_eq!(style(&state, o(false, true, true)), Some(Style::Bar));
assert_eq!(style(&state, o(false, true, false)), Some(Style::Bar));
assert_eq!(
style(&state, o(false, false, true)),
Some(Style::BlockHollow)
);
assert_eq!(
style(&state, o(false, false, false)),
Some(Style::BlockHollow)
);
}
#[test]
fn cursor_explicitly_not_visible() {
let s = state(false, false);
let mut term = term();
term.screen_mut().cursor.cursor_style = TerminalCursorStyle::Bar;
term.modes.set(Mode::CursorVisible, false);
term.modes.set(Mode::CursorBlinking, false);
let mut state = RenderState::new();
update(&mut term, &mut state);
for focused in [true, false] {
for blink in [true, false] {
assert_eq!(style(&s, o(false, focused, blink)), None);
assert_eq!(style(&state, o(false, focused, blink)), None);
}
}
}
#[test]
fn cursor_always_block_with_preedit() {
let mut s = state(true, true);
let mut term = term();
let mut state = RenderState::new();
update(&mut term, &mut state);
// In any bool state
for focused in [true, false] {
for blink in [true, false] {
assert_eq!(style(&s, o(true, focused, blink)), Some(Style::Block));
assert_eq!(style(&state, o(true, focused, blink)), Some(Style::Block));
}
}
// If we're scrolled though, then we don't show the cursor.
s.viewport = None;
for _ in 0..100 {
term.index().unwrap();
}
term.scroll_viewport(ScrollViewport::Top);
update(&mut term, &mut state);
// In any bool state
for focused in [true, false] {
for blink in [true, false] {
assert_eq!(style(&s, o(true, focused, blink)), None);
assert_eq!(style(&state, o(true, focused, blink)), None);
}
}
}
#[test]
fn cursor_password_input_is_lock() {
let mut s = state(false, true);
s.password_input = true;
assert_eq!(style(&s, o(false, true, true)), Some(Style::Lock));
assert_eq!(style(&s, o(true, true, true)), Some(Style::Block));
let mut term = term();
term.flags.password_input = true;
let mut state = RenderState::new();
update(&mut term, &mut state);
assert_eq!(style(&state, o(false, true, true)), Some(Style::Lock));
// Preedit still wins.
assert_eq!(style(&state, o(true, true, true)), Some(Style::Block));
}
}
+3414
View File
@@ -0,0 +1,3414 @@
//! The generic renderer (`renderer/generic.zig`), instantiated for the
//! OpenGL graphics API (`renderer.zig`: `pub const Renderer = GenericRenderer(OpenGL)`).
//!
//! The generic renderer builds the GPU cell buffers from a
//! [`ghostty_terminal::render::RenderState`] and draws frames.
//!
//! # Threading model
//!
//! Like Ghostty on GTK (`must_draw_from_app_thread = true`), the work is split
//! between two threads:
//!
//! * The **renderer thread** ([`crate::thread`]) owns the "update" half:
//! [`Renderer::update_frame`] snapshots the terminal under the renderer
//! mutex ([`crate::state::SharedState`]), shapes text, renders glyphs into the
//! font atlases and rebuilds the CPU copies of the cell buffers. It never
//! issues a GL call.
//! * The **apprt main thread** (the one the `GtkGLArea` context is current on)
//! owns the "draw" half: [`Renderer::draw_frame`] uploads buffers, atlases,
//! images and the background image, then issues all GL calls. The renderer
//! thread merely *requests* a draw through [`Host::redraw_surface`]; the
//! apprt answers by calling `queue_render` on the GLArea whose `render`
//! signal handler calls `draw_frame`.
//!
//! The renderer state is split to make that safe without `unsafe`:
//!
//! * `draw` ([`DrawState`], Zig's `draw_mutex`): everything `draw_frame`
//! reads. Held by the renderer thread only for the short "publish" part of
//! `update_frame` (`rebuild_cells`, uniforms) and by `set_*`/`change_config`.
//! * `thread` ([`ThreadState`]): data only the renderer thread touches
//! (`RenderState`, font shaper + cache, overlay, search highlights). It is a
//! `Mutex` only so that `Renderer` is `Sync`; it is uncontended.
//!
//! Lock order is always: renderer mutex (`SharedState`) is *not* held while
//! taking `draw`; `thread` before `draw` before the font grid lock.
//!
//! # GL context requirements
//!
//! Everything that creates, uses or destroys GL objects ([`Renderer::new`],
//! [`Renderer::draw_frame`], [`Renderer::display_realized`],
//! [`Renderer::display_unrealized`], dropping the `Renderer`) must run on the
//! thread with the GL context current. The renderer thread never does.
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Instant;
use ghostty_config::config::Config;
use ghostty_config::path::Path as ConfigPath;
use ghostty_config::types_a::{BoldColor, FontShapingBreak, TerminalColor, WindowPaddingColor};
use ghostty_config::types_b::{
AlphaBlending, BackgroundBlur, BackgroundImageFit, BackgroundImagePosition, WindowColorspace,
};
use ghostty_font::face::{Constraint, ConstraintSize, RenderOptions};
use ghostty_font::nerd_font_attributes::get_constraint;
use ghostty_font::shape::{self, Cache as ShaperCache, RunIterator, RunOptions, TextRun};
use ghostty_font::shaper::run::RunCell;
use ghostty_font::{Atlas, Presentation, SPRITE_INDEX, SharedGrid, Sprite, Style as FontStyle};
use ghostty_input::Mods;
use ghostty_terminal::highlight::Flattened;
use ghostty_terminal::page::{self, Cell as PageCell, Wide};
use ghostty_terminal::page_list::Scrollbar;
use ghostty_terminal::point::{Coordinate, Tag};
use ghostty_terminal::render::{Cursor as RenderCursor, Dirty, RenderState, Row as RenderRow};
use ghostty_terminal::style::{self, Style};
use ghostty_terminal::terminal::ScrollViewport;
use ghostty_vt::color::Rgb;
use ghostty_vt::modes::Mode;
use ghostty_vt::sgr::Underline;
use crate::backend::Health;
use crate::cell::{self, Contents, Key as CellKey};
use crate::cursor::{self, Style as CursorStyle};
use crate::image::{self, Gpu};
use crate::link;
use crate::math;
use crate::opengl::buffer::Buffer;
use crate::opengl::gl::{self, Gl, Primitive};
use crate::opengl::render_pass::{Attachment, AttachmentTarget, Draw, Step};
use crate::opengl::shaders::{
BgImage, BgImageFit, BgImageInfo, BgImagePosition, Bools, CellBg, CellText, CellTextAtlas,
PaddingExtend, Shaders, Uniforms,
};
use crate::opengl::{self, OpenGL, Sampler, Target, Texture};
use crate::options::Options;
use crate::overlay::{self, Overlay};
use crate::row::never_extend_bg;
use crate::shadertoy::{self, PathItem};
use crate::size::{CellSize, GridSize, Padding, Size};
use crate::state::{Preedit, SharedState};
/// The graphics API used by the renderer on Linux.
pub type GraphicsApi = OpenGL;
/// Errors surfaced by the generic renderer.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Api(#[from] opengl::Error),
#[error(transparent)]
Gl(#[from] gl::Error),
#[error(transparent)]
Texture(#[from] opengl::texture::Error),
#[error(transparent)]
Shaper(#[from] ghostty_font::shaper::harfbuzz::ShapeError),
#[error(transparent)]
ShapeRun(#[from] ghostty_font::shaper::harfbuzz::ShapeRunError),
#[error(transparent)]
Grid(#[from] ghostty_font::shared_grid::Error),
#[error(transparent)]
Link(#[from] link::Error),
#[error("terminal state error: {0}")]
Terminal(page::Error),
#[error("the swap chain is defunct (display unrealized)")]
Defunct,
}
/// What the renderer needs from the surface/apprt (Zig: `surface_mailbox`
/// and the `redraw_surface` app message). Implementations must not block for
/// long: `scrollbar` and `redraw_surface` are "instant" pushes in Zig.
pub trait Host: Send + Sync {
/// `surface_mailbox.push(.{ .scrollbar = .. }, .instant)`. Returns true if
/// the message was accepted (otherwise the renderer retries next frame).
fn scrollbar(&self, scrollbar: Scrollbar) -> bool;
/// `surface_mailbox.push(.{ .renderer_health = .. }, .forever)`.
fn renderer_health(&self, health: Health);
/// Ask the apprt to call [`Renderer::draw_frame`] from its main thread
/// (`app_mailbox.push(.{ .redraw_surface = surface }, .instant)`). Called
/// from the renderer thread because GTK must draw on the main thread
/// (`must_draw_from_app_thread = true`).
fn redraw_surface(&self) -> bool;
}
/// A host that discards everything. Useful for tests and headless use.
#[derive(Debug, Default, Clone, Copy)]
pub struct NullHost;
impl Host for NullHost {
fn scrollbar(&self, _: Scrollbar) -> bool {
true
}
fn renderer_health(&self, _: Health) {}
fn redraw_surface(&self) -> bool {
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
enum HighlightTag {
SearchMatch = 0,
SearchMatchSelected = 1,
}
/// The configuration for this renderer that is derived from the main
/// configuration (`DerivedConfig`). Owned and cheap to send in messages.
#[derive(Clone)]
pub struct DerivedConfig {
pub font_thicken: bool,
pub font_thicken_strength: u8,
pub font_features: Vec<String>,
pub font_styles: [bool; 4],
pub font_shaping_break: FontShapingBreak,
pub cursor_color: Option<TerminalColor>,
pub cursor_opacity: f64,
pub cursor_text: Option<TerminalColor>,
pub background: Rgb,
pub background_opacity: f64,
pub background_opacity_cells: bool,
pub foreground: Rgb,
pub selection_background: Option<TerminalColor>,
pub selection_foreground: Option<TerminalColor>,
pub search_background: TerminalColor,
pub search_foreground: TerminalColor,
pub search_selected_background: TerminalColor,
pub search_selected_foreground: TerminalColor,
pub bold_color: Option<BoldColor>,
pub faint_opacity: u8,
pub min_contrast: f32,
pub padding_color: WindowPaddingColor,
pub custom_shaders: Vec<PathItem>,
pub bg_image: Option<ConfigPath>,
pub bg_image_opacity: f32,
pub bg_image_position: BackgroundImagePosition,
pub bg_image_fit: BackgroundImageFit,
pub bg_image_repeat: bool,
pub links: Arc<link::Set>,
pub vsync: bool,
pub colorspace: WindowColorspace,
pub blending: AlphaBlending,
pub background_blur: BackgroundBlur,
pub scroll_to_bottom_on_output: bool,
}
impl DerivedConfig {
pub fn new(config: &Config) -> Result<DerivedConfig, link::Error> {
use ghostty_config::types_a::FontStyle as ConfigFontStyle;
// Copy our shaders
let custom_shaders = config
.custom_shader
.value
.iter()
.map(|p| match p {
ConfigPath::Optional(s) => PathItem::Optional(s.clone()),
ConfigPath::Required(s) => PathItem::Required(s.clone()),
})
.collect();
// Get our font styles (index order: regular, bold, italic, bold_italic)
let font_styles = [
true,
config.font_style_bold != ConfigFontStyle::False,
config.font_style_italic != ConfigFontStyle::False,
config.font_style_bold_italic != ConfigFontStyle::False,
];
// Our link configs
let links = link::Set::from_config(&config.link.links)?;
Ok(DerivedConfig {
background_opacity: config.background_opacity.clamp(0.0, 1.0),
background_opacity_cells: config.background_opacity_cells,
font_thicken: config.font_thicken,
font_thicken_strength: config.font_thicken_strength,
font_features: config.font_feature.list.clone(),
font_styles,
font_shaping_break: config.font_shaping_break,
cursor_color: config.cursor_color,
cursor_text: config.cursor_text,
cursor_opacity: config.cursor_opacity.clamp(0.0, 1.0),
background: config.background.to_terminal_rgb(),
foreground: config.foreground.to_terminal_rgb(),
bold_color: config.bold_color,
faint_opacity: (config.faint_opacity * 255.0).ceil().clamp(0.0, 255.0) as u8,
min_contrast: config.minimum_contrast as f32,
padding_color: config.window_padding_color,
selection_background: config.selection_background,
selection_foreground: config.selection_foreground,
search_background: config.search_background,
search_foreground: config.search_foreground,
search_selected_background: config.search_selected_background,
search_selected_foreground: config.search_selected_foreground,
custom_shaders,
bg_image: config.background_image.clone(),
bg_image_opacity: config.background_image_opacity,
bg_image_position: config.background_image_position,
bg_image_fit: config.background_image_fit,
bg_image_repeat: config.background_image_repeat,
links: Arc::new(links),
vsync: config.window_vsync,
colorspace: config.window_colorspace,
blending: config.alpha_blending,
background_blur: config.background_blur,
scroll_to_bottom_on_output: config.scroll_to_bottom.output,
})
}
}
impl std::fmt::Debug for DerivedConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DerivedConfig").finish_non_exhaustive()
}
}
// ---------------------------------------------------------------------------
// GPU frame state
/// State we need duplicated for every frame. Any state that could be in a
/// data race between the GPU and CPU while a frame is being drawn should be
/// in this struct.
///
/// With OpenGL `swap_chain_count == 1` and frames complete synchronously
/// (`glFinish`), so the semaphore of the Zig swap chain is not needed: the
/// draw mutex already serializes `draw_frame`.
struct FrameState {
uniforms: Buffer<Uniforms>,
cells: Buffer<CellText>,
cells_bg: Buffer<CellBg>,
grayscale: Texture,
grayscale_modified: usize,
color: Texture,
color_modified: usize,
target: Arc<Target>,
/// See property of same name on Renderer for explanation.
target_config_modified: usize,
/// Buffer with the vertex data for our background image.
bg_image_buffer: Buffer<BgImage>,
/// See property of same name on Renderer for explanation.
bg_image_buffer_modified: usize,
/// Custom shader state, this is None if we have no custom shaders.
custom_shader_state: Option<CustomShaderState>,
}
impl FrameState {
fn new(api: &OpenGL, custom_shaders: bool) -> Result<FrameState, Error> {
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
// Uniform buffer contains exactly 1 uniform struct. The
// uniform data will be zeroed so this must be set before
// a frame is drawn.
let uniforms = Buffer::<Uniforms>::init(gl, api.uniform_buffer_options(), 1)?;
// Create GPU buffers for our cells.
//
// We start them off with a size of 1, which will of course be
// too small, but they will be resized as needed.
let cells = Buffer::<CellText>::init(gl, api.fg_buffer_options(), 1)?;
let cells_bg = Buffer::<CellBg>::init(gl, api.bg_buffer_options(), 1)?;
// Create a GPU buffer for our background image info.
let bg_image_buffer = Buffer::<BgImage>::init(gl, api.bg_image_buffer_options(), 1)?;
// Initialize our textures for our font atlas.
//
// As with the buffers above, we start these off as small
// as possible since they'll inevitably be resized anyway.
//
// (2x2 rather than Zig's 1x1: `Atlas::new` keeps a 1px border so the
// smallest valid atlas is 2px; the texture is replaced on first sync.)
let grayscale =
api.init_atlas_texture(&Atlas::new(2, ghostty_font::atlas::Format::Grayscale))?;
let color = api.init_atlas_texture(&Atlas::new(2, ghostty_font::atlas::Format::Bgra))?;
let custom_shader_state = if custom_shaders {
Some(CustomShaderState::new(api)?)
} else {
None
};
// Initialize the target. Just as with the other resources,
// start it off as small as we can since it'll be resized.
let target = Arc::new(api.init_target(1, 1)?);
Ok(FrameState {
uniforms,
cells,
cells_bg,
grayscale,
grayscale_modified: 0,
color,
color_modified: 0,
target,
target_config_modified: 0,
bg_image_buffer,
bg_image_buffer_modified: 0,
custom_shader_state,
})
}
fn resize(&mut self, api: &OpenGL, width: usize, height: usize) -> Result<(), Error> {
if let Some(state) = &mut self.custom_shader_state {
state.resize(api, width, height)?;
}
self.target = Arc::new(api.init_target(width, height)?);
Ok(())
}
}
/// State relevant to our custom shaders if we have any.
struct CustomShaderState {
/// When we have a custom shader state, we maintain a front
/// and back texture which we use as a swap chain to render
/// between when multiple custom shaders are defined.
front_texture: Texture,
back_texture: Texture,
/// Shadertoy uses a sampler for accessing the various channel textures.
/// Samplers are immutable and describe sampling properties so we can
/// share the sampler across front/back textures.
sampler: Sampler,
uniforms: Buffer<shadertoy::Uniforms>,
}
impl CustomShaderState {
fn new(api: &OpenGL) -> Result<CustomShaderState, Error> {
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
// Create a GPU buffer to hold our uniforms.
let uniforms = Buffer::<shadertoy::Uniforms>::init(gl, api.uniform_buffer_options(), 1)?;
// Initialize the front and back textures at 1x1 px, this
// is slightly wasteful but it's only done once so whatever.
let front_texture = Texture::init(gl, api.texture_options(), 1, 1, None)?;
let back_texture = Texture::init(gl, api.texture_options(), 1, 1, None)?;
let sampler = Sampler::init(gl, api.sampler_options())
.map_err(|_| gl::Error::CreateFailed("sampler".into()))?;
Ok(CustomShaderState {
front_texture,
back_texture,
sampler,
uniforms,
})
}
/// Swap the front and back textures.
fn swap(&mut self) {
std::mem::swap(&mut self.front_texture, &mut self.back_texture);
}
fn resize(&mut self, api: &OpenGL, width: usize, height: usize) -> Result<(), Error> {
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
self.front_texture = Texture::init(gl, api.texture_options(), width, height, None)?;
self.back_texture = Texture::init(gl, api.texture_options(), width, height, None)?;
Ok(())
}
}
/// Swap chain which maintains multiple copies of the state needed to render
/// a frame (always one for OpenGL, see [`FrameState`]).
struct SwapChain {
frames: Vec<Option<FrameState>>,
/// Index of the most recently used frame state struct.
frame_index: usize,
/// Set to true when deinited, so that a defunct swap chain is ignored
/// instead of being used or freed twice (`displayUnrealized`).
defunct: bool,
}
impl SwapChain {
fn new(api: &OpenGL, custom_shaders: bool) -> Result<SwapChain, Error> {
let mut frames = Vec::with_capacity(opengl::SWAP_CHAIN_COUNT);
for _ in 0..opengl::SWAP_CHAIN_COUNT {
frames.push(Some(FrameState::new(api, custom_shaders)?));
}
Ok(SwapChain {
frames,
frame_index: 0,
defunct: false,
})
}
fn deinit(&mut self) {
if self.defunct {
return;
}
self.defunct = true;
self.frames.clear();
}
/// Get the next frame state to draw to.
fn next_frame(&mut self) -> Result<(usize, FrameState), Error> {
if self.defunct || self.frames.is_empty() {
return Err(Error::Defunct);
}
self.frame_index = (self.frame_index + 1) % self.frames.len();
let frame = self.frames[self.frame_index].take().ok_or(Error::Defunct)?;
Ok((self.frame_index, frame))
}
fn release_frame(&mut self, idx: usize, frame: FrameState) {
if let Some(slot) = self.frames.get_mut(idx) {
*slot = Some(frame);
}
}
/// Mutable access to all frames (for invalidating atlas textures).
fn frames_mut(&mut self) -> impl Iterator<Item = &mut FrameState> {
self.frames.iter_mut().flatten()
}
}
/// The background image state (`bg_image: ?imagepkg.Image`).
type BgImageState = Option<image::Image>;
// ---------------------------------------------------------------------------
// Renderer state
/// State protected by the draw mutex: everything `draw_frame` reads.
struct DrawState {
/// The configuration we need derived from the main config.
config: DerivedConfig,
/// Current font metrics defining our grid.
grid_metrics: ghostty_font::Metrics,
/// The size of everything.
size: Size,
/// True if the window is focused
focused: bool,
/// Flag to indicate that our focus state changed for custom
/// shaders to update their state.
custom_shader_focused_changed: bool,
/// The most recent scrollbar state. We use this as a cache to
/// determine if we need to notify the apprt that there was a
/// scrollbar change.
scrollbar: Scrollbar,
scrollbar_dirty: bool,
/// The current set of cells to render. This is rebuilt on every frame
/// but we keep this around so that we don't reallocate. Each set of
/// cells goes into a separate shader.
cells: Contents,
/// Set to true after rebuild_cells is called. This can be used
/// to determine if any possible changes have been made to the
/// cells for the draw call.
cells_rebuilt: bool,
/// The current GPU uniform values.
uniforms: Uniforms,
/// Custom shader uniform values.
custom_shader_uniforms: shadertoy::Uniforms,
/// Timestamp we rendered out first frame.
first_frame_time: Option<Instant>,
/// Timestamp when we rendered out more recent frame.
last_frame_time: Option<Instant>,
/// The font structures.
font_grid: Arc<SharedGrid>,
/// The images that we may render.
images: image::State,
/// True when a debug overlay image is currently present (Zig: `overlay != null`).
has_overlay: bool,
/// Background image, if we have one.
bg_image: BgImageState,
/// Background image vertex buffer.
bg_image_buffer: BgImage,
/// This value is used to force-update the swap chain copy
/// of the background image buffer whenever we change it.
bg_image_buffer_modified: usize,
/// Graphics API state.
api: OpenGL,
/// Our swap chain (multiple buffering)
swap_chain: SwapChain,
/// This value is used to force-update swap chain targets in the
/// event of a config change that requires it (such as blending mode).
target_config_modified: usize,
/// If something happened that requires us to reinitialize our shaders,
/// this is set to true so that we can do that whenever possible.
reinitialize_shaders: bool,
/// Whether or not we have custom shaders.
has_custom_shaders: bool,
/// Our shader pipelines. None while defunct (display unrealized).
shaders: Option<Shaders>,
}
/// State only touched by the renderer thread.
struct ThreadState {
/// The thread's copy of the derived config (kept in sync with
/// `DrawState::config`).
config: DerivedConfig,
/// The render state we update per loop.
terminal_state: RenderState,
/// The number of frames since the last terminal state reset.
/// We reset the terminal state after ~100,000 frames (about 10 to
/// 15 minutes at 120Hz) to prevent wasted memory buildup from
/// a large screen.
terminal_state_frame_count: usize,
/// Tracks the last bottom-right pin of the screen to detect new output.
/// Used for scroll-to-bottom on output feature.
last_bottom: Option<(ghostty_terminal::page_list::NodeId, u16)>,
/// The most recent viewport matches so that we can render search
/// matches in the visible frame. This is provided asynchronously
/// from the search thread so we have the dirty flag to also note
/// if we need to rebuild our cells to include search highlights.
///
/// Note that the selections MAY BE INVALID (point to PageList nodes
/// that do not exist anymore). These are validated by the render state.
search_matches: Option<Vec<Flattened>>,
search_selected_match: Option<Flattened>,
search_matches_dirty: bool,
/// Our overlay state, if any.
overlay: Option<Overlay>,
font_shaper: shape::Shaper,
font_shaper_cache: ShaperCache,
/// Scratch buffers reused while rebuilding rows.
run_cells: Vec<RunCell>,
shaped_cells: Vec<shape::Cell>,
}
/// The generic renderer. See the module docs for the threading model.
pub struct Renderer {
draw: Mutex<DrawState>,
thread: Mutex<ThreadState>,
/// Health of the most recently completed frame.
health: AtomicU8,
/// Mirrors `DrawState::has_custom_shaders` so the renderer thread can
/// read it without the draw lock (`hasAnimations`).
has_animations: AtomicBool,
/// The surface/apprt callbacks.
host: Arc<dyn Host>,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// How far a [`PreeditRange`] reaches within a row.
struct PreeditRange {
y: u16,
x: [u16; 2],
cp_offset: usize,
}
/// Load the custom shaders (`initShaders`). Errors are logged and result in
/// no custom shaders.
fn load_custom_shaders(config: &DerivedConfig) -> Vec<String> {
match shadertoy::load_from_files(&config.custom_shaders, opengl::CUSTOM_SHADER_TARGET) {
Ok(v) => v,
Err(err) => {
log::warn!("error loading custom shaders err={err}");
Vec::new()
}
}
}
impl DrawState {
/// Rebuild the shader pipelines (`initShaders`).
fn init_shaders(&mut self) -> Result<(), Error> {
// Load our custom shaders
let custom_shaders = load_custom_shaders(&self.config);
let has_custom_shaders = !custom_shaders.is_empty();
let shaders = self.api.init_shaders(&custom_shaders)?;
self.shaders = Some(shaders);
self.has_custom_shaders = has_custom_shaders;
Ok(())
}
/// Update uniforms that are based on the font grid.
fn update_font_grid_uniforms(&mut self) {
self.uniforms.cell_size = [
self.grid_metrics.cell_width as f32,
self.grid_metrics.cell_height as f32,
];
}
/// Update uniforms that are based on the screen size.
fn update_screen_size_uniforms(&mut self) {
let terminal_size = self.size.terminal();
// Blank space around the grid.
let blank: Padding = self.size.screen.blank_padding(
self.size.padding,
GridSize {
columns: self.cells.size.columns,
rows: self.cells.size.rows,
},
CellSize {
width: self.grid_metrics.cell_width,
height: self.grid_metrics.cell_height,
},
) + self.size.padding;
// Setup our uniforms
self.uniforms.projection_matrix = math::ortho2d(
-(self.size.padding.left as f32),
(terminal_size.width + self.size.padding.right) as f32,
(terminal_size.height + self.size.padding.bottom) as f32,
-(self.size.padding.top as f32),
);
self.uniforms.grid_padding = [
blank.top as f32,
blank.right as f32,
blank.bottom as f32,
blank.left as f32,
];
self.uniforms.screen_size = [
self.size.screen.width as f32,
self.size.screen.height as f32,
];
}
/// Update the background image vertex buffer (CPU-side).
///
/// This should be called if and when configs change that
/// could affect the background image.
fn update_bg_image_buffer(&mut self) {
self.bg_image_buffer = BgImage {
opacity: self.config.bg_image_opacity,
info: BgImageInfo {
position: match self.config.bg_image_position {
BackgroundImagePosition::TopLeft => BgImagePosition::Tl,
BackgroundImagePosition::TopCenter => BgImagePosition::Tc,
BackgroundImagePosition::TopRight => BgImagePosition::Tr,
BackgroundImagePosition::CenterLeft => BgImagePosition::Ml,
BackgroundImagePosition::CenterCenter | BackgroundImagePosition::Center => {
BgImagePosition::Mc
}
BackgroundImagePosition::CenterRight => BgImagePosition::Mr,
BackgroundImagePosition::BottomLeft => BgImagePosition::Bl,
BackgroundImagePosition::BottomCenter => BgImagePosition::Bc,
BackgroundImagePosition::BottomRight => BgImagePosition::Br,
},
fit: match self.config.bg_image_fit {
BackgroundImageFit::Contain => BgImageFit::Contain,
BackgroundImageFit::Cover => BgImageFit::Cover,
BackgroundImageFit::Stretch => BgImageFit::Stretch,
BackgroundImageFit::None => BgImageFit::None,
},
repeat: self.config.bg_image_repeat,
}
.to_bits(),
pad: [0; 3],
};
// Signal that the buffer was modified.
self.bg_image_buffer_modified = self.bg_image_buffer_modified.wrapping_add(1);
}
/// Call this any time the background image path changes.
fn prep_background_image(&mut self) {
// Then we try to load the background image if we have a path.
if let Some(p) = &self.config.bg_image
&& let Some(image) = load_background_image(p.path())
{
// If we have an existing background image, replace it.
// Otherwise, set this as our background image directly.
match &mut self.bg_image {
Some(img) => img.mark_for_replace(image),
None => self.bg_image = Some(image),
}
} else if self.config.bg_image.is_none()
&& let Some(img) = &mut self.bg_image
{
// If we don't have a background image path, mark our
// background image for unload if we currently have one.
img.mark_for_unload();
}
}
fn upload_background_image(&mut self, gpu: &Gpu<'_>) {
// Make sure our bg image is uploaded if it needs to be.
if let Some(bg) = &mut self.bg_image {
if bg.is_unloading() {
self.bg_image = None;
return;
}
if bg.is_pending()
&& let Err(err) = bg.upload(gpu)
{
log::warn!("error uploading background image err={err}");
}
}
}
/// Update custom shader uniforms that depend on terminal state.
///
/// This should be called in `update_frame` when terminal state changes.
fn update_custom_shader_uniforms_from_state(
&mut self,
state: &RenderState,
cursor_text: Option<TerminalColor>,
) {
fn f4(c: Rgb) -> [f32; 4] {
[
f32::from(c.r) / 255.0,
f32::from(c.g) / 255.0,
f32::from(c.b) / 255.0,
1.0,
]
}
// We only need to do this if we have custom shaders.
if !self.has_custom_shaders {
return;
}
// Only update when terminal state is dirty.
if state.dirty == Dirty::False {
return;
}
let selection_background = self.config.selection_background;
let selection_foreground = self.config.selection_foreground;
let uniforms = &mut self.custom_shader_uniforms;
let colors = &state.colors;
// 256-color palette
for (i, color) in colors.palette.iter().enumerate() {
uniforms.palette[i] = f4(*color);
}
// Background color
uniforms.background_color = f4(colors.background);
// Foreground color
uniforms.foreground_color = f4(colors.foreground);
// Cursor color
if let Some(cursor_color) = colors.cursor {
uniforms.cursor_color = f4(cursor_color);
}
// NOTE: the following could be optimized to follow a change in
// config for a slight optimization however this is only 12 bytes
// each being updated and likely isn't a cause for concern
//
// Deviation: Zig reads `.color` unconditionally, which is invalid for
// the `cell-foreground`/`cell-background` variants; those are skipped.
// Cursor text color
if let Some(TerminalColor::Color(c)) = cursor_text {
uniforms.cursor_text = f4(c.to_terminal_rgb());
}
// Selection background color
if let Some(TerminalColor::Color(c)) = selection_background {
uniforms.selection_background_color = f4(c.to_terminal_rgb());
}
// Selection foreground color
if let Some(TerminalColor::Color(c)) = selection_foreground {
uniforms.selection_foreground_color = f4(c.to_terminal_rgb());
}
// Cursor visibility
uniforms.cursor_visible = i32::from(state.cursor.visible);
// Cursor style
let cursor_style = CursorStyle::from_terminal(state.cursor.visual_style);
uniforms.previous_cursor_style = uniforms.current_cursor_style;
uniforms.current_cursor_style = cursor_style as i32;
}
/// Update per-frame custom shader uniforms.
///
/// This should be called exactly once per frame, inside `draw_frame`.
#[allow(clippy::float_cmp)]
fn update_custom_shader_uniforms_for_frame(&mut self) {
// We only need to do this if we have custom shaders.
if !self.has_custom_shaders {
return;
}
let now = Instant::now();
let first_frame_time = *self.first_frame_time.get_or_insert(now);
let last_frame_time = self.last_frame_time.unwrap_or(now);
self.last_frame_time = Some(now);
let screen = self.size.screen;
let padding = self.size.padding;
let cell = self.size.cell;
let cursor_glyph = self.cells.get_cursor_glyph();
let focused = self.focused;
let y_is_down = opengl::CUSTOM_SHADER_Y_IS_DOWN;
let uniforms = &mut self.custom_shader_uniforms;
let since_ns = now.duration_since(first_frame_time).as_nanos() as f32;
uniforms.time = since_ns / 1_000_000_000.0;
let delta_ns = now.duration_since(last_frame_time).as_nanos() as f32;
uniforms.time_delta = delta_ns / 1_000_000_000.0;
uniforms.frame += 1;
uniforms.resolution = [screen.width as f32, screen.height as f32, 1.0];
uniforms.channel_resolution[0] = [screen.width as f32, screen.height as f32, 1.0, 0.0];
if let Some(cursor) = cursor_glyph {
let cursor_width = cursor.glyph_size[0] as f32;
let cursor_height = cursor.glyph_size[1] as f32;
// Left edge of the cell the cursor is in.
let mut pixel_x = (u32::from(cursor.grid_pos[0]) * cell.width + padding.left) as f32;
// Top edge, relative to the top of the
// screen, of the cell the cursor is in.
let mut pixel_y = (u32::from(cursor.grid_pos[1]) * cell.height + padding.top) as f32;
// If +Y is up in our shaders, we need to flip the coordinate
// so that it's instead the top edge of the cell relative to
// the *bottom* of the screen.
if !y_is_down {
pixel_y = screen.height as f32 - pixel_y;
}
// Add the X bearing to get the -X (left) edge of the cursor.
pixel_x += f32::from(cursor.bearings[0]);
// How we deal with the Y bearing depends on which direction
// is "up", since we want our final `pixel_y` value to be the
// +Y edge of the cursor.
if y_is_down {
// As a reminder, the Y bearing is the distance from the
// bottom of the cell to the top of the glyph, so to get
// the +Y edge we need to add the cell height, subtract
// the Y bearing, and add the glyph height to get the +Y
// (bottom) edge of the cursor.
pixel_y += cell.height as f32;
pixel_y -= f32::from(cursor.bearings[1]);
pixel_y += cursor.glyph_size[1] as f32;
} else {
// If the Y direction is reversed though, we instead want
// the *top* edge of the cursor, which means we just need
// to subtract the cell height and add the Y bearing.
pixel_y -= cell.height as f32;
pixel_y += f32::from(cursor.bearings[1]);
}
let new_cursor = [pixel_x, pixel_y, cursor_width, cursor_height];
let cursor_color = [
f32::from(cursor.color[0]) / 255.0,
f32::from(cursor.color[1]) / 255.0,
f32::from(cursor.color[2]) / 255.0,
f32::from(cursor.color[3]) / 255.0,
];
let cursor_changed = new_cursor != uniforms.current_cursor
|| cursor_color != uniforms.current_cursor_color;
if cursor_changed {
uniforms.previous_cursor = uniforms.current_cursor;
uniforms.previous_cursor_color = uniforms.current_cursor_color;
uniforms.current_cursor = new_cursor;
uniforms.current_cursor_color = cursor_color;
uniforms.cursor_change_time = uniforms.time;
}
}
// Update focus uniforms
uniforms.focus = i32::from(focused);
// If we need to update the time our focus state changed
// then update it to our current frame time. This may not be
// exactly correct since it is frame time, not exact focus
// time, but focus time on its own isn't exactly correct anyways
// since it comes async from a message.
if self.custom_shader_focused_changed && self.focused {
self.custom_shader_uniforms.time_focus = self.custom_shader_uniforms.time;
self.custom_shader_focused_changed = false;
}
}
/// Sync the atlas data to the given texture. This copies the bytes
/// associated with the atlas to the given texture. If the atlas no
/// longer fits into the texture, the texture will be resized.
fn sync_atlas_texture(api: &OpenGL, atlas: &Atlas, texture: &mut Texture) -> Result<(), Error> {
if atlas.size as usize > texture.width {
// Reallocate (the old texture is dropped/deleted)
*texture = api.init_atlas_texture(atlas)?;
}
texture.replace_region(0, 0, atlas.size as usize, atlas.size as usize, &atlas.data)?;
Ok(())
}
}
/// Decode a background image file (`prepBackgroundImage`). Failures are
/// logged and result in no image, as in Zig.
fn load_background_image(path: &str) -> Option<image::Image> {
// Read it (Max size of 4 GiB, for now.)
let contents = match std::fs::read(path) {
Ok(c) if u32::try_from(c.len()).is_ok() => c,
Ok(_) => {
log::warn!("background image file \"{path}\" is too large");
return None;
}
Err(err) => {
log::warn!("error opening background image file \"{path}\": {err}");
return None;
}
};
// Figure out what type it probably is.
let file_type = match detect_file_type(&contents) {
FileType::Unknown => guess_file_type_from_extension(path),
t => t,
};
// Decode it if we know how.
let (width, height, data) = match file_type {
FileType::Png => match decode_png(&contents) {
Ok(v) => v,
Err(err) => {
log::warn!("error decoding png background image \"{path}\": {err}");
return None;
}
},
FileType::Jpeg => match decode_jpeg(&contents) {
Ok(v) => v,
Err(err) => {
log::warn!("error decoding jpeg background image \"{path}\": {err}");
return None;
}
},
FileType::Unknown => {
log::warn!("Cannot determine file type for background image file \"{path}\"!");
return None;
}
f => {
log::warn!("Unsupported file type {f:?} for background image file \"{path}\"!");
return None;
}
};
Some(image::Image::Pending(image::Pending {
width,
height,
pixel_format: image::PixelFormat::Rgba,
data,
}))
}
/// `file_type.zig` (the subset needed here).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileType {
Jpeg,
Png,
Gif,
Bmp,
Qoi,
Webp,
Unknown,
}
fn detect_file_type(contents: &[u8]) -> FileType {
const PNG: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
if contents.starts_with(PNG) {
FileType::Png
} else if contents.starts_with(&[0xFF, 0xD8, 0xFF]) {
FileType::Jpeg
} else if contents.starts_with(b"GIF87a") || contents.starts_with(b"GIF89a") {
FileType::Gif
} else if contents.starts_with(b"BM") {
FileType::Bmp
} else if contents.starts_with(b"qoif") {
FileType::Qoi
} else if contents.len() >= 12 && &contents[..4] == b"RIFF" && &contents[8..12] == b"WEBP" {
FileType::Webp
} else {
FileType::Unknown
}
}
fn guess_file_type_from_extension(path: &str) -> FileType {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("jpg" | "jpeg" | "jfif") => FileType::Jpeg,
Some("png") => FileType::Png,
Some("gif") => FileType::Gif,
Some("bmp") => FileType::Bmp,
Some("qoi") => FileType::Qoi,
Some("webp") => FileType::Webp,
_ => FileType::Unknown,
}
}
/// Decode a PNG to straight (non-premultiplied) RGBA8.
#[allow(clippy::chunks_exact_to_as_chunks)]
fn decode_png(contents: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(contents));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let mut buf = vec![0; reader.output_buffer_size().ok_or("png too large")?];
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
buf.truncate(info.buffer_size());
let n = info.width as usize * info.height as usize;
let rgba = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => buf
.chunks_exact(3)
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect(),
png::ColorType::GrayscaleAlpha => buf
.chunks_exact(2)
.flat_map(|p| [p[0], p[0], p[0], p[1]])
.collect(),
png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
png::ColorType::Indexed => return Err("unexpanded indexed png".into()),
};
if rgba.len() != n * 4 {
return Err("unexpected png buffer size".into());
}
Ok((info.width, info.height, rgba))
}
/// Decode a JPEG to RGBA8.
#[allow(clippy::chunks_exact_to_as_chunks)]
fn decode_jpeg(contents: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(contents));
let pixels = decoder.decode().map_err(|e| e.to_string())?;
let info = decoder.info().ok_or("missing jpeg info")?;
let rgba: Vec<u8> = match info.pixel_format {
jpeg_decoder::PixelFormat::RGB24 => pixels
.chunks_exact(3)
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect(),
jpeg_decoder::PixelFormat::L8 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
jpeg_decoder::PixelFormat::L16 => pixels
.chunks_exact(2)
.flat_map(|p| [p[0], p[0], p[0], 255])
.collect(),
jpeg_decoder::PixelFormat::CMYK32 => pixels
.chunks_exact(4)
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect(),
};
Ok((u32::from(info.width), u32::from(info.height), rgba))
}
/// Convert the config bold color to the terminal style's bold policy.
fn bold_policy(c: Option<BoldColor>) -> Option<style::BoldColor> {
c.map(|c| match c {
BoldColor::Color(c) => style::BoldColor::Color(c.to_terminal_rgb()),
BoldColor::Bright => style::BoldColor::Bright,
})
}
/// Resolve a configured [`TerminalColor`] against the cell colors
/// (the repeated `.color / .cell-foreground / .cell-background` switch).
/// `cell-foreground` is the foreground unless the cell is inverse, in which
/// case it is the background (and vice versa for `cell-background`).
fn resolve_terminal_color(
c: TerminalColor,
inverse: bool,
fg_style: Option<Rgb>,
bg_style: Option<Rgb>,
) -> Option<Rgb> {
match c {
TerminalColor::Color(color) => Some(color.to_terminal_rgb()),
TerminalColor::CellForeground => {
if inverse {
bg_style
} else {
fg_style
}
}
TerminalColor::CellBackground => {
if inverse {
fg_style
} else {
bg_style
}
}
}
}
/// Mutable shaping context for a row rebuild.
struct ShapeCtx<'a> {
shaper: &'a mut shape::Shaper,
cache: &'a mut ShaperCache,
run_cells: &'a mut Vec<RunCell>,
shaped: &'a mut Vec<shape::Cell>,
}
/// Fill `shaped` with the shaped cells for `run`, from the cache if possible
/// (a copy is made because the shaper's buffer is only valid until the next
/// shaping call).
fn shaped_cells_for_run(
shaper: &mut shape::Shaper,
cache: &mut ShaperCache,
shaped: &mut Vec<shape::Cell>,
grid: &SharedGrid,
run: &TextRun,
) -> Result<(), Error> {
shaped.clear();
// Try to read the cells from the shaping cache if we can.
if let Some(cells) = cache.get(run) {
shaped.extend_from_slice(cells);
return Ok(());
}
// Otherwise we have to shape them.
let new_cells = shaper.shape_run(grid, run)?;
// Cache them: this is just a performance optimization.
cache.put(run, new_cells);
shaped.extend_from_slice(new_cells);
Ok(())
}
impl Renderer {
/// Create the renderer (`Renderer.init`).
///
/// `api` must already have its GL context prepared
/// ([`OpenGL::prepare_context`], i.e. Zig `Renderer.surfaceInit`), and the
/// context must be current on the calling thread: swap chain buffers,
/// textures and shaders are created here.
pub fn new(opts: Options<DerivedConfig>, api: OpenGL) -> Result<Renderer, Error> {
let config = opts.config;
let has_custom_shaders = !config.custom_shaders.is_empty();
// Prepare our swap chain
let swap_chain = SwapChain::new(&api, has_custom_shaders)?;
// Create the font shaper.
let font_shaper = shape::Shaper::new(&shape::Options {
features: config.font_features.clone(),
})?;
// Initialize all the data that requires a critical font section.
let grid_metrics = opts.font_grid.metrics();
let mut draw = DrawState {
config: config.clone(),
grid_metrics,
size: opts.size,
focused: true,
custom_shader_focused_changed: false,
scrollbar: Scrollbar::default(),
scrollbar_dirty: false,
// Render state
cells: Contents::new(),
cells_rebuilt: false,
uniforms: Uniforms {
min_contrast: config.min_contrast,
cursor_pos: [u16::MAX, u16::MAX],
bg_color: [
config.background.r,
config.background.g,
config.background.b,
// Note that if we're on macOS with glass effects
// we'll disable background opacity but we handle
// that in update_frame.
(config.background_opacity * 255.0).round() as u8,
],
bools: Bools {
cursor_wide: false,
use_display_p3: config.colorspace == WindowColorspace::DisplayP3,
use_linear_blending: config.blending.is_linear(),
use_linear_correction: config.blending == AlphaBlending::LinearCorrected,
}
.to_bits(),
..Uniforms::default()
},
custom_shader_uniforms: shadertoy::Uniforms {
resolution: [0.0, 0.0, 1.0],
frame_rate: 60.0, // not currently updated
focus: 1, // assume focused initially
..shadertoy::Uniforms::default()
},
first_frame_time: None,
last_frame_time: None,
font_grid: opts.font_grid,
images: image::State::new(),
has_overlay: false,
bg_image: None,
bg_image_buffer: BgImage::default(),
bg_image_buffer_modified: 0,
api,
swap_chain,
target_config_modified: 0,
reinitialize_shaders: false,
has_custom_shaders: false,
shaders: None,
};
draw.init_shaders()?;
// Ensure our default values above are correctly initialized.
draw.update_font_grid_uniforms();
draw.update_screen_size_uniforms();
draw.update_bg_image_buffer();
draw.prep_background_image();
let thread = ThreadState {
config,
terminal_state: RenderState::new(),
terminal_state_frame_count: 0,
last_bottom: None,
search_matches: None,
search_selected_match: None,
search_matches_dirty: false,
overlay: None,
font_shaper,
font_shaper_cache: ShaperCache::new(),
run_cells: Vec::new(),
shaped_cells: Vec::new(),
};
let has_animations = AtomicBool::new(draw.has_custom_shaders);
Ok(Renderer {
draw: Mutex::new(draw),
thread: Mutex::new(thread),
health: AtomicU8::new(Health::Healthy as u8),
has_animations,
host: opts.host,
})
}
/// This is called by the GTK apprt after the surface is reinitialized
/// due to any of the events mentioned in the doc comment of
/// [`Renderer::display_unrealized`]. The GL context must be current.
pub fn display_realized<F>(&self, get_proc_address: F) -> Result<(), Error>
where
F: FnMut(&str) -> *const std::ffi::c_void,
{
// Lock the draw mutex so that we can
// safely reinitialize our GPU resources.
let mut d = lock(&self.draw);
// If our API has to do things on realize, let it.
d.api.display_realized(get_proc_address);
// We assume that the swap chain was deinited in
// `display_unrealized`, in which case it should be
// marked defunct. If not, we have a problem.
debug_assert!(d.swap_chain.defunct);
// We reinitialize our shaders and our swap chain.
d.init_shaders()?;
self.has_animations
.store(d.has_custom_shaders, Ordering::Relaxed);
d.swap_chain = SwapChain::new(&d.api, d.has_custom_shaders)?;
d.reinitialize_shaders = false;
d.target_config_modified = 1;
d.api.clear_last_target();
// The new atlas textures and buffers need to be refilled.
d.update_bg_image_buffer();
Ok(())
}
/// This is called by the GTK apprt when the surface is being destroyed.
/// This can happen because the surface is being closed but also when
/// moving the window between displays or splitting. The GL context
/// must be current.
pub fn display_unrealized(&self) {
// Lock the draw mutex so that we can
// safely deinitialize our GPU resources. (The thread state is locked
// first, per the lock order.)
let mut t = lock(&self.thread);
let mut d = lock(&self.draw);
// We deinit our swap chain and shaders.
//
// This will mark them as defunct so that they
// can't be double-freed or used in draw calls.
d.swap_chain.deinit();
d.shaders = None;
d.api.clear_last_target();
// GPU textures of images and the background image die with the
// context; drop them so they are re-uploaded after realize.
d.images = image::State::new();
d.images.force_kitty_update = true;
d.bg_image = None;
d.prep_background_image();
t.terminal_state.dirty = Dirty::Full;
}
/// Mark the full screen as dirty so that we redraw everything.
pub fn mark_dirty(&self) {
lock(&self.thread).terminal_state.dirty = Dirty::Full;
}
/// True if our renderer has animations so that a higher frequency
/// timer is used.
pub fn has_animations(&self) -> bool {
self.has_animations.load(Ordering::Relaxed)
}
/// True if our renderer is using vsync. If true, the renderer or apprt
/// is responsible for triggering draw_now calls to the render thread.
/// That is the only way to trigger a `draw_frame`. (Always false for
/// OpenGL on Linux; there is no display link.)
pub fn has_vsync(&self) -> bool {
false
}
/// The health of the most recently completed frame.
pub fn health(&self) -> Health {
if self.health.load(Ordering::SeqCst) == Health::Healthy as u8 {
Health::Healthy
} else {
Health::Unhealthy
}
}
/// The current size of everything as the renderer knows it.
pub fn size(&self) -> Size {
lock(&self.draw).size
}
/// Callback when the focus changes for the terminal this is rendering.
///
/// Must be called on the render thread.
pub fn set_focus(&self, focus: bool) {
let mut d = lock(&self.draw);
debug_assert_ne!(d.focused, focus);
d.focused = focus;
// Flag that we need to update our custom shaders
d.custom_shader_focused_changed = true;
}
/// Callback when the window is visible or occluded.
///
/// Must be called on the render thread. (There is no display link on
/// Linux, so there is nothing to do.)
pub fn set_visible(&self, _visible: bool) {}
/// Set the new font grid.
///
/// Must be called on the render thread.
pub fn set_font_grid(&self, grid: Arc<SharedGrid>) {
let mut t = lock(&self.thread);
let mut d = lock(&self.draw);
// Get our metrics from the grid. This doesn't require a lock because
// the metrics are never recalculated.
let metrics = grid.metrics();
// Update our grid
d.font_grid = grid;
// Update all our textures so that they sync on the next frame.
for frame in d.swap_chain.frames_mut() {
frame.grayscale_modified = 0;
frame.color_modified = 0;
}
d.grid_metrics = metrics;
// Reset our shaper cache. If our font changed (not just the size) then
// the data in the shaper cache may be invalid and cannot be used, so we
// always clear the cache just in case.
t.font_shaper_cache = ShaperCache::new();
// Update cell size.
d.size.cell = CellSize {
width: metrics.cell_width,
height: metrics.cell_height,
};
// Update relevant uniforms
d.update_font_grid_uniforms();
// Force a full rebuild, because cached rows may still reference
// an outdated atlas from the old grid and this can cause garbage
// to be rendered.
t.terminal_state.dirty = Dirty::Full;
}
/// Update the configuration.
#[allow(clippy::float_cmp)]
pub fn change_config(&self, config: DerivedConfig) -> Result<(), Error> {
let mut t = lock(&self.thread);
let mut d = lock(&self.draw);
// We always redo the font shaper in case font features changed. We
// could check to see if there was an actual config change but this is
// easier and rare enough to not cause performance issues.
t.font_shaper = shape::Shaper::new(&shape::Options {
features: config.font_features.clone(),
})?;
// We also need to reset the shaper cache so shaper info
// from the previous font isn't reused for the new font.
t.font_shaper_cache = ShaperCache::new();
// Set our new minimum contrast
d.uniforms.min_contrast = config.min_contrast;
// Set our new color space and blending
let mut bools = Bools::from_bits(d.uniforms.bools);
bools.use_display_p3 = config.colorspace == WindowColorspace::DisplayP3;
bools.use_linear_blending = config.blending.is_linear();
bools.use_linear_correction = config.blending == AlphaBlending::LinearCorrected;
d.uniforms.bools = bools.to_bits();
let bg_image_config_changed = d.config.bg_image_fit != config.bg_image_fit
|| d.config.bg_image_position != config.bg_image_position
|| d.config.bg_image_repeat != config.bg_image_repeat
|| d.config.bg_image_opacity != config.bg_image_opacity;
let bg_image_changed = d.config.bg_image != config.bg_image;
let old_blending = d.config.blending;
let custom_shaders_changed = d.config.custom_shaders != config.custom_shaders;
d.config = config.clone();
t.config = config;
// If our background image path changed, prepare the new bg image.
if bg_image_changed {
d.prep_background_image();
}
// If our background image config changed, update the vertex buffer.
if bg_image_config_changed {
d.update_bg_image_buffer();
}
// Reset our viewport to force a rebuild, in case of a font change.
t.terminal_state.dirty = Dirty::Full;
let blending_changed = old_blending != d.config.blending;
if blending_changed {
// We update our API's blending mode.
d.api.blending = d.config.blending;
// And indicate that we need to reinitialize our shaders.
d.reinitialize_shaders = true;
// And indicate that our swap chain targets need to
// be re-created to account for the new blending mode.
d.target_config_modified = d.target_config_modified.wrapping_add(1);
}
if custom_shaders_changed {
d.reinitialize_shaders = true;
}
Ok(())
}
/// Resize the screen.
pub fn set_screen_size(&self, size: Size) {
let mut d = lock(&self.draw);
// We only actually need the padding from this,
// everything else is derived elsewhere.
d.size.padding = size.padding;
d.update_screen_size_uniforms();
log::debug!("screen size size={size:?}");
}
/// Set the search highlights (from the renderer thread mailbox).
pub fn set_search_viewport_matches(&self, matches: Vec<Flattened>) {
let mut t = lock(&self.thread);
t.search_matches = Some(matches);
t.search_matches_dirty = true;
}
/// Set the selected search match (from the renderer thread mailbox).
pub fn set_search_selected_match(&self, m: Option<Flattened>) {
let mut t = lock(&self.thread);
t.search_selected_match = m;
t.search_matches_dirty = true;
}
/// Read back the pixels (RGBA8, bottom-up like `glReadPixels`) of the
/// most recently presented frame. Intended for tests and screenshots;
/// requires the GL context to be current.
pub fn last_frame_pixels(&self) -> Option<(usize, usize, Vec<u8>)> {
let d = lock(&self.draw);
let target = Arc::clone(d.api.last_target()?);
let gl = d.api.gl()?.clone();
let bind = gl.bind_framebuffer(target.framebuffer(), gl::FramebufferTarget::Read);
let px = gl.read_pixels_rgba8(0, 0, target.width as i32, target.height as i32);
gl.unbind_framebuffer(bind);
Some((target.width, target.height, px))
}
// Callback from the graphics API when a frame is completed.
fn frame_completed(&self, health: Health) {
// If our health value hasn't changed, then we do nothing. We don't
// do a cmpxchg here because strict atomicity isn't important.
if self.health.load(Ordering::SeqCst) != health as u8 {
self.health.store(health as u8, Ordering::SeqCst);
// Our health value changed, so we notify the surface so that it
// can do something about it.
self.host.renderer_health(health);
}
}
}
// ---------------------------------------------------------------------------
// update_frame (renderer thread)
/// Data extracted from the terminal inside the critical section.
struct Critical {
links: HashSet<Coordinate>,
mouse_point: Option<Coordinate>,
mouse_mods: Mods,
preedit: Option<Preedit>,
scrollbar: Scrollbar,
overlay_features: Vec<overlay::Feature>,
}
/// The max number of frames before we throw away the `RenderState`
/// (~12 minutes at 120Hz).
const MAX_TERMINAL_STATE_FRAME_COUNT: usize = 100_000;
impl Renderer {
/// Update the frame data.
///
/// This is the renderer thread half of the renderer: it locks
/// `state` (the renderer mutex) briefly to snapshot the terminal and then
/// rebuilds the CPU copy of the GPU cell buffers. It performs no GL calls.
pub fn update_frame(
&self,
state: &SharedState,
cursor_blink_visible: bool,
) -> Result<(), Error> {
let mut guard = lock(&self.thread);
let t = &mut *guard;
// We fully deinit and reset the terminal state every so often
// so that a particularly large terminal state doesn't cause
// the renderer to hold on to retained memory.
if t.terminal_state_frame_count >= MAX_TERMINAL_STATE_FRAME_COUNT {
t.terminal_state = RenderState::new();
}
t.terminal_state_frame_count += 1;
// Update all our data as tightly as possible within the mutex.
let mut critical = {
let mut st = lock(state);
let st = &mut *st;
// If we're in a synchronized output state, we pause all rendering.
if st.terminal.modes.get(Mode::SynchronizedOutput) {
log::debug!("synchronized output started, skipping render");
return Ok(());
}
// If scroll-to-bottom on output is enabled, check if the final line
// changed by comparing the bottom-right pin. If the node or
// y offset changed, new content was added to the screen.
// Update this BEFORE we update our render state so we can
// draw the new scrolled data immediately.
if t.config.scroll_to_bottom_on_output
&& let Some(br) = st.terminal.screen().pages.get_bottom_right(Tag::Screen)
&& t.last_bottom != Some((br.node, br.y))
{
// Update tracked pin state for next frame
t.last_bottom = Some((br.node, br.y));
// Scroll
st.terminal.scroll_viewport(ScrollViewport::Bottom);
}
// Update our terminal state
t.terminal_state
.update(&mut st.terminal)
.map_err(Error::Terminal)?;
// If our terminal state is dirty at all we need to redo
// the viewport search.
if t.terminal_state.dirty != Dirty::False {
st.terminal.flags.search_viewport_dirty = true;
}
// Get our scrollbar out of the terminal. We synchronize
// the scrollbar read with frame data updates because this
// naturally limits the number of calls to this method (it
// can be expensive) and also makes it so we don't need another
// cross-thread mailbox message within the IO path.
let scrollbar = st.terminal.screen().pages.scrollbar();
// Get our preedit state
let preedit = st.preedit.clone();
// If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
// We only do this if the Kitty image state is dirty meaning only if
// it changes.
//
// If we have any virtual references, we must also rebuild our
// kitty state on every frame because any cell change can move
// an image.
{
let mut d = lock(&self.draw);
if d.images.kitty_requires_update(&st.terminal) {
let cell_size = CellSize {
width: d.grid_metrics.cell_width,
height: d.grid_metrics.cell_height,
};
update_kitty(&mut d.images, &mut st.terminal, cell_size);
}
}
// Get our OSC8 links we're hovering if we have a mouse.
// This requires terminal state because of URLs.
let links = 'osc8: {
// If our mouse isn't hovering, we have no links.
let Some(vp) = st.mouse.point else {
break 'osc8 HashSet::new();
};
// If the right mods aren't pressed, then we can't match.
if !st
.mouse
.mods
.equal(ghostty_input::ctrl_or_super(Mods::default()))
{
break 'osc8 HashSet::new();
}
t.terminal_state.link_cells(&st.terminal.screen().pages, vp)
};
Critical {
links,
mouse_point: st.mouse.point,
mouse_mods: st.mouse.mods,
preedit,
scrollbar,
overlay_features: st.overlay_features.clone(),
}
};
// Outside the critical area we can update our links to contain
// our regex results.
if let Err(err) = t.config.links.render_cell_map(
&mut critical.links,
&t.terminal_state,
critical.mouse_point,
critical.mouse_mods,
) {
log::warn!("error searching for regex links err={err}");
}
// Clear our highlight state and update.
if t.search_matches_dirty || t.terminal_state.dirty != Dirty::False {
t.search_matches_dirty = false;
// Clear the prior highlights
let mut any_dirty = false;
for row in &mut t.terminal_state.row_data {
if !row.highlights.is_empty() {
row.highlights.clear();
row.dirty = true;
any_dirty = true;
}
}
if any_dirty && t.terminal_state.dirty == Dirty::False {
t.terminal_state.dirty = Dirty::Partial;
}
// NOTE: The order below matters. Highlights added earlier
// will take priority.
if let Some(m) = &t.search_selected_match {
t.terminal_state.update_highlights_flattened(
HighlightTag::SearchMatchSelected as u8,
std::slice::from_ref(m),
);
}
if let Some(m) = &t.search_matches {
t.terminal_state
.update_highlights_flattened(HighlightTag::SearchMatch as u8, m);
}
}
// Rebuild the overlay image if we have one. We can do this
// outside of any critical areas.
if let Err(err) = self.rebuild_overlay(t, &critical.overlay_features) {
log::warn!("error rebuilding overlay surface err={err}");
}
// Acquire the draw mutex for all remaining state updates.
{
let mut d = lock(&self.draw);
// Build our GPU cells
let cursor_style = cursor::style(
&t.terminal_state,
cursor::StyleOptions {
preedit: critical.preedit.is_some(),
focused: d.focused,
blink_visible: cursor_blink_visible,
},
);
d.rebuild_cells(t, critical.preedit.as_ref(), cursor_style, &critical.links);
// The scrollbar is only emitted during draws so we also
// check the scrollbar cache here and update if needed.
// This is pretty fast.
if d.scrollbar != critical.scrollbar {
d.scrollbar = critical.scrollbar;
d.scrollbar_dirty = true;
}
// Update our background color
d.uniforms.bg_color = [
t.terminal_state.colors.background.r,
t.terminal_state.colors.background.g,
t.terminal_state.colors.background.b,
(t.config.background_opacity * 255.0).round() as u8,
];
// (On macOS glass styles the background opacity is removed; that
// is out of scope on Linux.)
// Prepare our overlay image for upload (or unload).
d.has_overlay = t.overlay.is_some();
if let Err(err) = d.images.overlay_update(t.overlay.as_ref()) {
log::warn!("error updating overlay images err={err}");
}
// Update custom shader uniforms that depend on terminal state.
let cursor_text = t.config.cursor_text;
d.update_custom_shader_uniforms_from_state(&t.terminal_state, cursor_text);
}
// Reset our dirty state after updating.
t.terminal_state.dirty = Dirty::False;
// Notify our shaper we're done for the frame. For some shapers,
// such as CoreText, this triggers off-thread cleanup logic.
t.font_shaper.end_frame();
Ok(())
}
/// Build the overlay as configured (`rebuildOverlay`).
fn rebuild_overlay(
&self,
t: &mut ThreadState,
features: &[overlay::Feature],
) -> Result<(), overlay::InitError> {
// If we have no features enabled, don't build an overlay.
// If we had a previous overlay, deallocate it.
if features.is_empty() {
t.overlay = None;
return Ok(());
}
let size = lock(&self.draw).size;
// If we had a previous overlay, clear it. Otherwise, init.
let reuse = match &t.overlay {
Some(v) => {
// Verify that our overlay size matches our screen
// size as we know it now. If not, deinit and reinit.
let term_size = size.terminal();
// We also depend on cell size.
v.width() == term_size.width
&& v.height() == term_size.height
&& v.cell_size == size.cell
}
None => false,
};
if reuse {
// Everything matches, so we can just reset the surface
// and redraw.
if let Some(v) = &mut t.overlay {
v.reset();
}
} else {
// If we reached this point we want to reset our overlay.
t.overlay = None;
t.overlay = Some(Overlay::new(&size)?);
}
if let Some(v) = &mut t.overlay {
v.apply_features(&t.terminal_state, features);
}
Ok(())
}
}
/// Update the kitty image state from the terminal (the `kittyUpdate` call in
/// `updateFrame`).
fn update_kitty(
images: &mut image::State,
terminal: &mut ghostty_terminal::terminal::Terminal,
cell_size: CellSize,
) {
// TODO(integration): `Screen.kitty_images` is still the placeholder
// `KittyImages { dirty }` (see docs/rewrite/notes/ghostty-terminal-kitty.md).
// Once it is a `kitty::ImageStorage`, call
// images.kitty_update(&screen.kitty_images, &screen.pages,
// &TerminalInfo::from_terminal(terminal), cell_size);
// and then clear `screen.kitty_images.dirty`. `image::State::kitty_update`
// is implemented and compiled against the real storage API already.
let _ = cell_size;
images.force_kitty_update = false;
terminal.screen_mut().kitty_images.dirty = false;
}
// ---------------------------------------------------------------------------
// rebuild_cells (draw lock held, renderer thread)
/// Read-only data shared by every row rebuild.
struct RowEnv<'a> {
config: &'a DerivedConfig,
colors: &'a ghostty_terminal::render::Colors,
cursor: &'a RenderCursor,
cols: u16,
}
/// Which highlight (if any) covers a cell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)]
enum Selected {
False,
Selection,
Search,
SearchSelected,
}
impl DrawState {
/// Convert the terminal state to GPU cells stored in CPU memory. These
/// are then synced to the GPU in the next frame. This only updates CPU
/// memory and doesn't touch the GPU.
///
/// This requires the draw mutex.
///
/// Dirty state on terminal state won't be reset by this.
fn rebuild_cells(
&mut self,
t: &mut ThreadState,
preedit: Option<&Preedit>,
cursor_style_: Option<CursorStyle>,
links: &HashSet<Coordinate>,
) {
let ThreadState {
config,
terminal_state: state,
font_shaper,
font_shaper_cache,
run_cells,
shaped_cells,
..
} = t;
let grid_size_diff =
self.cells.size.rows != state.rows || self.cells.size.columns != state.cols;
if grid_size_diff {
let new_size = GridSize {
rows: state.rows,
columns: state.cols,
};
self.cells.resize(new_size);
// Update our uniforms accordingly, otherwise
// our background cells will be out of place.
self.uniforms.grid_size = [new_size.columns, new_size.rows];
}
let rebuild = state.dirty == Dirty::Full || grid_size_diff;
if rebuild {
// If we are doing a full rebuild, then we clear the entire cell buffer.
self.cells.reset();
// We also reset our padding extension depending on the screen type
match config.padding_color {
WindowPaddingColor::Background => {}
// For extension, assume we are extending in all directions.
// For "extend" this may be disabled due to heuristics below.
WindowPaddingColor::Extend | WindowPaddingColor::ExtendAlways => {
self.uniforms.padding_extend = PaddingExtend {
up: true,
down: true,
left: true,
right: true,
}
.to_bits();
}
}
}
let RenderState {
rows,
cols,
colors,
cursor,
row_data,
..
} = &mut *state;
let (rows, cols) = (*rows, *cols);
// If our cell contents buffer is shorter than the screen viewport,
// we render the rows that fit, starting from the bottom. If instead
// the viewport is shorter than the cell contents buffer, we align
// the top of the viewport with the top of the contents buffer.
let row_len = usize::from(rows.min(self.cells.size.rows));
// Determine our x/y range for preedit. We don't want to render anything
// here because we will render the preedit separately.
let preedit_range: Option<PreeditRange> = preedit.and_then(|preedit_v| {
// We base the preedit on the position of the cursor in the
// viewport. If the cursor isn't visible in the viewport we
// don't show it.
let cursor_vp = cursor.viewport?;
// If our preedit row isn't dirty then we don't need the
// preedit range. This also avoids an issue later where we
// unconditionally add preedit cells when this is set.
if !rebuild && !row_data[usize::from(cursor_vp.y)].dirty {
return None;
}
let range = preedit_v.range(cursor_vp.x, cols - 1);
Some(PreeditRange {
y: cursor_vp.y,
x: [range.start, range.end],
cp_offset: range.cp_offset,
})
});
let env = RowEnv {
config,
colors,
cursor,
cols,
};
let mut sh = ShapeCtx {
shaper: font_shaper,
cache: font_shaper_cache,
run_cells,
shaped: shaped_cells,
};
for (y_usize, row) in row_data.iter_mut().enumerate().take(row_len) {
let y = y_usize as u16;
if !rebuild {
// Only rebuild if we are doing a full rebuild or this row is dirty.
if !row.dirty {
continue;
}
// Clear the cells if the row is dirty
self.cells.clear(y);
}
// Unmark the dirty state in our render state.
row.dirty = false;
if let Err(err) = self.rebuild_row(&env, y, row, preedit_range.as_ref(), links, &mut sh)
{
// This should never happen except under exceptional
// scenarios. In this case, we don't want to corrupt
// our render state so just clear this row and keep
// trying to finish it out.
log::warn!("error building row y={y} err={err}");
self.cells.clear(y);
}
}
// Setup our cursor rendering information.
self.setup_cursor(&env, row_data, cursor_style_, preedit.is_some());
// Setup our preedit text.
if let Some(preedit_v) = preedit
&& let Some(range) = &preedit_range
{
let mut x = range.x[0];
for cp in &preedit_v.codepoints[range.cp_offset..] {
if let Err(err) = self.add_preedit_cell(
config,
cp.codepoint,
cp.wide,
Coordinate {
x,
y: u32::from(range.y),
},
colors.foreground,
) {
log::warn!(
"error building preedit cell, will be invalid x={x} y={}, err={err}",
range.y
);
}
x += if cp.wide { 2 } else { 1 };
}
}
// Update that our cells rebuilt
self.cells_rebuilt = true;
}
fn setup_cursor(
&mut self,
env: &RowEnv<'_>,
row_data: &[RenderRow],
cursor_style_: Option<CursorStyle>,
has_preedit: bool,
) {
let config = env.config;
let colors = env.colors;
// Clear our cursor by default.
self.cells.set_cursor(None, None);
self.uniforms.cursor_pos = [u16::MAX, u16::MAX];
// If the cursor isn't visible on the viewport, don't show
// a cursor. Otherwise, get our cursor cell, because we may
// need it for styling.
let Some(cursor_vp) = env.cursor.viewport else {
return;
};
let cursor_style: Style = {
let cell = row_data[usize::from(cursor_vp.y)]
.cells
.get(usize::from(cursor_vp.x));
if cell.raw.has_styling() {
cell.style
} else {
Style::default()
}
};
// If we have preedit text, we don't setup a cursor
if has_preedit {
return;
}
// If there isn't a cursor visual style requested then
// we don't render a cursor.
let Some(style) = cursor_style_ else {
return;
};
let bold = bold_policy(config.bold_color);
let fg_style = cursor_style.fg(style::Fg {
default: colors.foreground,
palette: &colors.palette,
bold,
});
let bg_style = cursor_style
.bg(&env.cursor.cell, &colors.palette)
.unwrap_or(colors.background);
// Determine the cursor color.
let cursor_color = 'color: {
// If an explicit cursor color was set by OSC 12, use that.
if let Some(v) = colors.cursor {
break 'color v;
}
// Use our configured color if specified
if let Some(v) = config.cursor_color {
break 'color resolve_terminal_color(
v,
cursor_style.flags.inverse,
Some(fg_style),
Some(bg_style),
)
.unwrap_or(colors.foreground);
}
colors.foreground
};
self.add_cursor(env, style, cursor_color);
// If the cursor is visible then we set our uniforms.
if style == CursorStyle::Block {
let wide = env.cursor.cell.wide();
self.uniforms.cursor_pos = [
// If we are a spacer tail of a wide cell, our cursor needs
// to move back one cell. The saturate is to ensure we don't
// overflow but this shouldn't happen with well-formed input.
match wide {
Wide::Narrow | Wide::SpacerHead | Wide::Wide => cursor_vp.x,
Wide::SpacerTail => cursor_vp.x.saturating_sub(1),
},
cursor_vp.y,
];
let mut bools = Bools::from_bits(self.uniforms.bools);
bools.cursor_wide = match wide {
Wide::Narrow | Wide::SpacerHead => false,
Wide::Wide | Wide::SpacerTail => true,
};
self.uniforms.bools = bools.to_bits();
let uniform_color = if let Some(txt) = config.cursor_text {
// If cursor-text is set, then compute the correct color.
// Otherwise, use the background color.
resolve_terminal_color(
txt,
cursor_style.flags.inverse,
Some(fg_style),
Some(bg_style),
)
.unwrap_or(colors.background)
} else {
colors.background
};
self.uniforms.cursor_color = [uniform_color.r, uniform_color.g, uniform_color.b, 255];
}
}
}
impl DrawState {
#[allow(clippy::too_many_arguments)]
fn rebuild_row(
&mut self,
env: &RowEnv<'_>,
y: u16,
row: &RenderRow,
preedit_range: Option<&PreeditRange>,
links: &HashSet<Coordinate>,
sh: &mut ShapeCtx<'_>,
) -> Result<(), Error> {
let config = env.config;
let colors = env.colors;
let cursor = env.cursor;
// If our viewport is wider than our cell contents buffer,
// we still only process cells up to the width of the buffer.
let cells = &row.cells;
let cells_len = cells.len().min(usize::from(self.cells.size.columns));
let cells_raw = &cells.raw;
// On primary screen, we still apply vertical padding
// extension under certain conditions we feel are safe.
//
// This helps make some scenarios look better while
// avoiding scenarios we know do NOT look good.
match config.padding_color {
// These already have the correct values set above.
WindowPaddingColor::Background | WindowPaddingColor::ExtendAlways => {}
// Apply heuristics for padding extension.
WindowPaddingColor::Extend => {
if y == 0 {
let mut pe = PaddingExtend::from_bits(self.uniforms.padding_extend);
pe.up = !never_extend_bg(
row.raw,
cells_raw,
&cells.style,
&colors.palette,
colors.background,
);
self.uniforms.padding_extend = pe.to_bits();
} else if y == self.cells.size.rows - 1 {
let mut pe = PaddingExtend::from_bits(self.uniforms.padding_extend);
pe.down = !never_extend_bg(
row.raw,
cells_raw,
&cells.style,
&colors.palette,
colors.background,
);
self.uniforms.padding_extend = pe.to_bits();
}
}
}
// The run iterator wants an owned-cell slice (`RunCell`).
sh.run_cells.clear();
sh.run_cells.extend(cells.iter().map(|c| RunCell {
raw: c.raw,
grapheme: c.grapheme.to_vec(),
style: c.style,
}));
// Iterator of runs for shaping.
let font_grid = Arc::clone(&self.font_grid);
let mut run_iter_opts = RunOptions::new(&font_grid, sh.run_cells.as_slice());
run_iter_opts.selection = row.selection;
// We want to do font shaping as long as the cursor is
// visible on this viewport.
run_iter_opts.cursor_x = cursor
.viewport
.filter(|vp| vp.y == y)
.map(|vp| usize::from(vp.x));
run_iter_opts.apply_break_config(config.font_shaping_break);
let mut run_iter = RunIterator::new(run_iter_opts);
let mut shaper_run: Option<TextRun> = run_iter.next(&mut *sh.shaper)?;
// `sh.shaped` holds the shaped cells of `shaper_run` when `have_cells`.
let mut have_cells = false;
let mut shaper_cells_i: usize = 0;
for x in 0..cells_len {
// If this cell falls within our preedit range then we
// skip this because preedits are setup separately.
if let Some(range) = preedit_range
&& range.y == y
&& x >= usize::from(range.x[0])
{
// We're in the preedit range, skip this cell.
if x <= usize::from(range.x[1]) {
continue;
}
// After exiting the preedit range we need to catch
// the run position up because of the missed cells.
// In all other cases, no action is necessary.
if x == usize::from(range.x[1]) + 1 {
// Step the run iterator until we find a run that ends
// after the current cell, which will be the soonest run
// that might contain glyphs for our cell.
while let Some(run) = shaper_run {
if usize::from(run.offset) + usize::from(run.cells) > x {
break;
}
shaper_run = run_iter.next(&mut *sh.shaper)?;
have_cells = false;
shaper_cells_i = 0;
}
if let Some(run) = shaper_run {
// If we haven't shaped this run, do so now.
if !have_cells {
shaped_cells_for_run(sh.shaper, sh.cache, sh.shaped, &font_grid, &run)?;
have_cells = true;
}
// Advance our index until we reach or pass
// our current x position in the shaper cells.
while shaper_cells_i < sh.shaped.len()
&& usize::from(run.offset) + usize::from(sh.shaped[shaper_cells_i].x)
< x
{
shaper_cells_i += 1;
}
}
}
}
let cell = cells_raw[x];
let wide = cell.wide();
let cell_style: Style = if cell.has_styling() {
cells.style(x)
} else {
Style::default()
};
// True if this cell is selected
let selected = 'selected: {
// Order below matters for precedence.
// Selection should take the highest precedence.
let x_compare = if wide == Wide::SpacerTail {
x.saturating_sub(1)
} else {
x
};
if let Some(sel) = row.selection
&& x_compare >= usize::from(sel[0])
&& x_compare <= usize::from(sel[1])
{
break 'selected Selected::Selection;
}
// If we're highlighted, then we're selected. In the
// future we want to use a different style for this
// but this to get started.
for hl in &row.highlights {
if x_compare >= usize::from(hl.range[0])
&& x_compare <= usize::from(hl.range[1])
{
break 'selected if hl.tag == HighlightTag::SearchMatchSelected as u8 {
Selected::SearchSelected
} else {
Selected::Search
};
}
}
Selected::False
};
// The `_style` suffixed values are the colors based on
// the cell style (SGR), before applying any additional
// configuration, inversions, selections, etc.
let bg_style = cell_style.bg(&cell, &colors.palette);
let fg_style = cell_style.fg(style::Fg {
default: colors.foreground,
palette: &colors.palette,
bold: bold_policy(config.bold_color),
});
let inverse = cell_style.flags.inverse;
// The final background color for the cell.
let bg: Option<Rgb> = match selected {
// If we have an explicit selection background color
// specified in the config, use that.
//
// If no configuration, then our selection background
// is our foreground color.
Selected::Selection => match config.selection_background {
Some(v) => resolve_terminal_color(v, inverse, Some(fg_style), bg_style),
None => Some(colors.foreground),
},
Selected::Search => resolve_terminal_color(
config.search_background,
inverse,
Some(fg_style),
bg_style,
),
Selected::SearchSelected => resolve_terminal_color(
config.search_selected_background,
inverse,
Some(fg_style),
bg_style,
),
// Not selected
Selected::False => {
if inverse == cell::is_covering(cell.codepoint()) {
// Otherwise they cancel out.
bg_style
} else {
// Two cases cause us to invert (use the fg color as the bg)
// - The "inverse" style flag.
// - A "covering" glyph; we use fg for bg in that
// case to help make sure that padding extension
// works correctly.
//
// If one of these is true (but not the other)
// then we use the fg style color for the bg.
Some(fg_style)
}
}
};
let fg: Rgb = {
// Our happy-path non-selection background color
// is our style or our configured defaults.
let final_bg = bg_style.unwrap_or(colors.background);
// Whether we need to use the bg color as our fg color:
// - Cell is selected, inverted, and set to cell-foreground
// - Cell is selected, not inverted, and set to cell-background
// - Cell is inverted and not selected
match selected {
Selected::Selection => match config.selection_foreground {
Some(v) => {
resolve_terminal_color(v, inverse, Some(fg_style), Some(final_bg))
}
None => Some(colors.background),
},
Selected::Search => resolve_terminal_color(
config.search_foreground,
inverse,
Some(fg_style),
Some(final_bg),
),
Selected::SearchSelected => resolve_terminal_color(
config.search_selected_foreground,
inverse,
Some(fg_style),
Some(final_bg),
),
Selected::False => Some(if inverse { final_bg } else { fg_style }),
}
.unwrap_or(fg_style)
};
// Foreground alpha for this cell.
let alpha: u8 = if cell_style.flags.faint {
config.faint_opacity
} else {
255
};
// Set the cell's background color.
{
let rgb = bg.unwrap_or(colors.background);
// Determine our background alpha. If we have transparency configured
// then this is dynamic depending on some situations. This is all
// in an attempt to make transparency look the best for various
// situations. See inline comments.
let bg_alpha: u8 = 'bg_alpha: {
let default: u8 = 255;
// Cells that are selected should be fully opaque.
if selected != Selected::False {
break 'bg_alpha default;
}
// Cells that are reversed should be fully opaque.
if inverse {
break 'bg_alpha default;
}
// If the user requested to have opacity on all cells, apply it.
if config.background_opacity_cells && bg_style.is_some() {
let mut opacity = f64::from(default);
opacity *= config.background_opacity;
break 'bg_alpha opacity as u8;
}
// Cells that have an explicit bg color should be fully opaque.
if bg_style.is_some() {
break 'bg_alpha default;
}
// Otherwise, we won't draw the bg for this cell,
// we'll let the already-drawn background color
// show through.
0
};
*self.cells.bg_cell(usize::from(y), x) = [rgb.r, rgb.g, rgb.b, bg_alpha];
}
// If the invisible flag is set on this cell then we
// don't need to render any foreground elements, so
// we just skip all glyphs with this x coordinate.
//
// NOTE: This behavior matches xterm. Some other terminal
// emulators, e.g. Alacritty, still render text decorations
// and only make the text itself invisible. The decision
// has been made here to match xterm's behavior for this.
if cell_style.flags.invisible {
continue;
}
// Give links a single underline, unless they already have
// an underline, in which case use a double underline to
// distinguish them.
let underline = if links.contains(&Coordinate {
x: x as u16,
y: u32::from(y),
}) {
if cell_style.flags.underline == Underline::Single {
Underline::Double
} else {
Underline::Single
}
} else {
cell_style.flags.underline
};
// We draw underlines first so that they layer underneath text.
// This improves readability when a colored underline is used
// which intersects parts of the text (descenders).
if underline != Underline::None
&& let Err(err) = self.add_underline(
x as u16,
y,
underline,
cell_style.underline_color(&colors.palette).unwrap_or(fg),
alpha,
)
{
log::warn!(
"error adding underline to cell, will be invalid x={x} y={y}, err={err}"
);
}
if cell_style.flags.overline
&& let Err(err) = self.add_overline(x as u16, y, fg, alpha)
{
log::warn!("error adding overline to cell, will be invalid x={x} y={y}, err={err}");
}
// If we're at or past the end of our shaper run then
// we need to get the next run from the run iterator.
if have_cells && shaper_cells_i >= sh.shaped.len() {
shaper_run = run_iter.next(&mut *sh.shaper)?;
have_cells = false;
shaper_cells_i = 0;
}
if let Some(run) = shaper_run {
// If we haven't shaped this run yet, do so.
if !have_cells {
shaped_cells_for_run(sh.shaper, sh.cache, sh.shaped, &font_grid, &run)?;
have_cells = true;
}
// If there are no shaper cells for this run, ignore it.
// This can occur for runs of empty cells, and is fine.
if !sh.shaped.is_empty() && shaper_cells_i < sh.shaped.len() {
// If we encounter a shaper cell to the left of the current
// cell then we have some problems. This logic relies on x
// position monotonically increasing.
debug_assert!(
usize::from(run.offset) + usize::from(sh.shaped[shaper_cells_i].x) >= x
);
// NOTE: An assumption is made here that a single cell will never
// be present in more than one shaper run. If that assumption is
// violated, this logic breaks.
while shaper_cells_i < sh.shaped.len()
&& usize::from(run.offset) + usize::from(sh.shaped[shaper_cells_i].x) == x
{
let shaped_cell = sh.shaped[shaper_cells_i];
if let Err(err) = self.add_glyph(
config,
x as u16,
y,
usize::from(env.cols),
cells_raw,
shaped_cell,
&run,
fg,
alpha,
) {
log::warn!(
"error adding glyph to cell, will be invalid x={x} y={y}, err={err}"
);
}
shaper_cells_i += 1;
}
}
}
// Finally, draw a strikethrough if necessary.
if cell_style.flags.strikethrough
&& let Err(err) = self.add_strikethrough(x as u16, y, fg, alpha)
{
log::warn!(
"error adding strikethrough to cell, will be invalid x={x} y={y}, err={err}"
);
}
}
Ok(())
}
/// Render options for a sprite glyph that spans a single cell.
fn sprite_options(&self, cell_width: u8) -> RenderOptions {
RenderOptions {
cell_width: Some(cell_width),
..RenderOptions::new(self.grid_metrics)
}
}
/// Add a sprite decoration (underline, overline, strikethrough) to the
/// specified cell.
fn add_sprite_decoration(
&mut self,
key: CellKey,
sprite: Sprite,
x: u16,
y: u16,
color: Rgb,
alpha: u8,
) -> Result<(), Error> {
let render =
self.font_grid
.render_glyph(SPRITE_INDEX, sprite as u32, self.sprite_options(1))?;
self.cells.add(
key,
CellText {
atlas: CellTextAtlas::Grayscale as u8,
grid_pos: [x, y],
color: [color.r, color.g, color.b, alpha],
glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y],
glyph_size: [render.glyph.width, render.glyph.height],
bearings: [render.glyph.offset_x as i16, render.glyph.offset_y as i16],
..CellText::default()
},
);
Ok(())
}
/// Add an underline decoration to the specified cell
fn add_underline(
&mut self,
x: u16,
y: u16,
style: Underline,
color: Rgb,
alpha: u8,
) -> Result<(), Error> {
let sprite = match style {
Underline::None => unreachable!(),
Underline::Single => Sprite::Underline,
Underline::Double => Sprite::UnderlineDouble,
Underline::Dotted => Sprite::UnderlineDotted,
Underline::Dashed => Sprite::UnderlineDashed,
Underline::Curly => Sprite::UnderlineCurly,
};
self.add_sprite_decoration(CellKey::Underline, sprite, x, y, color, alpha)
}
/// Add a overline decoration to the specified cell
fn add_overline(&mut self, x: u16, y: u16, color: Rgb, alpha: u8) -> Result<(), Error> {
self.add_sprite_decoration(CellKey::Overline, Sprite::Overline, x, y, color, alpha)
}
/// Add a strikethrough decoration to the specified cell
fn add_strikethrough(&mut self, x: u16, y: u16, color: Rgb, alpha: u8) -> Result<(), Error> {
self.add_sprite_decoration(
CellKey::Strikethrough,
Sprite::Strikethrough,
x,
y,
color,
alpha,
)
}
/// Add a glyph to the specified cell.
#[allow(clippy::too_many_arguments)]
fn add_glyph(
&mut self,
config: &DerivedConfig,
x: u16,
y: u16,
cols: usize,
cell_raws: &[PageCell],
shaper_cell: shape::Cell,
shaper_run: &TextRun,
color: Rgb,
alpha: u8,
) -> Result<(), Error> {
let cell = cell_raws[usize::from(x)];
let cp = cell.codepoint();
// Render
let render = self.font_grid.render_glyph(
shaper_run.font_index,
shaper_cell.glyph_index,
RenderOptions {
thicken: config.font_thicken,
thicken_strength: config.font_thicken_strength,
cell_width: Some(cell.grid_width()),
// If there's no Nerd Font constraint for this codepoint
// then, if it's a symbol, we constrain it to fit inside
// its cell(s), we don't modify the alignment at all.
constraint: get_constraint(cp).unwrap_or(if cell::is_symbol(cp) {
Constraint {
size: ConstraintSize::Fit,
..Constraint::NONE
}
} else {
Constraint::NONE
}),
constraint_width: cell::constraint_width(cell_raws, usize::from(x), cols),
..RenderOptions::new(self.grid_metrics)
},
)?;
// If the glyph is 0 width or height, it will be invisible
// when drawn, so don't bother adding it to the buffer.
if render.glyph.width == 0 || render.glyph.height == 0 {
return Ok(());
}
self.cells.add(
CellKey::Text,
CellText {
atlas: match render.presentation {
Presentation::Emoji => CellTextAtlas::Color as u8,
Presentation::Text => CellTextAtlas::Grayscale as u8,
},
bools: if cell::no_min_contrast(cp) {
CellText::NO_MIN_CONTRAST
} else {
0
},
grid_pos: [x, y],
color: [color.r, color.g, color.b, alpha],
glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y],
glyph_size: [render.glyph.width, render.glyph.height],
bearings: [
(render.glyph.offset_x + i32::from(shaper_cell.x_offset)) as i16,
(render.glyph.offset_y + i32::from(shaper_cell.y_offset)) as i16,
],
..CellText::default()
},
);
Ok(())
}
fn add_cursor(&mut self, env: &RowEnv<'_>, cursor_style: CursorStyle, cursor_color: Rgb) {
let cursor_state = env.cursor;
let Some(cursor_vp) = cursor_state.viewport else {
return;
};
// Add the cursor. We render the cursor over the wide character if
// we're on the wide character tail.
let (wide, x) = if cursor_vp.wide_tail {
// If we're part of a wide character, we move the cursor back
// to the actual character.
(true, cursor_vp.x.saturating_sub(1))
} else {
// The cursor goes over the screen cursor position.
(cursor_state.cell.wide() == Wide::Wide, cursor_vp.x)
};
let alpha: u8 = if self.focused {
(255.0 * env.config.cursor_opacity).ceil() as u8
} else {
255
};
let render = match cursor_style {
CursorStyle::Block
| CursorStyle::BlockHollow
| CursorStyle::Bar
| CursorStyle::Underline => {
let sprite = match cursor_style {
CursorStyle::Block => Sprite::CursorRect,
CursorStyle::BlockHollow => Sprite::CursorHollowRect,
CursorStyle::Bar => Sprite::CursorBar,
CursorStyle::Underline => Sprite::CursorUnderline,
CursorStyle::Lock => unreachable!(),
};
match self.font_grid.render_glyph(
SPRITE_INDEX,
sprite as u32,
self.sprite_options(if wide { 2 } else { 1 }),
) {
Ok(r) => r,
Err(err) => {
log::warn!("error rendering cursor glyph err={err}");
return;
}
}
}
CursorStyle::Lock => {
match self.font_grid.render_codepoint(
0xF023, // lock symbol
FontStyle::Regular,
Some(Presentation::Text),
self.sprite_options(if wide { 2 } else { 1 }),
) {
Ok(Some(r)) => r,
Ok(None) => {
// This should never happen because we embed nerd
// fonts so we just log and return instead of fallback.
log::warn!("failed to find lock symbol for cursor codepoint=0xF023");
return;
}
Err(err) => {
log::warn!("error rendering cursor glyph err={err}");
return;
}
}
}
};
self.cells.set_cursor(
Some(CellText {
atlas: CellTextAtlas::Grayscale as u8,
bools: CellText::IS_CURSOR_GLYPH,
grid_pos: [x, cursor_vp.y],
color: [cursor_color.r, cursor_color.g, cursor_color.b, alpha],
glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y],
glyph_size: [render.glyph.width, render.glyph.height],
bearings: [render.glyph.offset_x as i16, render.glyph.offset_y as i16],
..CellText::default()
}),
Some(cursor_style),
);
}
fn add_preedit_cell(
&mut self,
config: &DerivedConfig,
codepoint: u32,
wide: bool,
coord: Coordinate,
screen_fg: Rgb,
) -> Result<(), Error> {
let _ = config;
// Render the glyph for our preedit text
let render = match self.font_grid.render_codepoint(
codepoint,
FontStyle::Regular,
Some(Presentation::Text),
RenderOptions::new(self.grid_metrics),
) {
Ok(Some(r)) => r,
Ok(None) => {
log::warn!("failed to find font for preedit codepoint={codepoint:X}");
return Ok(());
}
Err(err) => {
log::warn!("error rendering preedit glyph err={err}");
return Ok(());
}
};
// Add our text
self.cells.add(
CellKey::Text,
CellText {
atlas: CellTextAtlas::Grayscale as u8,
grid_pos: [coord.x, coord.y as u16],
color: [screen_fg.r, screen_fg.g, screen_fg.b, 255],
glyph_pos: [render.glyph.atlas_x, render.glyph.atlas_y],
glyph_size: [render.glyph.width, render.glyph.height],
bearings: [render.glyph.offset_x as i16, render.glyph.offset_y as i16],
..CellText::default()
},
);
// Add underline
self.add_underline(coord.x, coord.y as u16, Underline::Single, screen_fg, 255)?;
if wide && coord.x < self.cells.size.columns - 1 {
self.add_underline(
coord.x + 1,
coord.y as u16,
Underline::Single,
screen_fg,
255,
)?;
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// draw_frame (GL thread)
impl Renderer {
/// Draw the frame to the screen.
///
/// If `sync` is true, this will synchronously block until
/// the frame is finished drawing and has been presented.
///
/// Must be called with the GL context current (for GTK: from the
/// `GtkGLArea` `render` signal, on the main thread).
pub fn draw_frame(&self, sync: bool) -> Result<(), Error> {
// We hold a the draw mutex to prevent changes to any
// data we access while we're in the middle of drawing.
let mut d = lock(&self.draw);
let result = self.draw_frame_locked(&mut d, sync);
// After the graphics API is complete (so we defer) we want to
// update our scrollbar state.
if d.scrollbar_dirty {
// Fail instantly if the surface mailbox if full, we'll just
// get it on the next frame.
if self.host.scrollbar(d.scrollbar) {
d.scrollbar_dirty = false;
}
}
// Let our graphics API do any bookkeeping, etc.
// that it needs to do before / after `draw_frame`.
d.api.draw_frame_end();
result
}
fn draw_frame_locked(&self, d: &mut DrawState, sync: bool) -> Result<(), Error> {
d.api.draw_frame_start();
// Retrieve the most up-to-date surface size from the Graphics API
let (surface_width, surface_height) = d.api.surface_size()?;
// If either of our surface dimensions is zero
// then drawing is absurd, so we just return.
if surface_width == 0 || surface_height == 0 {
return Ok(());
}
let size_changed =
d.size.screen.width != surface_width || d.size.screen.height != surface_height;
// Conditions under which we need to draw the frame, otherwise we
// don't need to since the previous frame should be identical.
let needs_redraw = size_changed || d.cells_rebuilt || self.has_animations() || sync;
if !needs_redraw {
// We still need to present the last target again, because the
// apprt may be swapping buffers and display an outdated frame
// if we don't draw something new.
d.api.present_last_target()?;
return Ok(());
}
d.cells_rebuilt = false;
// Get a frame state to draw to.
let (idx, mut frame) = d.swap_chain.next_frame()?;
let result = d.draw_with_frame(
&mut frame,
sync,
(surface_width, surface_height),
size_changed,
);
d.swap_chain.release_frame(idx, frame);
let health = result?;
self.has_animations
.store(d.has_custom_shaders, Ordering::Relaxed);
self.frame_completed(health);
Ok(())
}
}
impl DrawState {
fn draw_with_frame(
&mut self,
frame: &mut FrameState,
sync: bool,
surface_size: (u32, u32),
size_changed: bool,
) -> Result<Health, Error> {
let (surface_width, surface_height) = surface_size;
// If we need to reinitialize our shaders, do so.
if self.reinitialize_shaders {
self.reinitialize_shaders = false;
self.shaders = None;
self.init_shaders()?;
}
// Our shaders should not be defunct at this point.
if self.shaders.is_none() {
return Err(Error::Defunct);
}
// If we have custom shaders, make sure we have the
// custom shader state in our frame state, otherwise
// if we have a state but don't need it we remove it.
if self.has_custom_shaders {
if frame.custom_shader_state.is_none() {
let mut state = CustomShaderState::new(&self.api)?;
state.resize(&self.api, surface_width as usize, surface_height as usize)?;
frame.custom_shader_state = Some(state);
}
} else {
frame.custom_shader_state = None;
}
// If our stored size doesn't match the
// surface size we need to update it.
if size_changed {
self.size.screen = crate::size::ScreenSize {
width: surface_width,
height: surface_height,
};
self.update_screen_size_uniforms();
}
// If this frame's target isn't the correct size, or the target
// config has changed (such as when the blending mode changes),
// remove it and replace it with a new one with the right values.
if frame.target.width != self.size.screen.width as usize
|| frame.target.height != self.size.screen.height as usize
|| frame.target_config_modified != self.target_config_modified
{
frame.resize(
&self.api,
self.size.screen.width as usize,
self.size.screen.height as usize,
)?;
frame.target_config_modified = self.target_config_modified;
// The remembered last target (if any) is the old one.
self.api.clear_last_target();
}
let gl: Gl = self.api.gl().ok_or(opengl::Error::NoContext)?.clone();
let gpu = Gpu {
gl: &gl,
texture_options: self
.api
.image_texture_options(opengl::ImageTextureFormat::Rgba, true),
buffer_options: self.api.image_buffer_options(),
};
// Upload images to the GPU as necessary.
let _ = self.images.upload(&gpu);
// Upload the background image to the GPU as necessary.
self.upload_background_image(&gpu);
// Update per-frame custom shader uniforms.
self.update_custom_shader_uniforms_for_frame();
// Setup our frame data
frame.uniforms.sync(&[self.uniforms])?;
frame.cells_bg.sync(&self.cells.bg_cells)?;
let fg_lists: Vec<&[CellText]> = self.cells.fg_rows.iter().map(Vec::as_slice).collect();
let fg_count = frame.cells.sync_from_slices(&fg_lists)?;
drop(fg_lists);
// If our background image buffer has changed, sync it.
if frame.bg_image_buffer_modified != self.bg_image_buffer_modified {
frame.bg_image_buffer.sync(&[self.bg_image_buffer])?;
frame.bg_image_buffer_modified = self.bg_image_buffer_modified;
}
// If our font atlas changed, sync the texture data
{
let grid = Arc::clone(&self.font_grid);
let modified = grid.read().atlas_grayscale.modified.load(Ordering::Relaxed);
if modified > frame.grayscale_modified {
let guard = grid.read();
frame.grayscale_modified = guard.atlas_grayscale.modified.load(Ordering::Relaxed);
DrawState::sync_atlas_texture(
&self.api,
&guard.atlas_grayscale,
&mut frame.grayscale,
)?;
}
let modified = grid.read().atlas_color.modified.load(Ordering::Relaxed);
if modified > frame.color_modified {
let guard = grid.read();
frame.color_modified = guard.atlas_color.modified.load(Ordering::Relaxed);
DrawState::sync_atlas_texture(&self.api, &guard.atlas_color, &mut frame.color)?;
}
}
// Sync the custom shader uniforms now while we can still use `self`.
if let Some(state) = &mut frame.custom_shader_state {
state.uniforms.sync(&[self.custom_shader_uniforms])?;
}
// Get a frame context from the graphics API.
let DrawState {
api,
shaders,
images,
bg_image,
has_overlay,
..
} = self;
let Some(shaders) = shaders.as_ref() else {
return Err(Error::Defunct);
};
let frame_ctx = api.begin_frame(Arc::clone(&frame.target));
{
let attachments = [Attachment {
target: match &frame.custom_shader_state {
Some(state) => AttachmentTarget::Texture(&state.back_texture),
None => AttachmentTarget::Target(&frame.target),
},
clear_color: Some([0.0, 0.0, 0.0, 0.0]),
}];
let mut pass = frame_ctx.render_pass(&attachments);
// First we draw our background image, if we have one.
// The bg image shader also draws the main bg color.
//
// Otherwise, if we don't have a background image, we
// draw the background color by itself in its own step.
//
// NOTE: We don't use the clear_color for this because that
// would require us to do color space conversion on the
// CPU-side. In the future when we have utilities for
// that we should remove this step and use clear_color.
match bg_image {
Some(img) => {
if let image::Image::Ready(texture) = img {
pass.step(&Step {
pipeline: &shaders.bg_image,
uniforms: Some(frame.uniforms.raw()),
buffers: &[Some(frame.bg_image_buffer.raw())],
textures: &[Some(texture)],
samplers: &[],
draw: Draw::new(Primitive::Triangle, 3),
});
}
}
None => {
pass.step(&Step {
pipeline: &shaders.bg_color,
uniforms: Some(frame.uniforms.raw()),
buffers: &[None, Some(frame.cells_bg.raw())],
textures: &[],
samplers: &[],
draw: Draw::new(Primitive::Triangle, 3),
});
}
}
// Then we draw any kitty images that need
// to be behind text AND cell backgrounds.
images.draw(
&gpu,
&shaders.image,
&mut pass,
image::DrawPlacements::KittyBelowBg,
);
// Then we draw any opaque cell backgrounds.
pass.step(&Step {
pipeline: &shaders.cell_bg,
uniforms: Some(frame.uniforms.raw()),
buffers: &[None, Some(frame.cells_bg.raw())],
textures: &[],
samplers: &[],
draw: Draw::new(Primitive::Triangle, 3),
});
// Kitty images between cell backgrounds and text.
images.draw(
&gpu,
&shaders.image,
&mut pass,
image::DrawPlacements::KittyBelowText,
);
// Text.
pass.step(&Step {
pipeline: &shaders.cell_text,
uniforms: Some(frame.uniforms.raw()),
buffers: &[Some(frame.cells.raw()), Some(frame.cells_bg.raw())],
textures: &[Some(&frame.grayscale), Some(&frame.color)],
samplers: &[],
draw: Draw {
ty: Primitive::TriangleStrip,
vertex_count: 4,
instance_count: fg_count,
},
});
// Kitty images in front of text.
images.draw(
&gpu,
&shaders.image,
&mut pass,
image::DrawPlacements::KittyAboveText,
);
// Debug overlay. We do this before any custom shader state
// because our debug overlay is aligned with the grid.
if *has_overlay {
images.draw(
&gpu,
&shaders.image,
&mut pass,
image::DrawPlacements::Overlay,
);
}
pass.complete();
}
// If we have custom shaders, then we render them.
if let Some(state) = &mut frame.custom_shader_state {
let post_count = shaders.post_pipelines.len();
for (i, pipeline) in shaders.post_pipelines.iter().enumerate() {
{
let attachments = [Attachment {
target: if i < post_count - 1 {
AttachmentTarget::Texture(&state.front_texture)
} else {
AttachmentTarget::Target(&frame.target)
},
clear_color: Some([0.0, 0.0, 0.0, 0.0]),
}];
let mut pass = frame_ctx.render_pass(&attachments);
pass.step(&Step {
pipeline,
uniforms: Some(state.uniforms.raw()),
buffers: &[],
textures: &[Some(&state.back_texture)],
samplers: &[Some(&state.sampler)],
draw: Draw::new(Primitive::Triangle, 3),
});
pass.complete();
}
state.swap();
}
}
// Complete the frame: glFinish, error check, present.
Ok(frame_ctx.complete(sync))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ghostty_config::types_a::Color as ConfigColor;
fn rgb(r: u8, g: u8, b: u8) -> Rgb {
Rgb { r, g, b }
}
#[test]
fn derived_config_from_default_config() {
let config = Config::default();
let d = DerivedConfig::new(&config).unwrap();
// The default config installs the URL link.
assert!(!d.links.links.is_empty());
assert_eq!(d.background, config.background.to_terminal_rgb());
assert_eq!(d.foreground, config.foreground.to_terminal_rgb());
assert!((d.background_opacity - 1.0).abs() < f64::EPSILON);
// faint-opacity 0.5 -> ceil(127.5) = 128
assert_eq!(d.faint_opacity, 128);
assert_eq!(d.font_styles, [true; 4]);
assert_eq!(d.custom_shaders.len(), 0);
assert!(d.bg_image.is_none());
assert!(!d.scroll_to_bottom_on_output);
}
#[test]
fn derived_config_clamps_opacity() {
let config = Config {
background_opacity: 3.0,
cursor_opacity: -1.0,
..Config::default()
};
let d = DerivedConfig::new(&config).unwrap();
assert!((d.background_opacity - 1.0).abs() < f64::EPSILON);
assert!(d.cursor_opacity.abs() < f64::EPSILON);
}
#[test]
fn terminal_color_resolution() {
let fg = Some(rgb(1, 1, 1));
let bg = Some(rgb(2, 2, 2));
let explicit = TerminalColor::Color(ConfigColor { r: 9, g: 9, b: 9 });
assert_eq!(
resolve_terminal_color(explicit, false, fg, bg),
Some(rgb(9, 9, 9))
);
assert_eq!(
resolve_terminal_color(TerminalColor::CellForeground, false, fg, bg),
fg
);
assert_eq!(
resolve_terminal_color(TerminalColor::CellForeground, true, fg, bg),
bg
);
assert_eq!(
resolve_terminal_color(TerminalColor::CellBackground, false, fg, bg),
bg
);
assert_eq!(
resolve_terminal_color(TerminalColor::CellBackground, true, fg, bg),
fg
);
}
#[test]
fn bold_color_policy() {
assert!(bold_policy(None).is_none());
assert!(matches!(
bold_policy(Some(BoldColor::Bright)),
Some(style::BoldColor::Bright)
));
let c = ConfigColor { r: 1, g: 2, b: 3 };
assert!(matches!(
bold_policy(Some(BoldColor::Color(c))),
Some(style::BoldColor::Color(Rgb { r: 1, g: 2, b: 3 }))
));
}
#[test]
fn file_type_detection() {
assert_eq!(
detect_file_type(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0]),
FileType::Png
);
assert_eq!(detect_file_type(&[0xFF, 0xD8, 0xFF, 0xE0]), FileType::Jpeg);
assert_eq!(detect_file_type(b"GIF89a.."), FileType::Gif);
assert_eq!(detect_file_type(b"hello"), FileType::Unknown);
assert_eq!(guess_file_type_from_extension("/a/b.JPG"), FileType::Jpeg);
assert_eq!(guess_file_type_from_extension("/a/b.png"), FileType::Png);
assert_eq!(guess_file_type_from_extension("/a/b"), FileType::Unknown);
}
#[test]
fn background_image_png_roundtrip() {
// Encode a 2x1 RGBA png, write it to disk and load it back.
let mut bytes = Vec::new();
{
let mut enc = png::Encoder::new(&mut bytes, 2, 1);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[1, 2, 3, 255, 4, 5, 6, 128]).unwrap();
}
let dir = std::env::temp_dir().join(format!("ghostty-bg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("bg.png");
std::fs::write(&path, &bytes).unwrap();
let img = load_background_image(path.to_str().unwrap()).expect("decoded");
match img {
image::Image::Pending(p) => {
assert_eq!((p.width, p.height), (2, 1));
assert_eq!(p.data, [1, 2, 3, 255, 4, 5, 6, 128]);
}
_ => panic!("expected a pending image"),
}
// Missing / undecodable files are not an error, just no image.
assert!(load_background_image(dir.join("missing.png").to_str().unwrap()).is_none());
let bad = dir.join("bad.txt");
std::fs::write(&bad, b"not an image").unwrap();
assert!(load_background_image(bad.to_str().unwrap()).is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1162
View File
@@ -0,0 +1,1162 @@
//! Generic image rendering state for the renderer (`renderer/image.zig`).
//!
//! This stores all images and their placements and exposes only a limited
//! public API for adding images and placements and drawing them. Image data
//! is converted to RGBA on the CPU (`prep_for_upload`, safe to do without a
//! GL context) and uploaded to the GPU on the thread that owns the context
//! (`State::upload`, called from `draw_frame`).
use std::collections::HashMap;
use std::time::Instant;
use ghostty_terminal::kitty::graphics_command::TransmissionFormat;
use ghostty_terminal::kitty::graphics_storage::{ImageStorage, TerminalInfo};
use ghostty_terminal::kitty::{Image as KittyImage, unicode};
use ghostty_terminal::page_list::PageList;
use ghostty_terminal::point::Tag;
use ghostty_terminal::terminal::Terminal;
use crate::opengl::gl::{Gl, Primitive};
use crate::opengl::pipeline::Pipeline;
use crate::opengl::render_pass::{Draw, RenderPass, Step};
use crate::opengl::shaders::Image as ImageVertex;
use crate::opengl::{Buffer, Texture, buffer, texture};
use crate::overlay::Overlay;
use crate::size::CellSize;
/// The GPU objects needed to upload and draw images. Built per frame from
/// the graphics API (the API itself is mutably borrowed by the frame while
/// drawing).
pub struct Gpu<'a> {
pub gl: &'a Gl,
/// `api.imageTextureOptions(.rgba, true)`.
pub texture_options: texture::Options,
/// `api.imageBufferOptions()`.
pub buffer_options: buffer::Options,
}
/// Generic image rendering state for the renderer. This stores all
/// images and their placements and exposes only a limited public API
/// for adding images and placements and drawing them.
#[derive(Default)]
pub struct State {
/// The full image state for the renderer that specifies what images
/// need to be uploaded, pruned, etc.
pub images: HashMap<Id, Entry>,
/// The placements for the Kitty image protocol.
pub kitty_placements: Vec<Placement>,
/// The end index (exclusive) for placements that should be
/// drawn below the background, below the text, etc.
pub kitty_bg_end: u32,
pub kitty_text_end: u32,
/// True if there are any virtual placements. This needs to be known
/// because virtual placements need to be recalculated more often
/// on frame builds and are generally more expensive to handle.
pub kitty_virtual: bool,
/// Overlays
pub overlay_placements: Vec<Placement>,
/// Forces the next [`State::kitty_requires_update`] to return true. Set
/// when the GPU context was recreated and the textures must be rebuilt.
pub force_kitty_update: bool,
}
/// An image in the map plus the time it was transmitted.
pub struct Entry {
pub image: Image,
pub transmit_time: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawPlacements {
KittyBelowBg,
KittyBelowText,
KittyAboveText,
Overlay,
}
#[derive(Debug, thiserror::Error)]
pub enum PrepImageError {
#[error("image conversion failed")]
ImageConversionError,
}
impl State {
pub fn new() -> State {
State::default()
}
/// Upload any images to the GPU that need to be uploaded,
/// and remove any images that are no longer needed on the GPU.
///
/// If any uploads fail, they are ignored. The return value
/// can be used to detect if upload was a total success (true)
/// or not (false).
pub fn upload(&mut self, gpu: &Gpu<'_>) -> bool {
let mut success = true;
self.images.retain(|_, entry| {
let img = &mut entry.image;
if img.is_unloading() {
return false;
}
if img.is_pending()
&& let Err(err) = img.upload(gpu)
{
log::warn!("error uploading image to GPU err={err}");
success = false;
}
true
});
success
}
/// Draw the given named set of placements.
///
/// Any placements that have non-uploaded images are ignored. Any
/// graphics API errors during drawing are also ignored.
pub fn draw(
&self,
gpu: &Gpu<'_>,
pipeline: &Pipeline,
pass: &mut RenderPass<'_>,
placement_type: DrawPlacements,
) {
let bg_end = self.kitty_bg_end as usize;
let text_end = self.kitty_text_end as usize;
let placements: &[Placement] = match placement_type {
DrawPlacements::KittyBelowBg => &self.kitty_placements[..bg_end],
DrawPlacements::KittyBelowText => &self.kitty_placements[bg_end..text_end],
DrawPlacements::KittyAboveText => &self.kitty_placements[text_end..],
DrawPlacements::Overlay => &self.overlay_placements,
};
for p in placements {
// Look up the image
let Some(entry) = self.images.get(&p.image_id) else {
log::warn!("image not found for placement image_id={:?}", p.image_id);
continue;
};
// Get the texture
let Some(texture) = entry.image.ready_texture() else {
log::warn!("image not ready for placement image_id={:?}", p.image_id);
continue;
};
// Create our vertex buffer, which is always exactly one item.
// future(mitchellh): we can group rendering multiple instances of a single image
let buf = match Buffer::<ImageVertex>::init_fill(
gpu.gl,
gpu.buffer_options,
&[ImageVertex {
grid_pos: [p.x as f32, p.y as f32],
cell_offset: [p.cell_offset_x as f32, p.cell_offset_y as f32],
source_rect: [
p.source_x as f32,
p.source_y as f32,
p.source_width as f32,
p.source_height as f32,
],
dest_size: [p.width as f32, p.height as f32],
pad: [0.0; 2],
}],
) {
Ok(b) => b,
Err(err) => {
log::warn!("error creating image vertex buffer err={err}");
continue;
}
};
pass.step(&Step {
pipeline,
uniforms: None,
buffers: &[Some(buf.raw())],
textures: &[Some(texture)],
samplers: &[],
draw: Draw::new(Primitive::TriangleStrip, 4),
});
}
}
/// Update our overlay state. None deletes any existing overlay.
pub fn overlay_update(&mut self, overlay: Option<&Overlay>) -> Result<(), PrepImageError> {
let Some(overlay) = overlay else {
// If we don't have an overlay, remove any existing one.
if let Some(data) = self.images.get_mut(&Id::Overlay) {
data.image.mark_for_unload();
}
return Ok(());
};
// For transmit time we always just use the current time
// and overwrite the overlay.
let transmit_time = Instant::now();
// Setup our image.
let pending = overlay.pending_image();
let (width, height) = (pending.width, pending.height);
self.overlay_placements.clear();
self.prep_image(Id::Overlay, transmit_time, pending)?;
// Setup our placement
self.overlay_placements.push(Placement {
image_id: Id::Overlay,
x: 0,
y: 0,
z: 0,
width,
height,
cell_offset_x: 0,
cell_offset_y: 0,
source_x: 0,
source_y: 0,
source_width: width,
source_height: height,
});
Ok(())
}
/// Returns true if the Kitty graphics state requires an update based
/// on the terminal state and our internal state.
///
/// This does not read/write state used by drawing.
pub fn kitty_requires_update(&self, t: &Terminal) -> bool {
// If the terminal kitty image state is dirty, we must update.
if self.force_kitty_update || t.screen().kitty_images.dirty {
return true;
}
// If we have any virtual references, we must also rebuild our
// kitty state on every frame because any cell change can move
// an image. If the virtual placements were removed, this will
// be set to false on the next update.
self.kitty_virtual
}
/// Update the Kitty graphics state from the terminal's image storage.
///
/// This reads/writes state used by drawing. The caller is responsible
/// for clearing `storage.dirty` afterwards (Zig: `defer storage.dirty =
/// false`).
///
/// Rust deviation: Zig takes the whole `*Terminal`; the Rust kitty
/// storage lives beside the page list it tracks pins in, so the pieces
/// are passed explicitly (`info` = `TerminalInfo::from_terminal(t)`).
pub fn kitty_update(
&mut self,
storage: &ImageStorage,
pages: &PageList,
info: &TerminalInfo,
cell_size: CellSize,
) {
// We always clear our previous placements no matter what because
// we rebuild them from scratch.
self.kitty_placements.clear();
self.kitty_virtual = false;
self.force_kitty_update = false;
// Go through our known images and if there are any that are no longer
// in use then mark them to be freed.
//
// This never conflicts with the below because a placement can't
// reference an image that doesn't exist.
for (id, entry) in &mut self.images {
match id {
// We're only looking at Kitty images
Id::Kitty(id) => {
if storage.image_by_id(*id).is_none() {
entry.image.mark_for_unload();
}
}
Id::Overlay => {}
}
}
// The top-left and bottom-right corners of our viewport in screen
// points. This lets us determine offsets and containment of placements.
let (Some(top), Some(bot)) = (
pages.get_top_left(Tag::Viewport),
pages.get_bottom_right(Tag::Viewport),
) else {
return;
};
let (Some(top_pt), Some(bot_pt)) = (
pages.point_from_pin(Tag::Screen, top),
pages.point_from_pin(Tag::Screen, bot),
) else {
return;
};
let top_y = top_pt.coord().y;
let bot_y = bot_pt.coord().y;
// Go through the placements and ensure the image is
// on the GPU or else is ready to be sent to the GPU.
for (key, p) in &storage.placements {
// Special logic based on location
match p.location {
ghostty_terminal::kitty::graphics_storage::Location::Pin(_) => {}
ghostty_terminal::kitty::graphics_storage::Location::Virtual => {
// We need to mark virtual placements on our renderer so that
// we know to rebuild in more scenarios since cell changes can
// now trigger placement changes.
self.kitty_virtual = true;
// We also continue out because virtual placements are
// only triggered by the unicode placeholder, not by the
// placement itself.
continue;
}
}
// Get the image for the placement
let Some(image) = storage.image_by_id(key.image_id) else {
log::warn!(
"missing image for placement, ignoring image_id={}",
key.image_id
);
continue;
};
if let Err(err) = self.prep_kitty_placement(pages, info, top_y, bot_y, image, p) {
// For errors we log and continue. We try to place
// other placements even if one fails.
log::warn!("error preparing kitty placement err={err}");
}
}
// If we have virtual placements then we need to scan for placeholders.
if self.kitty_virtual {
for virtual_p in unicode::placement_iterator(pages, top, Some(bot)) {
if let Err(err) =
self.prep_kitty_virtual_placement(storage, pages, &virtual_p, cell_size)
{
// For errors we log and continue. We try to place
// other placements even if one fails.
log::warn!("error preparing kitty placement err={err}");
}
}
}
// Sort the placements by their Z value.
self.kitty_placements.sort_unstable_by(|lhs, rhs| {
lhs.z.cmp(&rhs.z).then_with(|| {
if lhs.image_id.z_less_than(rhs.image_id) {
std::cmp::Ordering::Less
} else if rhs.image_id.z_less_than(lhs.image_id) {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Equal
}
})
});
// Find our indices. The values are sorted by z so we can
// find the first placement out of bounds to find the limits.
let bg_limit = i32::MIN / 2;
let mut bg_end: Option<u32> = None;
let mut text_end: Option<u32> = None;
for (i, p) in self.kitty_placements.iter().enumerate() {
if bg_end.is_none() && p.z >= bg_limit {
bg_end = Some(i as u32);
}
if text_end.is_none() && p.z >= 0 {
text_end = Some(i as u32);
}
}
// If we didn't see any images with a z > the bg limit,
// then our bg end is the end of our placement list.
self.kitty_bg_end = bg_end.unwrap_or(self.kitty_placements.len() as u32);
// Same idea for the image_text_end.
self.kitty_text_end = text_end.unwrap_or(self.kitty_placements.len() as u32);
}
/// Get the viewport-relative position for this
/// placement and add it to the placements list.
fn prep_kitty_placement(
&mut self,
pages: &PageList,
info: &TerminalInfo,
top_y: u32,
bot_y: u32,
image: &KittyImage,
p: &ghostty_terminal::kitty::graphics_storage::Placement,
) -> Result<(), PrepImageError> {
// Get the rect for the placement. If this placement doesn't have
// a rect then its virtual or something so skip it.
let Some(rect) = p.rect(image, info, pages) else {
return Ok(());
};
// This is expensive but necessary.
let (Some(top_pt), Some(bot_pt)) = (
pages.point_from_pin(Tag::Screen, rect.top_left),
pages.point_from_pin(Tag::Screen, rect.bottom_right),
) else {
return Ok(());
};
let img_top_y = top_pt.coord().y;
let img_bot_y = bot_pt.coord().y;
// If the selection isn't within our viewport then skip it.
if img_top_y > bot_y {
return Ok(());
}
if img_bot_y < top_y {
return Ok(());
}
// We need to prep this image for upload if it isn't in the
// cache OR it is in the cache but the transmit time doesn't
// match meaning this image is different.
self.prep_kitty_image(image)?;
// Calculate the dimensions of our image, taking in to
// account the rows / columns specified by the placement.
let dest_size = p.calculated_size(image, info);
// Calculate the source rectangle
let source_x = image.width.min(p.source_x);
let source_y = image.height.min(p.source_y);
let source_width = if p.source_width > 0 {
(image.width - source_x).min(p.source_width)
} else {
image.width
};
let source_height = if p.source_height > 0 {
(image.height - source_y).min(p.source_height)
} else {
image.height
};
// Get the viewport-relative Y position of the placement.
let y_pos = img_top_y as i32 - top_y as i32;
// Accumulate the placement
if dest_size.width > 0 && dest_size.height > 0 {
self.kitty_placements.push(Placement {
image_id: Id::Kitty(image.id),
x: i32::from(rect.top_left.x),
y: y_pos,
z: p.z,
width: dest_size.width,
height: dest_size.height,
cell_offset_x: p.x_offset,
cell_offset_y: p.y_offset,
source_x,
source_y,
source_width,
source_height,
});
}
Ok(())
}
fn prep_kitty_virtual_placement(
&mut self,
storage: &ImageStorage,
pages: &PageList,
p: &unicode::Placement,
cell_size: CellSize,
) -> Result<(), PrepImageError> {
let Some(image) = storage.image_by_id(p.image_id) else {
log::warn!(
"missing image for virtual placement, ignoring image_id={}",
p.image_id
);
return Ok(());
};
let rp = match p.render_placement(storage, image, cell_size.width, cell_size.height) {
Ok(rp) => rp,
Err(err) => {
log::warn!("error rendering virtual placement err={err}");
return Ok(());
}
};
// If our placement is zero sized then we don't do anything.
if rp.dest_width == 0 || rp.dest_height == 0 {
return Ok(());
}
// This should be impossible with virtual placements because we should
// only ever be looking at virtual placements that are in our
// viewport in the renderer and virtual placements only ever take
// up one row.
let Some(viewport) = pages.point_from_pin(Tag::Viewport, rp.top_left) else {
return Ok(());
};
// Prepare the image for the GPU and store the placement.
self.prep_kitty_image(image)?;
self.kitty_placements.push(Placement {
image_id: Id::Kitty(image.id),
x: i32::from(rp.top_left.x),
y: viewport.coord().y as i32,
z: -1,
width: rp.dest_width,
height: rp.dest_height,
cell_offset_x: rp.offset_x,
cell_offset_y: rp.offset_y,
source_x: rp.source_x,
source_y: rp.source_y,
source_width: rp.source_width,
source_height: rp.source_height,
});
Ok(())
}
/// Prepare an image for upload to the GPU.
pub fn prep_image(
&mut self,
id: Id,
transmit_time: Instant,
pending: Pending,
) -> Result<(), PrepImageError> {
// If this image exists and its transmit time is the same we assume
// it is the identical image so we don't need to send it to the GPU.
if let Some(existing) = self.images.get(&id)
&& existing.transmit_time == transmit_time
{
return Ok(());
}
// Store it in the map. We own the data (the caller handed us an
// owned copy).
match self.images.get_mut(&id) {
None => {
self.images.insert(
id,
Entry {
image: Image::Pending(pending),
transmit_time,
},
);
}
Some(entry) => entry.image.mark_for_replace(Image::Pending(pending)),
}
let Some(entry) = self.images.get_mut(&id) else {
return Ok(());
};
// If any error happens, we unload the image and it is invalid.
if let Err(err) = entry.image.prep_for_upload() {
log::warn!("error preparing image for upload err={err}");
entry.image.mark_for_unload();
return Err(PrepImageError::ImageConversionError);
}
entry.transmit_time = transmit_time;
Ok(())
}
/// Prepare the provided Kitty image for upload to the GPU by copying its
/// data and setting it to the pending state.
fn prep_kitty_image(&mut self, image: &KittyImage) -> Result<(), PrepImageError> {
let pixel_format = match image.format {
TransmissionFormat::Gray => PixelFormat::Gray,
TransmissionFormat::GrayAlpha => PixelFormat::GrayAlpha,
TransmissionFormat::Rgb => PixelFormat::Rgb,
TransmissionFormat::Rgba => PixelFormat::Rgba,
// should be decoded by now
TransmissionFormat::Png => {
log::warn!("png kitty image was not decoded id={}", image.id);
return Err(PrepImageError::ImageConversionError);
}
};
self.prep_image(
Id::Kitty(image.id),
image.transmit_time,
Pending {
width: image.width,
height: image.height,
pixel_format,
data: image.data.clone(),
},
)
}
}
/// Represents a single image placement on the grid.
/// A placement is a request to render an instance of an image.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Placement {
/// The image being rendered. This MUST be in the image map.
pub image_id: Id,
/// The grid x/y where this placement is located.
pub x: i32,
pub y: i32,
pub z: i32,
/// The width/height of the placed image.
pub width: u32,
pub height: u32,
/// The offset in pixels from the top left of the cell.
/// This is clamped to the size of a cell.
pub cell_offset_x: u32,
pub cell_offset_y: u32,
/// The source rectangle of the placement.
pub source_x: u32,
pub source_y: u32,
pub source_width: u32,
pub source_height: u32,
}
/// Image identifier used to store and lookup images.
///
/// This is tagged by different image types to make it easier to
/// store different kinds of images in the same map without having
/// to worry about ID collisions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Id {
/// Image sent to the terminal state via the kitty graphics protocol.
/// The value is the ID assigned by the terminal.
Kitty(u32),
/// Debug overlay. This is always composited down to a single
/// image for now. In the future we can support layers here if we want.
Overlay,
}
impl Id {
/// Z-ordering tie-breaker for images with the same z value.
pub fn z_less_than(self, rhs: Id) -> bool {
match (self, rhs) {
// Kitty images always sort before (lower z) non-kitty images.
(Id::Kitty(_), Id::Overlay) => true,
(Id::Overlay, Id::Kitty(_)) => false,
(Id::Kitty(l), Id::Kitty(r)) => l < r,
// No sensical ordering
(Id::Overlay, Id::Overlay) => false,
}
}
}
/// Pixel formats of pending image data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PixelFormat {
/// 1 byte per pixel grayscale.
Gray,
/// 2 bytes per pixel grayscale + alpha.
GrayAlpha,
/// 3 bytes per pixel RGB.
Rgb,
/// 3 byte per pixel BGR.
Bgr,
/// 4 byte per pixel RGBA.
Rgba,
/// 4 byte per pixel BGRA.
Bgra,
}
impl PixelFormat {
/// Get bytes per pixel for this format.
#[inline]
pub fn bpp(self) -> usize {
match self {
PixelFormat::Gray => 1,
PixelFormat::GrayAlpha => 2,
PixelFormat::Rgb | PixelFormat::Bgr => 3,
PixelFormat::Rgba | PixelFormat::Bgra => 4,
}
}
}
/// Pending image data that needs to be uploaded to the GPU.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pending {
pub height: u32,
pub width: u32,
pub pixel_format: PixelFormat,
/// Data is always expected to be (width * height * bpp).
pub data: Vec<u8>,
}
impl Pending {
/// The expected number of bytes of image data.
pub fn len(&self) -> usize {
self.width as usize * self.height as usize * self.pixel_format.bpp()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Convert to RGBA in place (the wuffs swizzle step). No-op for RGBA.
#[allow(clippy::chunks_exact_to_as_chunks)]
fn convert(&mut self) -> Result<(), ConvertError> {
// As things stand, we currently convert all images to RGBA before
// uploading to the GPU. This just makes things easier. In the future
// we may want to support other formats.
if self.pixel_format == PixelFormat::Rgba {
return Ok(());
}
let n = self.width as usize * self.height as usize;
let bpp = self.pixel_format.bpp();
if self.data.len() < n * bpp {
return Err(ConvertError::ShortData);
}
let src = &self.data[..n * bpp];
let mut out = Vec::with_capacity(n * 4);
match self.pixel_format {
PixelFormat::Gray => {
for &g in src {
out.extend_from_slice(&[g, g, g, 255]);
}
}
PixelFormat::GrayAlpha => {
for px in src.chunks_exact(2) {
out.extend_from_slice(&[px[0], px[0], px[0], px[1]]);
}
}
PixelFormat::Rgb => {
for px in src.chunks_exact(3) {
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
}
PixelFormat::Bgr => {
for px in src.chunks_exact(3) {
out.extend_from_slice(&[px[2], px[1], px[0], 255]);
}
}
PixelFormat::Bgra => {
for px in src.chunks_exact(4) {
out.extend_from_slice(&[px[2], px[1], px[0], px[3]]);
}
}
PixelFormat::Rgba => unreachable!(),
}
self.data = out;
self.pixel_format = PixelFormat::Rgba;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConvertError {
#[error("image data is shorter than width * height * bpp")]
ShortData,
}
#[derive(Debug, thiserror::Error)]
pub enum UploadError {
#[error(transparent)]
Convert(#[from] ConvertError),
/// Texture creation failed, usually a GPU memory issue.
#[error("texture upload failed")]
UploadFailed,
}
/// The state for a single image that is to be rendered.
pub enum Image {
/// The image data is pending upload to the GPU.
Pending(Pending),
/// This is the same as the pending states but there is
/// a texture already allocated that we want to replace.
Replace { texture: Texture, pending: Pending },
/// The image is uploaded and ready to be used.
Ready(Texture),
/// The image isn't uploaded yet but is scheduled to be unloaded.
UnloadPending(Pending),
/// The image is uploaded and is scheduled to be unloaded.
UnloadReady(Texture),
/// The image is uploaded and scheduled to be replaced
/// with new data, but it's also scheduled to be unloaded.
UnloadReplace { texture: Texture, pending: Pending },
}
impl Image {
/// Mark this image for unload whatever state it is in.
pub fn mark_for_unload(&mut self) {
let old = std::mem::replace(self, Image::Pending(Pending::empty()));
*self = match old {
v @ (Image::UnloadPending(_) | Image::UnloadReplace { .. } | Image::UnloadReady(_)) => {
v
}
Image::Ready(t) => Image::UnloadReady(t),
Image::Pending(p) => Image::UnloadPending(p),
Image::Replace { texture, pending } => Image::UnloadReplace { texture, pending },
};
}
/// Mark the current image to be replaced with a pending one. This will
/// attempt to update the existing texture if we have one, otherwise it
/// will act like a new upload.
pub fn mark_for_replace(&mut self, img: Image) {
let Image::Pending(new_pending) = img else {
debug_assert!(false, "mark_for_replace requires a pending image");
return;
};
// Take the current state apart; any pending data we have is dropped
// (freed), and an existing texture is reused in the replace.
let old = std::mem::replace(self, Image::Pending(Pending::empty()));
*self = match old {
Image::Ready(t) | Image::UnloadReady(t) => Image::Replace {
texture: t,
pending: new_pending,
},
Image::Replace { texture, .. } | Image::UnloadReplace { texture, .. } => {
Image::Replace {
texture,
pending: new_pending,
}
}
Image::Pending(_) | Image::UnloadPending(_) => Image::Pending(new_pending),
};
}
/// Returns true if this image is pending upload.
pub fn is_pending(&self) -> bool {
self.pending().is_some()
}
/// Returns true if this image has an associated texture.
pub fn has_texture(&self) -> bool {
self.texture().is_some()
}
/// Returns true if this image is marked for unload.
pub fn is_unloading(&self) -> bool {
matches!(
self,
Image::UnloadPending(_) | Image::UnloadReplace { .. } | Image::UnloadReady(_)
)
}
/// The texture if the image is ready or unload_ready (usable for drawing).
pub fn ready_texture(&self) -> Option<&Texture> {
match self {
Image::Ready(t) | Image::UnloadReady(t) => Some(t),
_ => None,
}
}
fn pending(&self) -> Option<&Pending> {
match self {
Image::Pending(p) | Image::UnloadPending(p) => Some(p),
Image::Replace { pending, .. } | Image::UnloadReplace { pending, .. } => Some(pending),
_ => None,
}
}
fn pending_mut(&mut self) -> Option<&mut Pending> {
match self {
Image::Pending(p) | Image::UnloadPending(p) => Some(p),
Image::Replace { pending, .. } | Image::UnloadReplace { pending, .. } => Some(pending),
_ => None,
}
}
fn texture(&self) -> Option<&Texture> {
match self {
Image::Ready(t) | Image::UnloadReady(t) => Some(t),
Image::Replace { texture, .. } | Image::UnloadReplace { texture, .. } => Some(texture),
_ => None,
}
}
/// Prepare the pending image data for upload to the GPU.
/// This doesn't need GPU access so is safe to call any time.
pub fn prep_for_upload(&mut self) -> Result<(), ConvertError> {
if let Some(p) = self.pending_mut() {
p.convert()
} else {
debug_assert!(false, "prep_for_upload requires pending data");
Ok(())
}
}
/// Upload the pending image to the GPU and change the state of this
/// image to ready.
pub fn upload(&mut self, gpu: &Gpu<'_>) -> Result<(), UploadError> {
// No error recovery is required after this call because it just
// converts in place and is idempotent.
self.prep_for_upload()?;
// Get our pending info
let Some(p) = self.pending() else {
return Ok(());
};
// Create our texture
let texture = Texture::init(
gpu.gl,
gpu.texture_options,
p.width as usize,
p.height as usize,
Some(&p.data),
)
.map_err(|_| UploadError::UploadFailed)?;
// Uploaded. We can now clear our data and change our state.
//
// NOTE: For the `replace` state, this frees the old texture. We
// don't currently actually replace the existing texture
// in-place but that is an optimization we can do later.
*self = Image::Ready(texture);
Ok(())
}
}
impl Pending {
fn empty() -> Pending {
Pending {
height: 0,
width: 0,
pixel_format: PixelFormat::Rgba,
data: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pending(fmt: PixelFormat, data: Vec<u8>, w: u32, h: u32) -> Pending {
Pending {
width: w,
height: h,
pixel_format: fmt,
data,
}
}
#[test]
fn pixel_format_bpp() {
assert_eq!(PixelFormat::Gray.bpp(), 1);
assert_eq!(PixelFormat::GrayAlpha.bpp(), 2);
assert_eq!(PixelFormat::Rgb.bpp(), 3);
assert_eq!(PixelFormat::Bgr.bpp(), 3);
assert_eq!(PixelFormat::Rgba.bpp(), 4);
assert_eq!(PixelFormat::Bgra.bpp(), 4);
}
#[test]
fn convert_to_rgba() {
let mut p = pending(PixelFormat::Gray, vec![7], 1, 1);
p.convert().unwrap();
assert_eq!(p.data, [7, 7, 7, 255]);
assert_eq!(p.pixel_format, PixelFormat::Rgba);
let mut p = pending(PixelFormat::GrayAlpha, vec![7, 9], 1, 1);
p.convert().unwrap();
assert_eq!(p.data, [7, 7, 7, 9]);
let mut p = pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1);
p.convert().unwrap();
assert_eq!(p.data, [1, 2, 3, 255]);
let mut p = pending(PixelFormat::Bgr, vec![1, 2, 3], 1, 1);
p.convert().unwrap();
assert_eq!(p.data, [3, 2, 1, 255]);
let mut p = pending(PixelFormat::Bgra, vec![1, 2, 3, 4], 1, 1);
p.convert().unwrap();
assert_eq!(p.data, [3, 2, 1, 4]);
let mut p = pending(PixelFormat::Rgba, vec![1, 2, 3, 4], 1, 1);
p.convert().unwrap();
assert_eq!(p.data, [1, 2, 3, 4]);
// Short data is an error rather than a panic.
let mut p = pending(PixelFormat::Rgb, vec![1, 2], 1, 1);
assert!(p.convert().is_err());
}
#[test]
fn id_z_ordering() {
assert!(Id::Kitty(5).z_less_than(Id::Overlay));
assert!(!Id::Overlay.z_less_than(Id::Kitty(5)));
assert!(Id::Kitty(1).z_less_than(Id::Kitty(2)));
assert!(!Id::Kitty(2).z_less_than(Id::Kitty(1)));
assert!(!Id::Overlay.z_less_than(Id::Overlay));
}
#[test]
fn state_transitions_without_gpu() {
let mut img = Image::Pending(pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1));
assert!(img.is_pending());
assert!(!img.is_unloading());
assert!(!img.has_texture());
img.prep_for_upload().unwrap();
assert_eq!(img.pending().unwrap().data, [1, 2, 3, 255]);
// Replace a pending image with another pending one.
img.mark_for_replace(Image::Pending(pending(
PixelFormat::Rgba,
vec![9, 9, 9, 9],
1,
1,
)));
assert!(matches!(img, Image::Pending(_)));
assert_eq!(img.pending().unwrap().data, [9, 9, 9, 9]);
img.mark_for_unload();
assert!(img.is_unloading());
assert!(matches!(img, Image::UnloadPending(_)));
// Idempotent.
img.mark_for_unload();
assert!(matches!(img, Image::UnloadPending(_)));
}
#[test]
fn prep_image_same_transmit_time_is_noop() {
let mut s = State::new();
let t = Instant::now();
s.prep_image(
Id::Kitty(1),
t,
pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1),
)
.unwrap();
// Same transmit time: data is not replaced.
s.prep_image(
Id::Kitty(1),
t,
pending(PixelFormat::Rgb, vec![9, 9, 9], 1, 1),
)
.unwrap();
assert_eq!(
s.images[&Id::Kitty(1)].image.pending().unwrap().data,
[1, 2, 3, 255]
);
// A different transmit time replaces it.
s.prep_image(
Id::Kitty(1),
Instant::now() + std::time::Duration::from_millis(1),
pending(PixelFormat::Rgb, vec![9, 9, 9], 1, 1),
)
.unwrap();
assert_eq!(
s.images[&Id::Kitty(1)].image.pending().unwrap().data,
[9, 9, 9, 255]
);
}
fn kitty_img(id: u32, w: u32, h: u32) -> KittyImage {
KittyImage {
id,
width: w,
height: h,
format: TransmissionFormat::Rgba,
data: vec![255; (w * h * 4) as usize],
..KittyImage::default()
}
}
#[test]
fn kitty_update_builds_sorted_viewport_placements() {
use ghostty_terminal::kitty::graphics_storage::{Location, Placement as KPlacement};
use ghostty_terminal::point::{Coordinate, Point};
use ghostty_terminal::terminal::Options;
let mut t = Terminal::new(Options::new(10, 10)).unwrap();
t.width_px = 100;
t.height_px = 200;
let mut storage = ImageStorage::default();
storage
.add_image(&mut t.screen_mut().pages, kitty_img(1, 20, 20))
.unwrap();
storage
.add_image(&mut t.screen_mut().pages, kitty_img(2, 10, 10))
.unwrap();
storage
.add_image(&mut t.screen_mut().pages, kitty_img(3, 10, 10))
.unwrap();
// (image, z, x, y)
for (id, z, x, y) in [
(1u32, 5, 2u16, 3u32),
(2, -1, 4, 1),
(3, i32::MIN / 2 - 1, 0, 0),
] {
let pages = &mut t.screen_mut().pages;
let pin = pages.pin(Point::Active(Coordinate { x, y })).unwrap();
let loc = Location::Pin(pages.track_pin(pin).unwrap());
let mut p = KPlacement::new(loc);
p.z = z;
storage.add_placement(pages, id, 0, p).unwrap();
}
// A virtual placement only flags that we need to rebuild every frame.
storage
.add_placement(
&mut t.screen_mut().pages,
1,
7,
KPlacement::new(Location::Virtual),
)
.unwrap();
let info = TerminalInfo::from_terminal(&t);
let mut s = State::new();
s.kitty_update(
&storage,
&t.screen().pages,
&info,
CellSize {
width: 10,
height: 20,
},
);
assert!(s.kitty_virtual);
let order: Vec<_> = s
.kitty_placements
.iter()
.map(|p| (p.image_id, p.z, p.x, p.y))
.collect();
assert_eq!(
order,
[
(Id::Kitty(3), i32::MIN / 2 - 1, 0, 0),
(Id::Kitty(2), -1, 4, 1),
(Id::Kitty(1), 5, 2, 3),
]
);
// Below-bg is z < i32::MIN/2, below-text is z < 0, the rest is above.
assert_eq!(s.kitty_bg_end, 1);
assert_eq!(s.kitty_text_end, 2);
// Native image size is used when the placement has no columns/rows.
assert_eq!(
(s.kitty_placements[2].width, s.kitty_placements[2].height),
(20, 20)
);
// The images were converted and queued for upload.
assert!(s.images[&Id::Kitty(1)].image.is_pending());
// Deleting an image from the storage marks it for unload next update.
storage.images.remove(&2);
s.kitty_update(
&storage,
&t.screen().pages,
&info,
CellSize {
width: 10,
height: 20,
},
);
assert!(s.images[&Id::Kitty(2)].image.is_unloading());
assert!(
!s.kitty_placements
.iter()
.any(|p| p.image_id == Id::Kitty(2))
);
}
}
+29 -5
View File
@@ -1,22 +1,46 @@
//! ghostty-renderer: OpenGL renderer building blocks (see
//! docs/rewrite/ARCHITECTURE.md and docs/rewrite/notes/ghostty-renderer-R1.md).
//! ghostty-renderer: the OpenGL renderer (see docs/rewrite/ARCHITECTURE.md and
//! docs/rewrite/notes/ghostty-renderer-R1.md / -R2.md).
//!
//! Terminal-independent parts of `renderer/`: the OpenGL graphics API backend
//! and its GPU object wrappers, shaders and their std140 data layouts,
//! shadertoy custom-shader conversion, size math and cursor style logic.
//! * R1: the OpenGL graphics API backend and its GPU object wrappers, shaders
//! and their std140 data layouts, shadertoy custom-shader conversion, size
//! math and cursor style logic.
//! * R2: the generic renderer that builds GPU cells from a terminal
//! `RenderState` ([`generic`]), cell contents ([`cell`]), row heuristics,
//! link matching, kitty/overlay/background images ([`image`]), the debug
//! [`overlay`], the shared render [`state`] and the renderer [`thread`].
#![cfg_attr(test, allow(clippy::unwrap_used))]
pub mod backend;
pub mod cell;
pub mod cursor;
pub mod generic;
pub mod image;
pub mod link;
pub mod math;
pub mod message;
pub mod opengl;
pub mod options;
pub mod overlay;
pub mod row;
pub mod shadertoy;
pub mod size;
pub mod state;
pub mod thread;
pub use backend::{Backend, Health};
pub use cursor::{Style as CursorStyle, style as cursor_style};
pub use message::Message;
pub use opengl::OpenGL;
pub use size::{CellSize, Coordinate, GridSize, Padding, ScreenSize, Size};
pub use state::State;
/// The renderer used on Linux: the generic renderer instantiated with the
/// OpenGL graphics API (`renderer.zig`: `pub const Renderer = GenericRenderer(OpenGL)`).
pub type Renderer = generic::Renderer;
/// The renderer's derived configuration (`Renderer.DerivedConfig`).
pub type DerivedConfig = generic::DerivedConfig;
/// The renderer thread (`renderer.Thread`).
pub use thread::Thread;
+285
View File
@@ -0,0 +1,285 @@
//! Link matching for the renderer (`renderer/link.zig`).
use std::collections::HashSet;
use ghostty_input::Mods;
use ghostty_input::link::{Highlight, Link as InputLink};
use ghostty_terminal::point::Coordinate;
use ghostty_terminal::render::RenderState;
use onig::{Regex, Region, SearchOptions};
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A configured link regex failed to compile.
#[error("invalid link regex: {0}")]
Regex(#[from] onig::Error),
/// The render state contains an invalid codepoint so it can't be
/// converted to a string.
#[error("render state contains an invalid codepoint")]
InvalidString,
}
/// The link configuration needed for renderers.
pub struct Link {
/// The regular expression to match the link against.
pub regex: Regex,
/// The situations in which the link should be highlighted.
pub highlight: Highlight,
}
/// A set of links. This provides a higher level API for renderers
/// to match against a viewport and determine if cells are part of
/// a link.
#[derive(Default)]
pub struct Set {
pub links: Vec<Link>,
}
impl Set {
/// Returns the set of links from the configuration.
pub fn from_config(config: &[InputLink]) -> Result<Set, Error> {
let mut links = Vec::with_capacity(config.len());
for link in config {
links.push(Link {
regex: Regex::new(&link.regex)?,
highlight: link.highlight,
});
}
Ok(Set { links })
}
/// Fills `result` with the matches from regex link matches.
pub fn render_cell_map(
&self,
result: &mut HashSet<Coordinate>,
render_state: &RenderState,
mouse_viewport: Option<Coordinate>,
mouse_mods: Mods,
) -> Result<(), Error> {
// Fast path, not very likely since we have default links.
if self.links.is_empty() {
return Ok(());
}
// Convert our render state to a string + byte map.
let mut str = String::new();
let mut map: Vec<Coordinate> = Vec::new();
render_state
.string(&mut str, Some(&mut map))
.map_err(|_| Error::InvalidString)?;
// Go through each link and see if we have any matches.
for link in &self.links {
// Determine if our highlight conditions are met. We use a
// match here instead of an if so that we get a compile
// error if any other conditions are added.
match link.highlight {
Highlight::Always => {}
Highlight::AlwaysMods(v) => {
if !mouse_mods.equal(v) {
continue;
}
}
// We check the hover points later.
Highlight::Hover => {
if mouse_viewport.is_none() {
continue;
}
}
Highlight::HoverMods(v) => {
if mouse_viewport.is_none() {
continue;
}
if !mouse_mods.equal(v) {
continue;
}
}
}
let mut offset: usize = 0;
while offset < str.len() {
let haystack = &str[offset..];
let mut region = Region::new();
let found = link.regex.search_with_options(
haystack,
0,
haystack.len(),
SearchOptions::SEARCH_OPTION_NONE,
Some(&mut region),
);
// A mismatch (or any search failure such as hitting the
// retry limit) ends the search for this link.
let Some((offset_start, offset_end)) = found.and(region.pos(0)) else {
break;
};
// We have a match!
let start = offset + offset_start;
let end = offset + offset_end;
// Increment our offset by the number of bytes in the match.
// We do this after handling the match (Zig: `defer`). A
// zero-length match advances by one character so we always
// make progress (Zig would loop forever here).
let next_offset = if end > offset {
end
} else {
let step = str[offset..].chars().next().map_or(1, char::len_utf8);
offset + step
};
let hovered = match link.highlight {
Highlight::Always | Highlight::AlwaysMods(_) => true,
Highlight::Hover | Highlight::HoverMods(_) => match mouse_viewport {
Some(vp) => map[start..end].contains(&vp),
None => false,
},
};
if hovered {
// Record the match
for pt in &map[start..end] {
result.insert(*pt);
}
}
offset = next_offset;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ghostty_input::link::Action;
use ghostty_terminal::terminal::{Options, Terminal};
fn link(regex: &str, highlight: Highlight) -> InputLink {
InputLink {
regex: regex.to_string(),
action: Action::Open,
highlight,
}
}
fn state() -> RenderState {
let mut t = Terminal::new(Options::new(5, 3)).unwrap();
{
let mut s = t.vt_stream();
s.next_slice(b"1ABCD2EFGH\r\n3IJKL").unwrap();
}
let mut state = RenderState::new();
state.update(&mut t).unwrap();
state
}
fn c(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
#[test]
fn render_cell_map() {
let state = state();
let set = Set::from_config(&[link("AB", Highlight::Always), link("EF", Highlight::Always)])
.unwrap();
// Get our matches
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(1, 0)));
assert!(result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
#[test]
fn render_cell_map_hover_links() {
let state = state();
let set = Set::from_config(&[link("AB", Highlight::Hover), link("EF", Highlight::Always)])
.unwrap();
// Not hovering over the first link
{
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(!result.contains(&c(1, 0)));
assert!(!result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
// Hovering over the first link
{
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, Some(c(1, 0)), Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(1, 0)));
assert!(result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
}
#[test]
fn render_cell_map_mods_no_match() {
let state = state();
let set = Set::from_config(&[
link("AB", Highlight::Always),
link(
"EF",
Highlight::AlwaysMods(Mods {
ctrl: true,
..Mods::default()
}),
),
])
.unwrap();
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(1, 0)));
assert!(result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(!result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
#[test]
fn default_url_regex_matches() {
let mut t = Terminal::new(Options::new(30, 2)).unwrap();
{
let mut s = t.vt_stream();
s.next_slice(b"see https://example.com now").unwrap();
}
let mut state = RenderState::new();
state.update(&mut t).unwrap();
let set = Set::from_config(&[link(ghostty_config::url::REGEX, Highlight::Always)]).unwrap();
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(4, 0)));
assert!(result.contains(&c(20, 0)));
assert!(!result.contains(&c(24, 0)));
}
#[test]
fn invalid_regex_is_error() {
assert!(Set::from_config(&[link("(", Highlight::Always)]).is_err());
}
}
+16 -36
View File
@@ -1,30 +1,15 @@
//! Messages that can be sent to a renderer thread (`renderer/message.zig`).
use std::sync::Arc;
use ghostty_font::SharedGrid;
use ghostty_config::config::Config;
use ghostty_font::shared_grid_set::Key as SharedGridKey;
use ghostty_font::{SharedGrid, SharedGridSet};
use ghostty_terminal::highlight::Flattened;
use crate::generic::DerivedConfig as RendererDerivedConfig;
use crate::link;
use crate::size::Size;
/// Key identifying a font grid in the `SharedGridSet`.
// TODO(integration): replace with ghostty_font's SharedGridSet key once
// SharedGridSet is ported (it needs ghostty-config).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SharedGridKey(pub u64);
/// The renderer-thread derived config (`renderer.Thread.DerivedConfig`).
// TODO(integration): defined with Thread.zig (deferred; needs ghostty-config).
#[derive(Debug, Clone, Default)]
pub struct ThreadDerivedConfig {}
/// The renderer implementation derived config (`Renderer.DerivedConfig`).
// TODO(integration): defined with generic.zig (deferred; needs ghostty-config).
#[derive(Debug, Clone, Default)]
pub struct RendererDerivedConfig {}
/// A flattened highlight (`terminal.highlight.Flattened`), used for search matches.
// TODO(integration): replace with ghostty_terminal::highlight::Flattened.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Flattened {}
use crate::thread::DerivedConfig as ThreadDerivedConfig;
/// The messages that can be sent to a renderer thread.
pub enum Message {
@@ -48,7 +33,7 @@ pub enum Message {
/// Change the font grid. This can happen for any number of reasons
/// including a font size change, family change, etc.
FontGrid(FontGridChange),
FontGrid(Box<FontGridChange>),
/// Changes the size. The screen size might change, padding, grid, etc.
Resize(Size),
@@ -78,6 +63,7 @@ pub enum Message {
/// Payload of [`Message::FontGrid`].
pub struct FontGridChange {
pub grid: Arc<SharedGrid>,
pub set: Arc<SharedGridSet>,
/// The key for the new grid. If adopting the new grid fails for any
/// reason, the old grid should be kept but the new key should be
@@ -87,21 +73,15 @@ pub struct FontGridChange {
/// After accepting the new grid, the old grid must be dereferenced
/// using this key.
pub old_key: SharedGridKey,
// TODO(integration): upstream also carries `set: *SharedGridSet`.
}
impl Message {
/// Initialize a change_config message.
// TODO(integration): `initChangeConfig(alloc, config)` builds the derived
// configs from `config.Config`; add once the derived config types exist.
pub fn init_change_config(
thread: ThreadDerivedConfig,
r#impl: RendererDerivedConfig,
) -> Message {
Message::ChangeConfig {
thread: Box::new(thread),
r#impl: Box::new(r#impl),
}
/// Initialize a change_config message (`initChangeConfig`).
pub fn init_change_config(config: &Config) -> Result<Message, link::Error> {
Ok(Message::ChangeConfig {
thread: Box::new(ThreadDerivedConfig::new(config)),
r#impl: Box::new(RendererDerivedConfig::new(config)?),
})
}
}
@@ -111,7 +91,7 @@ mod tests {
#[test]
fn change_config_message_roundtrip() {
let m = Message::init_change_config(ThreadDerivedConfig {}, RendererDerivedConfig {});
let m = Message::init_change_config(&Config::default()).unwrap();
assert!(matches!(m, Message::ChangeConfig { .. }));
}
}
+7 -17
View File
@@ -51,23 +51,7 @@ pub const MIN_VERSION_MAJOR: u32 = 4;
pub const MIN_VERSION_MINOR: u32 = 3;
/// `config.AlphaBlending`.
// TODO(integration): replace with ghostty_config's AlphaBlending.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlphaBlending {
#[default]
Native,
Linear,
LinearCorrected,
}
impl AlphaBlending {
pub fn is_linear(self) -> bool {
match self {
AlphaBlending::Native => false,
AlphaBlending::Linear | AlphaBlending::LinearCorrected => true,
}
}
}
pub use ghostty_config::types_b::AlphaBlending;
#[derive(Debug, thiserror::Error)]
pub enum Error {
@@ -296,6 +280,12 @@ impl OpenGL {
Ok(())
}
/// The most recently presented target, if any (also set when the blit to
/// the default framebuffer is a no-op, e.g. on a surfaceless context).
pub fn last_target(&self) -> Option<&Arc<Target>> {
self.last_target.as_ref()
}
/// Drop the remembered last-presented target (call when the target it
/// refers to is being replaced, e.g. on resize).
pub fn clear_last_target(&mut self) {
+9 -3
View File
@@ -3,14 +3,17 @@ use std::sync::Arc;
use ghostty_font::SharedGrid;
use crate::generic::Host;
use crate::size::Size;
/// Options for constructing a renderer.
///
/// `config` is the derived renderer configuration (`Renderer.DerivedConfig`).
// TODO(integration): upstream also carries `surface_mailbox`, `rt_surface`
// and `thread` (apprt / Thread.zig types). They are added when the generic
// renderer and the apprt exist; the GL loader closure replaces `rt_surface`.
///
/// Zig also carries `rt_surface` (used by the graphics API to prepare the
/// GL context) and `thread`; in Rust the apprt prepares the GL context
/// itself (`OpenGL::prepare_context`) before constructing the renderer, and
/// the thread is wired up by [`crate::thread::Thread`].
pub struct Options<C> {
/// The derived configuration for this renderer implementation.
pub config: C,
@@ -20,4 +23,7 @@ pub struct Options<C> {
/// The size of everything.
pub size: Size,
/// The surface mailbox / apprt callbacks (Zig: `surface_mailbox`).
pub host: Arc<dyn Host>,
}
+466
View File
@@ -0,0 +1,466 @@
//! The debug overlay that can be drawn on top of the terminal during the
//! rendering process (`renderer/Overlay.zig`).
//!
//! Zig draws with z2d on the CPU. Everything drawn here is an axis-aligned
//! rectangle (translucent fill plus a 1px border, no anti-aliasing), so the
//! Rust port rasterizes directly into a premultiplied RGBA8 buffer with
//! source-over compositing, which is what z2d does for these operations.
//!
//! Initialize the overlay, apply features with [`Overlay::apply_features`],
//! then get the resulting image with [`Overlay::pending_image`] to upload to
//! the GPU. This works in concert with [`crate::image::State`].
use ghostty_terminal::page::{SemanticContent, SemanticPrompt};
use ghostty_terminal::render::RenderState;
use crate::image::{Pending, PixelFormat};
use crate::size::{CellSize, Size};
/// The colors we use for overlays.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Color {
/// light blue
Hyperlink,
/// orange/gold
SemanticPrompt,
/// cyan
SemanticInput,
}
/// A premultiplied RGBA pixel.
type Pixel = [u8; 4];
impl Color {
pub fn rgb(self) -> [u8; 3] {
match self {
Color::Hyperlink => [180, 180, 255],
Color::SemanticPrompt => [255, 200, 64],
Color::SemanticInput => [64, 200, 255],
}
}
/// The fill color for rectangles.
pub fn rect_fill(self) -> Pixel {
self.alpha_pixel(96)
}
/// The border color for rectangles.
pub fn rect_border(self) -> Pixel {
self.alpha_pixel(200)
}
fn alpha_pixel(self, alpha: u8) -> Pixel {
let [r, g, b] = self.rgb();
let m = |c: u8| ((u32::from(c) * u32::from(alpha) + 127) / 255) as u8;
[m(r), m(g), m(b), alpha]
}
}
/// The set of available features and their configuration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Feature {
HighlightHyperlinks,
SemanticPrompts,
}
#[derive(Debug, thiserror::Error)]
pub enum InitError {
/// The terminal dimensions are invalid to support an overlay.
/// Either too small or too big.
#[error("invalid overlay dimensions")]
InvalidDimensions,
}
/// The overlay surface plus the cell size so we can map grid coordinates to
/// pixels.
#[derive(Debug)]
pub struct Overlay {
width: u32,
height: u32,
/// Premultiplied RGBA, row-major.
buf: Vec<u8>,
/// Cell size information so we can map grid coordinates to pixels.
pub cell_size: CellSize,
}
impl Overlay {
/// Initialize a new, blank overlay.
pub fn new(sz: &Size) -> Result<Overlay, InitError> {
// Our surface does NOT need to take into account padding because
// we render the overlay using the image subsystem and shaders which
// already take that into account.
let term_size = sz.terminal();
if term_size.width == 0
|| term_size.height == 0
|| i32::try_from(term_size.width).is_err()
|| i32::try_from(term_size.height).is_err()
{
return Err(InitError::InvalidDimensions);
}
let len = (term_size.width as usize)
.checked_mul(term_size.height as usize)
.and_then(|n| n.checked_mul(4))
.ok_or(InitError::InvalidDimensions)?;
Ok(Overlay {
width: term_size.width,
height: term_size.height,
buf: vec![0; len],
cell_size: sz.cell,
})
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
/// The raw premultiplied RGBA pixels.
pub fn data(&self) -> &[u8] {
&self.buf
}
/// Returns a pending image that can be used to copy, convert, upload, etc.
pub fn pending_image(&self) -> Pending {
Pending {
width: self.width,
height: self.height,
pixel_format: PixelFormat::Rgba,
data: self.buf.clone(),
}
}
/// Clear the overlay.
pub fn reset(&mut self) {
self.buf.fill(0);
}
/// Apply the given features to this overlay. This will draw on top of
/// any pre-existing content in the overlay.
pub fn apply_features(&mut self, state: &RenderState, features: &[Feature]) {
for f in features {
match f {
Feature::HighlightHyperlinks => self.highlight_hyperlinks(state),
Feature::SemanticPrompts => self.highlight_semantic_prompts(state),
}
}
}
/// Add rectangles around contiguous hyperlinks in the render state.
///
/// Note: this currently doesn't take into account unique hyperlink IDs
/// because the render state doesn't contain this. This will be added
/// later.
fn highlight_hyperlinks(&mut self, state: &RenderState) {
let border_color = Color::Hyperlink.rect_border();
let fill_color = Color::Hyperlink.rect_fill();
for (y, row) in state.row_data.iter().enumerate() {
if !row.raw.hyperlink() {
continue;
}
let raw_cells = &row.cells.raw;
let mut x = 0;
while x < raw_cells.len() {
// Skip cells without hyperlinks
if !raw_cells[x].hyperlink() {
x += 1;
continue;
}
// Found start of a hyperlink run
let start_x = x;
// Find end of contiguous hyperlink cells
while x < raw_cells.len() && raw_cells[x].hyperlink() {
x += 1;
}
let end_x = x;
if let Err(err) = self.highlight_grid_rect(
start_x,
y,
end_x - start_x,
1,
border_color,
fill_color,
) {
log::warn!("Error drawing hyperlink border: {err}");
}
}
}
}
fn highlight_semantic_prompts(&mut self, state: &RenderState) {
// Highlight the row-level semantic prompt bars. The prompts are easy
// because they're part of the row metadata.
{
let prompt_border = Color::SemanticPrompt.rect_border();
let prompt_fill = Color::SemanticPrompt.rect_fill();
let rows = &state.row_data;
let mut y = 0;
while y < rows.len() {
// If its not a semantic prompt row, skip it.
if rows[y].raw.semantic_prompt() == SemanticPrompt::None {
y += 1;
continue;
}
// Find the full length of the semantic prompt row by
// connecting all continuations.
let start_y = y;
y += 1;
while y < rows.len()
&& rows[y].raw.semantic_prompt() == SemanticPrompt::PromptContinuation
{
y += 1;
}
let end_y = y; // Exclusive
let bar_width = 5.min(self.cell_size.width as usize);
if let Err(err) = self.highlight_pixel_rect(
0,
start_y,
bar_width,
end_y - start_y,
prompt_border,
prompt_fill,
) {
log::warn!("Error drawing semantic prompt bar: {err}");
}
}
}
// Highlight contiguous semantic cells within rows.
for (y, row) in state.row_data.iter().enumerate() {
let raw_cells = &row.cells.raw;
let mut x = 0;
while x < raw_cells.len() {
let content = raw_cells[x].semantic_content();
let start_x = x;
// We skip output because its just the rest of the non-prompt
// parts and it makes the overlay too noisy.
if content == SemanticContent::Output {
x += 1;
continue;
}
// Find the end of this content.
x += 1;
while x < raw_cells.len() && raw_cells[x].semantic_content() == content {
x += 1;
}
let color = match content {
SemanticContent::Prompt => Color::SemanticPrompt,
SemanticContent::Input => Color::SemanticInput,
SemanticContent::Output => unreachable!(),
};
if let Err(err) = self.highlight_grid_rect(
start_x,
y,
x - start_x,
1,
color.rect_border(),
color.rect_fill(),
) {
log::warn!("Error drawing semantic content highlight: {err}");
}
}
}
}
/// Creates a rectangle for highlighting a grid region. x/y/width/height
/// are all in grid cells.
fn highlight_grid_rect(
&mut self,
x: usize,
y: usize,
width: usize,
height: usize,
border_color: Pixel,
fill_color: Pixel,
) -> Result<(), Overflow> {
// All math below uses checked arithmetic to avoid overflows. The
// inputs aren't trusted and the path this is in isn't hot enough
// to warrant unsafe optimizations.
let cw = self.cell_size.width as usize;
let px_width = checked_i32(width.checked_mul(cw).ok_or(Overflow)?)?;
let px_height = checked_i32(
height
.checked_mul(self.cell_size.height as usize)
.ok_or(Overflow)?,
)?;
let start_x = checked_i32(x.checked_mul(cw).ok_or(Overflow)?)?;
let start_y = checked_i32(
y.checked_mul(self.cell_size.height as usize)
.ok_or(Overflow)?,
)?;
self.rect(
start_x,
start_y,
start_x.checked_add(px_width).ok_or(Overflow)?,
start_y.checked_add(px_height).ok_or(Overflow)?,
border_color,
fill_color,
);
Ok(())
}
/// Creates a rectangle for highlighting a region. x/y are grid cells and
/// width/height are pixels (width) and grid cells (height).
fn highlight_pixel_rect(
&mut self,
x: usize,
y: usize,
width_px: usize,
height: usize,
border_color: Pixel,
fill_color: Pixel,
) -> Result<(), Overflow> {
let px_width = checked_i32(width_px)?;
let px_height = checked_i32(
height
.checked_mul(self.cell_size.height as usize)
.ok_or(Overflow)?,
)?;
let start_x = checked_i32(
x.checked_mul(self.cell_size.width as usize)
.ok_or(Overflow)?,
)?;
let start_y = checked_i32(
y.checked_mul(self.cell_size.height as usize)
.ok_or(Overflow)?,
)?;
self.rect(
start_x,
start_y,
start_x.checked_add(px_width).ok_or(Overflow)?,
start_y.checked_add(px_height).ok_or(Overflow)?,
border_color,
fill_color,
);
Ok(())
}
/// Fill the half-open rectangle `[x0, x1) x [y0, y1)` and then stroke a
/// one pixel hairline along its outline (the closed path through the
/// four corners, so the right/bottom edge lies on `x1`/`y1`).
fn rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, border: Pixel, fill: Pixel) {
for y in y0..y1 {
for x in x0..x1 {
self.blend(x, y, fill);
}
}
for x in x0..=x1 {
self.blend(x, y0, border);
self.blend(x, y1, border);
}
for y in y0..=y1 {
self.blend(x0, y, border);
self.blend(x1, y, border);
}
}
/// Source-over blend of a premultiplied pixel, clipped to the surface.
fn blend(&mut self, x: i32, y: i32, src: Pixel) {
if x < 0 || y < 0 || x as u32 >= self.width || y as u32 >= self.height {
return;
}
let i = (y as usize * self.width as usize + x as usize) * 4;
let inv = 255 - u32::from(src[3]);
for (d, s) in self.buf[i..i + 4].iter_mut().zip(src) {
let out = u32::from(s) + (u32::from(*d) * inv + 127) / 255;
*d = out.min(255) as u8;
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("overflow")]
struct Overflow;
fn checked_i32(v: usize) -> Result<i32, Overflow> {
i32::try_from(v).map_err(|_| Overflow)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::size::{Padding, ScreenSize};
use ghostty_terminal::terminal::{Options, Terminal};
fn size(w: u32, h: u32) -> Size {
Size {
screen: ScreenSize {
width: w,
height: h,
},
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding::default(),
}
}
#[test]
fn invalid_dimensions() {
assert!(Overlay::new(&size(0, 10)).is_err());
assert!(Overlay::new(&size(10, 0)).is_err());
assert!(Overlay::new(&size(40, 40)).is_ok());
}
#[test]
fn colors_are_premultiplied() {
assert_eq!(Color::Hyperlink.rect_fill()[3], 96);
assert_eq!(Color::Hyperlink.rect_border()[3], 200);
let [r, _, _, a] = Color::SemanticPrompt.rect_fill();
assert_eq!(a, 96);
assert_eq!(r, ((255u32 * 96 + 127) / 255) as u8);
}
#[test]
fn grid_rect_fill_and_reset() {
let mut o = Overlay::new(&size(100, 100)).unwrap();
o.highlight_grid_rect(1, 1, 2, 1, [0, 0, 100, 200], [0, 0, 50, 96])
.unwrap();
// Interior pixel is the fill.
let i = ((30 * 100) + 15) * 4;
assert_eq!(&o.data()[i..i + 4], &[0, 0, 50, 96]);
// The top-left corner pixel is the border (blended over fill).
let i = ((20 * 100) + 10) * 4;
assert!(o.data()[i + 3] >= 200);
// Outside is untouched.
assert_eq!(&o.data()[0..4], &[0, 0, 0, 0]);
o.reset();
assert!(o.data().iter().all(|&b| b == 0));
}
#[test]
fn highlights_semantic_prompt_rows() {
let mut t = Terminal::new(Options::new(10, 3)).unwrap();
{
let mut s = t.vt_stream();
// OSC 133 A: start of prompt.
s.next_slice(b"\x1b]133;A\x07$ ").unwrap();
}
let mut state = RenderState::new();
state.update(&mut t).unwrap();
let mut o = Overlay::new(&size(100, 60)).unwrap();
o.apply_features(&state, &[Feature::SemanticPrompts]);
assert!(o.data().iter().any(|&b| b != 0));
// And the hyperlink feature on a terminal without links draws nothing.
let mut o = Overlay::new(&size(100, 60)).unwrap();
o.apply_features(&state, &[Feature::HighlightHyperlinks]);
assert!(o.data().iter().all(|&b| b == 0));
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Row heuristics for the renderer (`renderer/row.zig`).
use ghostty_terminal::page::{Cell, ContentTag, Row, SemanticPrompt};
use ghostty_terminal::style::Style;
use ghostty_vt::color::{Palette, Rgb};
/// Returns true if the row of this pin should never have its background
/// color extended for filling padding space in the renderer. This is
/// a set of heuristics that help making our padding look better.
///
/// `styles` is the (possibly empty) style array of the render-state row;
/// when empty, every cell has the default style.
pub fn never_extend_bg(
row: Row,
cells: &[Cell],
styles: &[Style],
palette: &Palette,
default_background: Rgb,
) -> bool {
// Any semantic prompts should not have their background extended
// because prompts often contain special formatting (such as
// powerline) that looks bad when extended.
match row.semantic_prompt() {
SemanticPrompt::Prompt | SemanticPrompt::PromptContinuation => return true,
SemanticPrompt::None => {}
}
for (x, cell) in cells.iter().enumerate() {
// If any cell has a default background color then we don't
// extend because the default background color probably looks
// good enough as an extension.
match cell.content_tag() {
// If it is a background color cell, we check the color.
ContentTag::BgColorPalette | ContentTag::BgColorRgb => {
let s = style_at(*cell, styles, x);
let Some(bg) = s.bg(cell, palette) else {
return true;
};
if bg == default_background {
return true;
}
}
// If its a codepoint cell we can check the style.
ContentTag::Codepoint | ContentTag::CodepointGrapheme => {
// For codepoint containing, we also never extend bg
// if any cell has a powerline glyph because these are
// perfect-fit.
if matches!(
cell.codepoint(),
// Powerline
0xE0B0..=0xE0C8 | 0xE0CA | 0xE0CC..=0xE0D2 | 0xE0D4
) {
return true;
}
// Never extend a cell that has a default background.
// A default background is applied if there is no background
// on the style or the explicitly set background
// matches our default background.
let s = style_at(*cell, styles, x);
let Some(bg) = s.bg(cell, palette) else {
return true;
};
if bg == default_background {
return true;
}
}
}
}
false
}
fn style_at(cell: Cell, styles: &[Style], x: usize) -> Style {
if cell.has_styling() {
styles.get(x).copied().unwrap_or_default()
} else {
Style::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ghostty_vt::color;
const BG: Rgb = Rgb { r: 1, g: 2, b: 3 };
#[test]
fn default_background_cells_never_extend() {
let cells = [Cell::init(u32::from('a'))];
assert!(never_extend_bg(
Row::default(),
&cells,
&[],
&color::DEFAULT,
BG
));
}
#[test]
fn prompt_rows_never_extend() {
let mut row = Row::default();
row.set_semantic_prompt(SemanticPrompt::Prompt);
assert!(never_extend_bg(row, &[], &[], &color::DEFAULT, BG));
}
#[test]
fn powerline_never_extends() {
let cells = [Cell::init(0xE0B0)];
assert!(never_extend_bg(
Row::default(),
&cells,
&[],
&color::DEFAULT,
BG
));
}
#[test]
fn explicit_bg_color_cell_extends() {
let mut c = Cell::default();
c.set_content_tag(ContentTag::BgColorRgb);
c.set_color_rgb(Rgb { r: 9, g: 9, b: 9 });
assert!(!never_extend_bg(
Row::default(),
&[c],
&[],
&color::DEFAULT,
BG
));
// ... unless it equals the default background.
c.set_color_rgb(BG);
assert!(never_extend_bg(
Row::default(),
&[c],
&[],
&color::DEFAULT,
BG
));
}
}
+210
View File
@@ -0,0 +1,210 @@
//! The render state that is given to a renderer (`renderer/State.zig`).
use std::sync::{Arc, Mutex};
use ghostty_input::Mods;
use ghostty_terminal::point::Coordinate;
use ghostty_terminal::size::CellCountInt;
use ghostty_terminal::terminal::Terminal;
use crate::overlay::Feature as OverlayFeature;
/// The state shared between the IO side, the surface and the renderer.
///
/// Zig passes a `*Mutex` plus pointers to the terminal and friends. Rust
/// folds them into one value behind one mutex: everything in here must only
/// be read or written while holding [`SharedState`]'s lock (the "renderer
/// mutex" of Ghostty). The termio thread parses into `terminal`, the surface
/// updates `preedit`/`mouse`, and the renderer thread reads all of it in
/// `update_frame`.
#[derive(Debug)]
pub struct State {
/// The terminal data.
pub terminal: Terminal,
/// The overlay features currently requested by the terminal inspector
/// (Zig: `inspector: ?*Inspector` and `inspector.rendererInfo()
/// .overlayFeatures()`). Empty while the inspector is not active.
// TODO(integration): the inspector is deferred (Phase 7); it fills this.
pub overlay_features: Vec<OverlayFeature>,
/// Dead key state. This will render the current dead key preedit text
/// over the cursor. This currently only ever renders a single codepoint.
/// Preedit can in theory be multiple codepoints long but that is left as
/// a future exercise.
pub preedit: Option<Preedit>,
/// Mouse state. This only contains state relevant to what renderers
/// need about the mouse.
pub mouse: Mouse,
}
/// The shared, mutex-protected renderer state.
pub type SharedState = Arc<Mutex<State>>;
impl State {
pub fn new(terminal: Terminal) -> State {
State {
terminal,
overlay_features: Vec::new(),
preedit: None,
mouse: Mouse::default(),
}
}
/// Wrap this state into the shared form used by the renderer thread.
pub fn into_shared(self) -> SharedState {
Arc::new(Mutex::new(self))
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Mouse {
/// The point on the viewport where the mouse currently is. We use
/// viewport points to avoid the complexity of mapping the mouse to
/// the renderer state.
pub point: Option<Coordinate>,
/// The mods that are currently active for the last mouse event.
/// This could really just be mods in general and we probably will
/// move it out of mouse state at some point.
pub mods: Mods,
}
/// The pre-edit state. See Surface.preeditCallback for more information.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Preedit {
/// The codepoints to render as preedit text.
pub codepoints: Vec<PreeditCodepoint>,
}
/// A single codepoint to render as preedit text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PreeditCodepoint {
pub codepoint: u32,
pub wide: bool,
}
/// The result of [`Preedit::range`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PreeditRange {
pub start: CellCountInt,
pub end: CellCountInt,
pub cp_offset: usize,
}
impl Preedit {
/// The width in cells of all codepoints in the preedit.
pub fn width(&self) -> usize {
self.codepoints
.iter()
.map(|cp| if cp.wide { 2 } else { 1 })
.sum()
}
/// Range returns the start and end x position of the preedit text
/// along with any codepoint offset necessary to fit the preedit
/// into the available space.
pub fn range(&self, start: CellCountInt, max: CellCountInt) -> PreeditRange {
// If our width is greater than the number of cells we have
// then we need to adjust our codepoint start to a point where
// our width would be less than the number of cells we have.
let (w, cp_offset) = 'width: {
// max is inclusive, so we need to add 1 to it.
let max_width = max - start + 1;
// Rebuild our width in reverse order. This is because we want
// to offset by the end cells, not the start cells (if we have to).
let mut w: CellCountInt = 0;
for i in 0..self.codepoints.len() {
let reverse_i = self.codepoints.len() - i - 1;
let cp = self.codepoints[reverse_i];
w += if cp.wide { 2 } else { 1 };
if w > max_width {
break 'width (w, reverse_i);
}
}
// Width fit in the max width so no offset necessary.
(w, 0)
};
// If our preedit goes off the end of the screen, we adjust it so
// that it shifts left.
let end = start + w;
let start_offset = end.saturating_sub(max);
PreeditRange {
start: start.saturating_sub(start_offset),
end: end.saturating_sub(start_offset),
cp_offset,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cp(c: char, wide: bool) -> PreeditCodepoint {
PreeditCodepoint {
codepoint: c as u32,
wide,
}
}
#[test]
fn preedit_width() {
let p = Preedit {
codepoints: vec![cp('a', false), cp('b', true)],
};
assert_eq!(p.width(), 3);
assert_eq!(Preedit::default().width(), 0);
}
#[test]
fn preedit_range_fits() {
let p = Preedit {
codepoints: vec![cp('a', false), cp('b', true)],
};
let r = p.range(2, 9);
assert_eq!(
r,
PreeditRange {
start: 2,
end: 5,
cp_offset: 0
}
);
}
#[test]
fn preedit_range_shifts_left_at_edge() {
let p = Preedit {
codepoints: vec![cp('a', false), cp('b', false), cp('c', false)],
};
// Start at the last column of a 10 column screen (max = 9).
let r = p.range(8, 9);
// Width is 3 > max_width (2): the span shifts left so it ends at max.
assert_eq!(
r,
PreeditRange {
start: 6,
end: 9,
cp_offset: 0
}
);
}
#[test]
fn preedit_range_too_wide_for_screen() {
let p = Preedit {
codepoints: vec![cp('a', true), cp('b', true), cp('c', true)],
};
// 4 columns available starting at 0 (max = 3): width 6 > 4.
let r = p.range(0, 3);
// Walking in reverse: c(2) b(4) a(6>4) breaks at index 0.
assert_eq!(r.cp_offset, 0);
assert_eq!(r.end, 3);
assert_eq!(r.start, 0);
}
}
+563
View File
@@ -0,0 +1,563 @@
//! The renderer thread (`renderer/Thread.zig`).
//!
//! # Threading model (GTK / OpenGL)
//!
//! Ghostty's OpenGL renderer on GTK cannot draw from the renderer thread: the
//! `GtkGLArea` context is only current on the GTK main thread, inside the
//! `render` signal. So (`must_draw_from_app_thread = true`):
//!
//! ```text
//! termio thread --(wakeup)--> renderer thread --(redraw_surface)--> GTK main thread
//! | |
//! update_frame: lock terminal, GLArea::render ->
//! shape + rebuild CPU cell buffers Renderer::draw_frame:
//! (no GL calls) upload + draw + present
//! ```
//!
//! * The thread owns timers (cursor blink 600ms, draw timer 8ms for custom
//! shader animation), drains its [`Mailbox`], and on every wakeup runs
//! `update_frame` followed by a *draw request*.
//! * A draw request is [`Host::redraw_surface`]; the apprt answers by calling
//! `queue_render()` on the GLArea, whose `render` handler calls
//! [`Renderer::draw_frame`]. Both threads synchronise through the renderer's
//! draw mutex.
//! * [`Handle::draw_now`] is the "draw now" async of Zig (used by the apprt for
//! vsync-driven renderers; unused for OpenGL on Linux).
//!
//! For headless use (tests, no GLArea) [`Thread::draw_on_app_thread`] can be
//! set to `false`, in which case the thread calls `draw_frame` itself; the GL
//! context must then be current on the renderer thread.
//!
//! The event loop (libxev in Zig) is replaced by a `crossbeam_channel::select!`
//! over the wakeup / stop / draw-now channels and the next timer deadline.
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossbeam_channel::{Receiver, Sender, bounded, never, select};
use ghostty_config::config::Config;
use ghostty_config::types_a::CustomShaderAnimation;
use ghostty_datastruct::blocking_queue::{BlockingQueue, Timeout};
use crate::generic::{Host, Renderer};
use crate::message::Message;
use crate::state::SharedState;
/// 120 FPS
pub const DRAW_INTERVAL: Duration = Duration::from_millis(8);
pub const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(600);
/// The type used for sending messages to the renderer thread. For now this is
/// hardcoded with a capacity. We can make this a comptime parameter in
/// the future if we want it configurable.
pub type Mailbox = BlockingQueue<Message, 64>;
/// Configuration we need derived from the main config.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DerivedConfig {
pub custom_shader_animation: CustomShaderAnimation,
}
impl DerivedConfig {
pub fn new(config: &Config) -> DerivedConfig {
DerivedConfig {
custom_shader_animation: config.custom_shader_animation,
}
}
}
#[derive(Debug, Clone, Copy)]
struct Flags {
/// This is true when a blinking cursor should be visible and false
/// when it should not be visible. This is toggled on a timer by the
/// thread automatically.
cursor_blink_visible: bool,
/// This is true when the inspector is active.
has_inspector: bool,
/// This is true when the view is visible. This is used to determine
/// if we should be rendering or not.
visible: bool,
/// This is true when the view is focused. This defaults to true
/// and it is up to the apprt to set the correct value.
focused: bool,
}
/// A cloneable handle to a (possibly running) renderer thread. This is what
/// the surface and the apprt keep to talk to the thread from any thread.
#[derive(Clone)]
pub struct Handle {
/// The mailbox that can be used to send this thread messages. Note
/// this is a blocking queue so if it is full you will get errors (or block).
pub mailbox: Arc<Mailbox>,
wakeup: Sender<()>,
stop: Sender<()>,
draw_now: Sender<()>,
}
impl Handle {
/// Push a message. Returns false if the mailbox was full (and `timeout`
/// elapsed). This does NOT wake the thread; call [`Handle::wakeup`]
/// (Zig: `mailbox.push(msg, .{.forever}); thread.wakeup.notify()`).
pub fn push(&self, msg: Message, timeout: Timeout) -> bool {
self.mailbox.push(msg, timeout) > 0
}
/// Push a message (blocking if full) and wake the thread.
pub fn send(&self, msg: Message) -> bool {
let ok = self.push(msg, Timeout::Forever);
self.wakeup();
ok
}
/// Wake the renderer up and force a render safely from any thread.
/// Wakeups coalesce.
pub fn wakeup(&self) {
let _ = self.wakeup.try_send(());
}
/// Force a draw request immediately (does not update frame data).
pub fn draw_now(&self) {
let _ = self.draw_now.try_send(());
}
/// Stop the renderer on the next loop iteration.
pub fn stop(&self) {
let _ = self.stop.try_send(());
}
}
/// A started renderer thread.
pub struct Running {
pub handle: Handle,
join: Option<std::thread::JoinHandle<()>>,
}
impl Running {
/// Stop the thread and wait for it to exit. After this returns the
/// thread has dropped its reference to the renderer, so the caller's
/// `Arc<Renderer>` is the last one (and can be dropped on the GL thread).
pub fn stop_and_join(mut self) {
self.handle.stop();
if let Some(j) = self.join.take()
&& j.join().is_err()
{
log::warn!("renderer thread panicked");
}
}
}
impl Drop for Running {
fn drop(&mut self) {
if let Some(j) = self.join.take() {
self.handle.stop();
let _ = j.join();
}
}
}
/// The renderer thread state. Construct with [`Thread::new`] then start it
/// with [`Thread::spawn`] (or run [`Thread::thread_main`] on a thread you
/// manage).
pub struct Thread {
/// The underlying renderer implementation.
renderer: Arc<Renderer>,
/// Pointer to the shared state that is used to generate the final render.
state: SharedState,
/// The surface/apprt callbacks.
host: Arc<dyn Host>,
/// Configuration we need derived from the main config.
config: DerivedConfig,
/// If true (the default, GTK), draws are requested from the apprt via
/// [`Host::redraw_surface`] instead of being performed on this thread
/// (`must_draw_from_app_thread`).
pub draw_on_app_thread: bool,
flags: Flags,
handle: Handle,
wakeup_rx: Receiver<()>,
wakeup_tx: Sender<()>,
stop_rx: Receiver<()>,
draw_now_rx: Receiver<()>,
/// Deadline of the cursor blink timer; None when it isn't running.
cursor_deadline: Option<Instant>,
/// Whether the draw timer should keep re-arming, and its next deadline.
draw_active: bool,
draw_deadline: Option<Instant>,
}
impl Thread {
/// Initialize the thread. This does not START the thread. This only sets
/// up all the internal state necessary prior to starting the thread. It
/// is up to the caller to start the thread with [`Thread::spawn`].
pub fn new(
config: &Config,
renderer: Arc<Renderer>,
state: SharedState,
host: Arc<dyn Host>,
) -> Thread {
// Wakeups, stop and draw-now coalesce (xev.Async semantics).
let (wakeup_tx, wakeup_rx) = bounded(1);
let (stop_tx, stop_rx) = bounded(1);
let (draw_now_tx, draw_now_rx) = bounded(1);
let handle = Handle {
mailbox: Arc::new(Mailbox::new()),
wakeup: wakeup_tx.clone(),
stop: stop_tx,
draw_now: draw_now_tx,
};
Thread {
renderer,
state,
host,
config: DerivedConfig::new(config),
draw_on_app_thread: true,
flags: Flags {
cursor_blink_visible: false,
has_inspector: false,
visible: true,
focused: true,
},
handle,
wakeup_rx,
wakeup_tx,
stop_rx,
draw_now_rx,
cursor_deadline: None,
draw_active: false,
draw_deadline: None,
}
}
/// The handle used to message this thread. Available before the thread
/// is started so the surface can queue messages.
pub fn handle(&self) -> Handle {
self.handle.clone()
}
/// Start the thread (named "renderer"). The returned [`Running`] keeps
/// the [`Handle`].
pub fn spawn(self) -> std::io::Result<Running> {
let handle = self.handle.clone();
let join = std::thread::Builder::new()
.name("renderer".into())
.spawn(move || self.thread_main())?;
Ok(Running {
handle,
join: Some(join),
})
}
/// The main entrypoint for the thread.
pub fn thread_main(mut self) {
log::debug!("starting renderer thread");
// Run our thread start/end callbacks. For OpenGL on GTK these are
// no-ops: all GL work is on the main thread.
// Send an initial wakeup message so that we render right away.
let _ = self.wakeup_tx.try_send(());
// Start blinking the cursor.
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
// Start the draw timer
self.sync_draw_timer();
// Run
loop {
let next = [self.cursor_deadline, self.draw_deadline]
.into_iter()
.flatten()
.min();
let timer = next.map_or_else(never, crossbeam_channel::at);
select! {
recv(self.wakeup_rx) -> _ => {
// When we wake up, we check the mailbox. Mailbox producers
// should wake up our thread after publishing.
self.drain_mailbox();
// Render immediately
self.render_callback();
}
recv(self.stop_rx) -> _ => break,
recv(self.draw_now_rx) -> _ => {
// Draw immediately
self.draw_frame(true);
}
recv(timer) -> _ => self.timers_due(),
}
}
log::debug!("starting renderer thread shutdown");
}
/// Fire whichever timers are due.
fn timers_due(&mut self) {
let now = Instant::now();
if self.cursor_deadline.is_some_and(|d| d <= now) {
self.cursor_timer_callback();
}
if self.draw_deadline.is_some_and(|d| d <= now) {
self.draw_callback();
}
}
fn sync_draw_timer(&mut self) {
if !should_draw_timer_run(
self.renderer.has_animations(),
self.config.custom_shader_animation,
self.flags.focused,
) {
// We're skipping the draw timer. Stop it on the next iteration.
self.draw_active = false;
return;
}
// Set our active state so it knows we're running. We set this before
// even checking the active state in case we have a pending shutdown.
self.draw_active = true;
// If our draw timer is already active, then we don't have to do anything.
if self.draw_deadline.is_some() {
return;
}
// Start the timer which loops
self.draw_deadline = Some(Instant::now() + DRAW_INTERVAL);
}
/// Drain the mailbox.
fn drain_mailbox(&mut self) {
while let Some(message) = self.handle.mailbox.pop() {
match message {
Message::Crash => panic!("crash request, crashing intentionally"),
Message::Visible(v) => {
// If our state didn't change we do nothing.
if self.flags.visible == v {
continue;
}
// Set our visible state
self.flags.visible = v;
// If we became visible then we immediately trigger a draw.
// We don't need to update frame data because that should
// still be happening.
if v {
self.draw_frame(false);
}
// Notify the renderer so it can update any state.
self.renderer.set_visible(v);
// Note that we're explicitly today not stopping any
// cursor timers, draw timers, etc. These things have very
// little resource cost and properly maintaining their active
// state across different transitions is going to be bug-prone,
// so its easier to just let them keep firing and have them
// check the visible state themselves to control their behavior.
}
Message::Focus(v) => {
// If our state didn't change we do nothing.
if self.flags.focused == v {
continue;
}
// Set our state
self.flags.focused = v;
// Set it on the renderer
self.renderer.set_focus(v);
// We always resync our draw timer (may disable it)
self.sync_draw_timer();
if v {
// If we're focused, we immediately show the cursor again
// and then restart the timer.
if self.cursor_deadline.is_none() {
self.flags.cursor_blink_visible = true;
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
}
} else {
// If we're not focused, then we stop the cursor blink
self.cursor_deadline = None;
}
}
Message::ResetCursorBlink => {
self.flags.cursor_blink_visible = true;
if self.cursor_deadline.is_some() {
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
}
}
Message::FontGrid(grid) => {
let grid = *grid;
self.renderer.set_font_grid(grid.grid);
grid.set.deref(&grid.old_key);
}
Message::Resize(v) => self.renderer.set_screen_size(v),
Message::ChangeConfig { thread, r#impl } => {
self.config = *thread;
if let Err(err) = self.renderer.change_config(*r#impl) {
log::warn!("error changing renderer config err={err}");
}
// Stop and start the draw timer to capture the new
// has_animations value.
self.sync_draw_timer();
}
Message::SearchViewportMatches(v) => {
self.renderer.set_search_viewport_matches(v);
}
Message::SearchSelectedMatch(v) => {
self.renderer.set_search_selected_match(v);
}
Message::Inspector(v) => {
self.flags.has_inspector = v;
}
Message::MacosDisplayId(_) => {}
}
}
}
/// Trigger a draw. This will not update frame data or anything, it will
/// just trigger a draw/paint.
fn draw_frame(&self, now: bool) {
// If we're invisible, we do not draw.
if !self.flags.visible {
return;
}
// If the renderer is managing a vsync on its own, we only draw
// when we're forced to via `now`.
if !now && self.renderer.has_vsync() {
return;
}
if self.draw_on_app_thread {
let _ = self.host.redraw_surface();
} else if let Err(err) = self.renderer.draw_frame(false) {
log::warn!("error drawing err={err}");
}
}
fn render_callback(&self) {
// Update our frame data
if let Err(err) = self
.renderer
.update_frame(&self.state, self.flags.cursor_blink_visible)
{
log::warn!("error rendering err={err}");
}
// Draw
self.draw_frame(false);
}
fn draw_callback(&mut self) {
// Draw
self.draw_frame(false);
// Only continue if we're still active
self.draw_deadline = if self.draw_active {
Some(Instant::now() + DRAW_INTERVAL)
} else {
None
};
}
fn cursor_timer_callback(&mut self) {
self.flags.cursor_blink_visible = !self.flags.cursor_blink_visible;
let _ = self.wakeup_tx.try_send(());
self.cursor_deadline = Some(Instant::now() + CURSOR_BLINK_INTERVAL);
}
}
/// Whether the draw timer (used for custom shader animation) should run.
fn should_draw_timer_run(
has_animations: bool,
animation: CustomShaderAnimation,
focused: bool,
) -> bool {
// If our renderer supports animations and has them, then we
// can apply draw timer based on custom shader animation configuration.
if has_animations {
match animation {
// Always animate
CustomShaderAnimation::Always => return true,
// Only when focused
CustomShaderAnimation::True => {
if focused {
return true;
}
}
// Never animate
CustomShaderAnimation::False => {}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn draw_timer_policy() {
use CustomShaderAnimation::{Always, False, True};
// No animations: never runs.
for a in [Always, True, False] {
for f in [true, false] {
assert!(!should_draw_timer_run(false, a, f));
}
}
// Animations: always / only-when-focused / never.
assert!(should_draw_timer_run(true, Always, false));
assert!(should_draw_timer_run(true, Always, true));
assert!(should_draw_timer_run(true, True, true));
assert!(!should_draw_timer_run(true, True, false));
assert!(!should_draw_timer_run(true, False, true));
}
#[test]
fn intervals_match_zig() {
assert_eq!(DRAW_INTERVAL, Duration::from_millis(8));
assert_eq!(CURSOR_BLINK_INTERVAL, Duration::from_millis(600));
}
#[test]
fn derived_config_from_config() {
let c = Config {
custom_shader_animation: CustomShaderAnimation::Always,
..Config::default()
};
assert_eq!(
DerivedConfig::new(&c).custom_shader_animation,
CustomShaderAnimation::Always
);
}
}
@@ -388,3 +388,461 @@ fn headless_invalid_shader_reports_compile_error() {
Err(ghostty_renderer::opengl::gl::Error::CompileFailed(_))
));
}
// ---------------------------------------------------------------------------
// Full renderer: Terminal -> RenderState -> generic renderer -> offscreen target
mod full_renderer {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use ghostty_config::config::Config;
use ghostty_config::types_a::Color as ConfigColor;
use ghostty_config::types_b::AlphaBlending as ConfigBlending;
use ghostty_font::face::DesiredSize;
use ghostty_font::shared_grid_set::{DerivedConfig as FontDerivedConfig, SharedGridSet};
use ghostty_renderer::generic::{DerivedConfig, Host, Renderer};
use ghostty_renderer::options::Options;
use ghostty_renderer::size::{CellSize, Padding, ScreenSize, Size};
use ghostty_renderer::state::State;
use ghostty_terminal::page_list::Scrollbar;
use ghostty_terminal::terminal::{Options as TermOptions, Terminal};
use ghostty_vt::color::DynamicRgb;
#[derive(Default)]
struct TestHost {
scrollbars: Mutex<Vec<Scrollbar>>,
health: Mutex<Vec<ghostty_renderer::Health>>,
redraws: AtomicUsize,
}
impl Host for TestHost {
fn scrollbar(&self, s: Scrollbar) -> bool {
self.scrollbars.lock().unwrap().push(s);
true
}
fn renderer_health(&self, h: ghostty_renderer::Health) {
self.health.lock().unwrap().push(h);
}
fn redraw_surface(&self) -> bool {
self.redraws.fetch_add(1, Ordering::SeqCst);
true
}
}
const PAD: u32 = 10;
const COLS: u16 = 20;
const ROWS: u16 = 5;
struct Fixture {
_h: Headless,
renderer: Arc<Renderer>,
gl: Gl,
state: ghostty_renderer::state::SharedState,
host: Arc<TestHost>,
cell: CellSize,
w: usize,
h: usize,
bg: [u8; 3],
}
impl Fixture {
fn new(text: &str) -> Option<Fixture> {
let (h, mut api) = {
let h = match Headless::new() {
Ok(h) => h,
Err(e) => {
eprintln!("SKIP: headless EGL unavailable: {e}");
return None;
}
};
// Native blending so the stored bytes are exact.
let mut api = OpenGL::init(ConfigBlending::Native);
if let Err(e) = api.prepare_context(|n| h.proc_address(n)) {
eprintln!("SKIP: cannot prepare GL context: {e}");
return None;
}
(h, api)
};
let _ = &mut api;
let config = Config {
alpha_blending: ConfigBlending::Native,
background: ConfigColor {
r: 0x10,
g: 0x20,
b: 0x30,
},
background_opacity: 1.0,
..Config::default()
};
// Fonts: the embedded JetBrains Mono (no font-family configured).
let set = Arc::new(SharedGridSet::new().unwrap());
let (_key, grid) = set
.ref_(
&FontDerivedConfig::new(&config),
DesiredSize {
points: 12.0,
xdpi: 96,
ydpi: 96,
},
)
.unwrap();
let gc = grid.cell_size();
let cell = CellSize {
width: gc.width,
height: gc.height,
};
// As termio does at startup, give the terminal the configured colors.
let mut topts = TermOptions::new(COLS, ROWS);
topts.colors.background = DynamicRgb::init(config.background.to_terminal_rgb());
topts.colors.foreground = DynamicRgb::init(config.foreground.to_terminal_rgb());
let mut term = Terminal::new(topts).unwrap();
{
let mut s = term.vt_stream();
s.next_slice(text.as_bytes()).unwrap();
}
let state = State::new(term).into_shared();
let w = (u32::from(COLS) * cell.width + 2 * PAD) as usize;
let hh = (u32::from(ROWS) * cell.height + 2 * PAD) as usize;
let size = Size {
screen: ScreenSize {
width: w as u32,
height: hh as u32,
},
cell,
padding: Padding {
top: PAD,
bottom: PAD,
left: PAD,
right: PAD,
},
};
let gl: Gl = api.gl().unwrap().clone();
// A surfaceless context has no default viewport (GTK's GLArea sets one).
gl.viewport(0, 0, w as i32, hh as i32).unwrap();
let host = Arc::new(TestHost::default());
let renderer = Arc::new(
Renderer::new(
Options {
config: DerivedConfig::new(&config).unwrap(),
font_grid: grid,
size,
host: host.clone(),
},
api,
)
.unwrap(),
);
Some(Fixture {
_h: h,
gl,
renderer,
state,
host,
cell,
w,
h: hh,
bg: [0x10, 0x20, 0x30],
})
}
fn frame(&self, blink_visible: bool) -> Vec<u8> {
self.renderer
.update_frame(&self.state, blink_visible)
.unwrap();
self.renderer.draw_frame(true).unwrap();
let (w, h, px) = self
.renderer
.last_frame_pixels()
.expect("a frame was drawn");
assert_eq!((w, h), (self.w, self.h));
px
}
/// Pixel at terminal-space (top-left origin) pixel coordinates.
fn at(&self, px: &[u8], x: usize, y_top: usize) -> [u8; 4] {
super::px(px, self.w, x, self.h - 1 - y_top)
}
/// The pixel at the center of a rectangle.
fn at_center(&self, px: &[u8], r: (usize, usize, usize, usize)) -> [u8; 4] {
self.at(px, usize::midpoint(r.0, r.2), usize::midpoint(r.1, r.3))
}
/// The (x0, y0, x1, y1) pixel rectangle of grid cell (cx, cy).
fn cell_rect(&self, cx: usize, cy: usize) -> (usize, usize, usize, usize) {
let (cw, ch) = (self.cell.width as usize, self.cell.height as usize);
let (x0, y0) = (PAD as usize + cx * cw, PAD as usize + cy * ch);
(x0, y0, x0 + cw, y0 + ch)
}
/// Pixels of a rectangle.
fn region(&self, px: &[u8], r: (usize, usize, usize, usize)) -> Vec<[u8; 4]> {
let mut v = Vec::new();
for y in r.1..r.3 {
for x in r.0..r.2 {
v.push(self.at(px, x, y));
}
}
v
}
}
fn is_bg(p: [u8; 4], bg: [u8; 3]) -> bool {
p[..3] == bg
}
#[test]
fn headless_full_renderer_draws_terminal() {
// Line 1: red "hello"; line 2: three palette-green cells then the cursor.
let Some(f) = Fixture::new("\x1b[31mhello\x1b[0m\r\n\x1b[42m \x1b[0m") else {
return;
};
let px = f.frame(true);
// The padding shows the configured background exactly, in all corners.
for (x, y) in [
(0, 0),
(f.w - 1, 0),
(0, f.h - 1),
(f.w - 1, f.h - 1),
(PAD as usize / 2, f.h / 2),
] {
let p = f.at(&px, x, y);
assert_eq!(p[..3], f.bg, "padding pixel ({x},{y}) = {p:?}");
}
// The text area has non-background pixels in the "hello" cells and they
// are red (palette 1 is a red hue: r dominates).
let hello = f.region(&px, f.cell_rect(0, 0));
let hello_all: Vec<_> = (0..5)
.flat_map(|cx| f.region(&px, f.cell_rect(cx, 0)))
.collect();
assert!(hello_all.iter().any(|p| !is_bg(*p, f.bg)), "text drawn");
assert!(
hello_all
.iter()
.any(|p| p[0] > 150 && u16::from(p[0]) > u16::from(p[1]) + 80),
"red glyph pixels exist"
);
let _ = hello;
// Cells past the text on line 1 are untouched background.
for p in f.region(&px, f.cell_rect(10, 0)) {
assert!(is_bg(p, f.bg), "empty cell is bg: {p:?}");
}
// Palette-green background cells are filled with the exact palette color.
let green = ghostty_vt::color::DEFAULT[2];
let mid = f.cell_rect(1, 1);
let p = f.at_center(&px, mid);
assert_eq!(p[..3], [green.r, green.g, green.b], "cell bg color");
// The block cursor (focused, blink visible) is drawn in the foreground
// color at (3, 1).
let c = f.cell_rect(3, 1);
let p = f.at_center(&px, c);
assert_eq!(p[..3], [255, 255, 255], "cursor block");
// The frame was healthy, and the first scrollbar state was pushed.
assert_eq!(f.renderer.health(), ghostty_renderer::Health::Healthy);
assert!(!f.host.scrollbars.lock().unwrap().is_empty());
}
#[test]
fn headless_full_renderer_updates_incrementally_and_blinks() {
let Some(f) = Fixture::new("hi") else {
return;
};
let px = f.frame(true);
// The cursor is at (2, 0).
let c = f.cell_rect(2, 0);
let center = |px: &[u8]| f.at_center(px, c);
assert_eq!(center(&px)[..3], [255, 255, 255]);
// Blink off: the cursor disappears (partial/no-op row rebuild keeps
// the rest of the screen).
{
let mut st = f.state.lock().unwrap();
let mut s = st.terminal.vt_stream();
// Make the terminal's cursor blink so blink_visible=false hides it.
s.next_slice(b"\x1b[?12h").unwrap();
}
let px = f.frame(false);
assert_eq!(center(&px)[..3], f.bg, "hidden blinking cursor shows bg");
let hi = (0..2)
.flat_map(|cx| f.region(&px, f.cell_rect(cx, 0)))
.any(|p| !is_bg(p, f.bg));
assert!(hi, "existing text survives a rebuild");
// New output only dirties its own row; the old row stays.
{
let mut st = f.state.lock().unwrap();
let mut s = st.terminal.vt_stream();
s.next_slice(b"\r\n\x1b[1;32mbold\x1b[0m").unwrap();
}
let px = f.frame(true);
assert!(
(0..4)
.flat_map(|cx| f.region(&px, f.cell_rect(cx, 1)))
.any(|p| !is_bg(p, f.bg)),
"second line drawn"
);
assert!(
(0..2)
.flat_map(|cx| f.region(&px, f.cell_rect(cx, 0)))
.any(|p| !is_bg(p, f.bg)),
"first line still drawn"
);
}
#[test]
fn headless_full_renderer_selection_and_underline() {
// Underlined text on row 0 and a selected region.
let Some(f) = Fixture::new("\x1b[4mab\x1b[0m cd") else {
return;
};
let px = f.frame(false);
// The underline sits near the bottom of the cell: some non-bg pixel in
// the lower third of cell (0,0) that is a full-width line.
let r = f.cell_rect(0, 0);
let low = (r.0, r.1 + (r.3 - r.1) * 2 / 3, r.2, r.3);
assert!(
f.region(&px, low).iter().any(|p| !is_bg(*p, f.bg)),
"underline drawn"
);
}
#[test]
fn headless_full_renderer_resize_and_font_change() {
let Some(f) = Fixture::new("resize") else {
return;
};
let _ = f.frame(true);
// A screen size change (padding only) is accepted without a GL error.
f.renderer.set_screen_size(Size {
screen: ScreenSize {
width: f.w as u32,
height: f.h as u32,
},
cell: f.cell,
padding: Padding {
top: PAD,
bottom: PAD,
left: PAD,
right: PAD,
},
});
f.renderer.mark_dirty();
let px = f.frame(true);
assert_eq!(f.at(&px, 0, 0)[..3], f.bg);
assert_eq!(f.renderer.health(), ghostty_renderer::Health::Healthy);
}
#[test]
fn headless_full_renderer_survives_unrealize_realize() {
let Some(f) = Fixture::new("realize") else {
return;
};
let _ = f.frame(true);
// GTK destroys and recreates the GL objects when the widget is
// unrealized/realized (e.g. moved between windows).
f.renderer.display_unrealized();
assert!(matches!(
f.renderer.draw_frame(true),
Err(ghostty_renderer::generic::Error::Defunct)
));
f.renderer
.display_realized(|n| f._h.proc_address(n))
.unwrap();
// The new context needs a viewport again in the surfaceless case.
let gl = Gl::clone(&f.gl);
gl.viewport(0, 0, f.w as i32, f.h as i32).unwrap();
let px = f.frame(true);
assert_eq!(f.at(&px, 0, 0)[..3], f.bg);
assert!(
(0..7)
.flat_map(|cx| f.region(&px, f.cell_rect(cx, 0)))
.any(|p| !is_bg(p, f.bg)),
"text is drawn again after realize"
);
assert_eq!(f.renderer.health(), ghostty_renderer::Health::Healthy);
}
#[test]
fn headless_renderer_thread_updates_and_requests_draws() {
use ghostty_renderer::message::Message;
use ghostty_renderer::thread::Thread;
use std::time::{Duration, Instant};
let Some(f) = Fixture::new("thread") else {
return;
};
let wait_for = |n: usize| {
let end = Instant::now() + Duration::from_secs(5);
while f.host.redraws.load(Ordering::SeqCst) < n {
assert!(Instant::now() < end, "timed out waiting for redraw {n}");
std::thread::sleep(Duration::from_millis(5));
}
};
let thread = Thread::new(
&Config::default(),
f.renderer.clone(),
f.state.clone(),
f.host.clone(),
);
let running = thread.spawn().unwrap();
// The initial wakeup renders (update_frame) and asks the apprt
// (here: this test, standing in for the GTK main thread) to draw.
wait_for(1);
f.renderer.draw_frame(true).unwrap();
let (w, h, px) = f.renderer.last_frame_pixels().unwrap();
assert_eq!((w, h), (f.w, f.h));
assert_eq!(f.at(&px, 0, 0)[..3], f.bg);
assert!(
(0..6)
.flat_map(|cx| f.region(&px, f.cell_rect(cx, 0)))
.any(|p| !is_bg(p, f.bg)),
"thread-rendered text is visible"
);
// Messages are processed and cause another update + draw request.
let before = f.host.redraws.load(Ordering::SeqCst);
assert!(running.handle.send(Message::Focus(false)));
wait_for(before + 1);
// Unfocused: the hollow block cursor is drawn at (6, 0); its center
// stays background but its left edge is foreground.
{
let mut st = f.state.lock().unwrap();
st.terminal.vt_stream().next_slice(b"!").unwrap();
}
running.handle.wakeup();
wait_for(before + 2);
f.renderer.draw_frame(true).unwrap();
let (_, _, px) = f.renderer.last_frame_pixels().unwrap();
let c = f.cell_rect(7, 0);
assert_eq!(
f.at_center(&px, c)[..3],
f.bg,
"hollow cursor is empty in the middle"
);
assert!(
f.region(&px, c).iter().any(|p| !is_bg(*p, f.bg)),
"hollow cursor outline is drawn"
);
running.stop_and_join();
}
}
+121
View File
@@ -0,0 +1,121 @@
# ghostty-renderer — R2 (generic renderer, cells, links, images, overlay, state, thread)
Builds on R1 (`ghostty-renderer-R1.md`: OpenGL backend, shaders, shadertoy, size, cursor).
Deps re-added: `ghostty-terminal`, `ghostty-config`, `ghostty-input`, `ghostty-unicode`, `ghostty-vt`,
`ghostty-datastruct` (mailbox queue), `crossbeam-channel`, `onig` (link regexes), `png` + `jpeg-decoder`
(background image; replaces wuffs). No new `unsafe` (still only `opengl::gl`).
## Module map (Zig -> Rust, `crates/ghostty-renderer/src/`)
| Zig | Rust | notes |
|---|---|---|
| generic.zig | `generic.rs` | `Renderer`, `DerivedConfig`, `Host` trait, `Error`, `NullHost`, `update_frame`, `draw_frame`, rebuild_cells/row, cursor, preedit, decorations, custom-shader uniforms, bg image |
| renderer.zig | `lib.rs` | `pub type Renderer = generic::Renderer` (OpenGL), `DerivedConfig`, `Thread`, `Message`, `State` re-exports |
| cell.zig | `cell.rs` | `Contents`, `Key`, `is_covering`, `is_symbol`, `constraint_width`, `no_min_contrast` |
| row.zig | `row.rs` | `never_extend_bg` |
| link.zig | `link.rs` | `Set::from_config`, `Set::render_cell_map` (onig, uses `RenderState::string` + byte map) |
| State.zig | `state.rs` | `State`, `SharedState = Arc<Mutex<State>>`, `Mouse`, `Preedit`(+`range`) |
| Overlay.zig | `overlay.rs` | CPU RGBA canvas instead of z2d (only axis-aligned rects) |
| image.zig | `image.rs` | `State`, `Image` state machine, `Placement`, `Id`, `Pending`, kitty placement/virtual placement building |
| Thread.zig | `thread.rs` | `Thread`, `Handle`, `Running`, `Mailbox`, `DerivedConfig` |
| message.zig | `message.rs` | stubs from R1 replaced by real types |
| cursor.zig | `cursor.rs` | now takes `&RenderState`; the 4 Zig tests drive a real Terminal |
| Options.zig | `options.rs` | `Options<C>{config, font_grid, size, host}` |
## How the core Surface creates and drives the renderer
```rust
use ghostty_renderer::{generic::{Host, Renderer, DerivedConfig}, options::Options, opengl::OpenGL,
state::State, thread::Thread, message::Message};
// 1. In the GLArea `realize` handler (GL context current on the GTK thread):
let mut api = OpenGL::init(config.alpha_blending);
api.prepare_context(|name| gl_get_proc_address(name))?; // Zig Renderer.surfaceInit
let (key, grid) = font_set.ref_(&font_derived_config, font_size)?; // SharedGridSet
let renderer = Arc::new(Renderer::new(
Options { config: DerivedConfig::new(&config)?, font_grid: grid, size, host: host.clone() },
api)?); // creates buffers/textures/shaders: GL thread only
// 2. Shared state (replaces Zig's `renderer.State` + `renderer_state.mutex`):
let state: SharedState = State::new(terminal).into_shared(); // termio locks it to write the terminal
// state.preedit / state.mouse / state.overlay_features are set by the Surface under the same lock.
// 3. Thread:
let thread = Thread::new(&config, renderer.clone(), state.clone(), host.clone());
let running = thread.spawn()?; // `running.handle` is Clone + Send
let handle = running.handle.clone();
```
* `Host` (implemented by core/apprt, `Send + Sync`) is the "surface mailbox": `scrollbar(Scrollbar) -> bool`
(instant push; return false if full, the renderer retries next frame), `renderer_health(Health)`, and
`redraw_surface() -> bool` (renderer thread -> apprt: "please call `draw_frame` on the main thread").
Implementations must not block: `scrollbar` is called from `draw_frame` with the draw mutex held.
`generic::NullHost` discards everything.
* Wake the thread after changing terminal state (termio does `handle.wakeup()` after parsing, Zig `renderer_thread.wakeup.notify()`).
* Messages: `handle.send(Message::..)` = push(forever) + wakeup; `handle.push(msg, Timeout)` without wakeup.
`Focus(bool)`, `Visible(bool)`, `ResetCursorBlink`, `Resize(Size)`, `FontGrid(Box<FontGridChange{grid, set, new_key, old_key}>)`
(thread calls `set.deref(&old_key)` after adopting), `ChangeConfig` (`Message::init_change_config(&Config)?` builds both derived configs),
`SearchViewportMatches(Vec<Flattened>)`, `SearchSelectedMatch(Option<Flattened>)`, `Inspector(bool)`, `Crash` (panics on purpose).
`Handle::draw_now()`, `Handle::stop()`.
* GTK drawing: in the GLArea `render` signal handler call `renderer.draw_frame(false)` (`true` = sync; the apprt/Surface
uses `true` right after init and on resize like Zig `Surface.zig:869`). In the `redraw_surface` host callback call
`glarea.queue_render()`. On GLArea `unrealize` call `renderer.display_unrealized()` (context current) and on the next
`realize` `renderer.display_realized(loader)`; then draw with `sync = true`.
* Shutdown: `running.stop_and_join()` (thread drops its `Arc<Renderer>`), then drop the last `Arc<Renderer>` on the GL thread
with the context current (it deletes GL objects); `font_set.deref(&key)`.
* Debug/screenshot: `renderer.last_frame_pixels()` (RGBA8, bottom-up) reads the last presented target.
## Threading model (documented in `generic.rs` and `thread.rs` module docs)
GTK GLArea's context is only current on the main thread, so (as in Ghostty, `must_draw_from_app_thread = true`):
* renderer thread: owns timers (cursor blink 600ms; 8ms draw timer only when custom shaders animate), drains the mailbox,
and per wakeup runs `Renderer::update_frame` (lock `SharedState`, `RenderState::update`, OSC8/regex links, search highlights, overlay,
shaping with the harfbuzz shaper + cache, glyph rendering into the font atlases via `SharedGrid`, rebuild CPU `cell::Contents`, uniforms)
and then `Host::redraw_surface()`. It performs **no GL calls**.
* main thread: `Renderer::draw_frame` uploads cells/uniforms/atlases/images/bg image, runs the render passes (bg image or bg color,
kitty below-bg, cell bg, kitty below-text, text, kitty above-text, overlay, custom shader post passes), `glFinish`s and presents.
* State split (no unsafe): `draw: Mutex<DrawState>` (Zig draw_mutex: what `draw_frame` reads) and `thread: Mutex<ThreadState>`
(RenderState, shaper, cache, overlay, search; uncontended, only there for `Sync`). Lock order: `SharedState` (terminal) is never held
while waiting for `thread`; `thread` -> `draw` -> font grid lock. `Thread::draw_on_app_thread = false` makes the thread call `draw_frame`
itself (needs the context current on that thread; used for headless use).
* The swap chain keeps Zig's structure (`SWAP_CHAIN_COUNT = 1`) minus the semaphore: frames complete synchronously and the draw mutex serializes.
## Tests
* Ported: cell.zig 6/6 (`Contents`, `clear retains other content`, `clear last added content`, `zero-sized screen`, `Cell constraint widths`
now with real Terminal+RenderState; +`reset` and helper tests), link.zig 3/3, cursor.zig 4/4 (real Terminal) + password test,
row.zig (none upstream; 4 added), State.zig (none upstream; 4 preedit tests added), Overlay.zig (none upstream; 4 added),
image.zig (none upstream; 7 added incl. a real `ImageStorage` -> `kitty_update` test), generic.zig (none upstream; 6 added: derived config,
color resolution, bold policy, file types, PNG bg image round trip), thread.rs (3).
Totals: 69 unit tests + 9 integration tests in `tests/egl_headless.rs` (3 from R1), all pass.
* New EGL integration tests (surfaceless Mesa GL 4.3): full path Terminal -> RenderState -> `Renderer` -> offscreen target ->
`last_frame_pixels`: exact bg color at all four padding corners and the side padding, red glyph pixels in "hello", non-bg glyph pixels,
exact palette color for cell backgrounds (SGR 42), block cursor drawn in the foreground color, blink-off hides the cursor, incremental
row rebuild, underline sprite drawn, resize/mark_dirty, unrealize -> `Defunct` -> realize -> draw again, and a real `Thread`
(initial wakeup renders and requests a draw, Focus message -> hollow cursor outline with empty center, stop/join).
* Skipped: none of the Zig tests (Metal-only/allocator-failure ones don't exist in these files).
## Deviations
* Run iterator wants `&[RunCell]` (owned `Vec<u32>` graphemes); each rebuilt row copies `RenderState` cells into a reusable scratch
`Vec<RunCell>` (only cells with graphemes allocate). Shaped cells are copied from the shaper/cache into a scratch vec.
* `renderer.State`: one mutex-protected struct holding `terminal`, `preedit`, `mouse`, `overlay_features`
(inspector deferred: `inspector: ?*Inspector` replaced by `overlay_features`). Zig `*Mutex` + pointers -> `SharedState`.
* Overlay: no z2d; direct RGBA rasterization (fill rect + 1px hairline border, src-over). Hairline placement is approximate.
* Background image decoding: `png` / `jpeg-decoder` instead of wuffs (png/jpeg only, same as Zig; other types warn).
* Zig `cursor_text.color` / `selection_*.color` custom-shader uniforms read the union payload unconditionally (invalid for `cell-*`);
Rust only sets them for explicit colors.
* Zero-length regex matches advance by one character (Zig would loop forever).
* Atlas textures in `FrameState` start 2x2 (not 1x1: `Atlas::new` keeps a 1px border); replaced on first sync.
* `opengl::AlphaBlending` is now `ghostty_config::types_b::AlphaBlending` (R1 stub removed). `message.rs`: real derived-config types.
* `display_unrealized` also resets the image state and bg image (their textures die with the GL context); forces a kitty update.
* `Host::renderer_health` is called while holding the draw mutex (Zig pushes `.forever` too); keep it non-blocking.
* macOS-only paths (display link, glass blur, QoS) omitted.
## TODO(integration)
* **kitty graphics**: `Screen.kitty_images` is still the placeholder `KittyImages { dirty }` in ghostty-terminal. `image::State::kitty_update(&ImageStorage,
&PageList, &TerminalInfo, CellSize)` is implemented and tested against the real storage, but `generic::update_kitty` cannot call it yet;
once K1's step 1 lands (screen.rs holds `ImageStorage`), replace the body with the call and clear `storage.dirty`. Until then images
never appear.
* Unify `RendererState` with `ghostty-termio::state` (INTEGRATION_TODO): the canonical type is now `ghostty_renderer::state::State`/`SharedState`.
* `ghostty_font::shared_grid::CellSize` still stands in for `renderer::CellSize` (converted by hand at the call site).
* apprt: `surface::Scrollbar` / `RendererHealth` stand-ins should be replaced by `ghostty_terminal::page_list::Scrollbar` and `ghostty_renderer::Health`
(what `Host` uses).
* apprt-gtk: GLArea render handler must set the GL viewport (the renderer reads `GL_VIEWPORT` for the surface size, R1) and the
present blit goes to the framebuffer bound as draw FBO when `draw_frame` is called (GLArea binds its own FBO before `render`).
* Inspector (Phase 7) fills `State::overlay_features`; `Message::Inspector` only records a flag.