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:
1 parent
687752c650
commit
871c301713
16 files changed
+7491
-128
No files matched your search
@@ -6,11 +6,21 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bytemuck = { version = "1.25.2", features = ["derive"] }
|
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-font.workspace = true
|
||||||
|
ghostty-input.workspace = true
|
||||||
|
ghostty-terminal.workspace = true
|
||||||
|
ghostty-unicode.workspace = true
|
||||||
|
ghostty-vt.workspace = true
|
||||||
glow = "0.18.0"
|
glow = "0.18.0"
|
||||||
|
jpeg-decoder = { version = "0.3.2", default-features = false }
|
||||||
khronos-egl = { version = "6.0.0", features = ["dynamic"] }
|
khronos-egl = { version = "6.0.0", features = ["dynamic"] }
|
||||||
log = "0.4.34"
|
log = "0.4.34"
|
||||||
naga = { version = "30.0.1", features = ["glsl-in", "glsl-out", "wgsl-in"] }
|
naga = { version = "30.0.1", features = ["glsl-in", "glsl-out", "wgsl-in"] }
|
||||||
|
onig = "6.5.3"
|
||||||
|
png = "0.18.1"
|
||||||
thiserror = "2.0.21"
|
thiserror = "2.0.21"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +1,9 @@
|
|||||||
//! Cursor style decision logic (`renderer/cursor.zig`).
|
//! Cursor style decision logic (`renderer/cursor.zig`).
|
||||||
|
|
||||||
/// Terminal cursor styles (`terminal.CursorStyle`).
|
use ghostty_terminal::render::RenderState;
|
||||||
// TODO(integration): replace with ghostty_terminal's CursorStyle.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum TerminalCursorStyle {
|
|
||||||
Bar,
|
|
||||||
#[default]
|
|
||||||
Block,
|
|
||||||
BlockHollow,
|
|
||||||
Underline,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The subset of `terminal.RenderState.cursor` that the cursor style
|
/// Terminal cursor styles (`terminal.CursorStyle`).
|
||||||
/// decision needs.
|
pub type TerminalCursorStyle = ghostty_terminal::cursor::Style;
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Available cursor styles for drawing that renderers must support.
|
/// Available cursor styles for drawing that renderers must support.
|
||||||
/// This is a superset of terminal cursor styles since the renderer supports
|
/// 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
|
/// Returns the cursor style to use for the current render state or None
|
||||||
/// if a cursor should not be rendered at all.
|
/// 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
|
// Note the order of conditionals below is important. It represents
|
||||||
// a priority system of how we determine what state overrides cursor
|
// a priority system of how we determine what state overrides cursor
|
||||||
// visibility and style.
|
// visibility and style.
|
||||||
|
|
||||||
// The cursor must be visible in the viewport to be rendered.
|
// 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
|
// 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
|
// 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 we're at a password input its always a lock.
|
||||||
if state.password_input {
|
if state.cursor.password_input {
|
||||||
return Some(Style::Lock);
|
return Some(Style::Lock);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the cursor is explicitly not visible by terminal mode, we don't render.
|
// If the cursor is explicitly not visible by terminal mode, we don't render.
|
||||||
if !state.visible {
|
if !state.cursor.visible {
|
||||||
return None;
|
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,
|
// If the cursor is blinking and our blink state is not visible,
|
||||||
// then we don't show the cursor.
|
// then we don't show the cursor.
|
||||||
if state.blinking && !opts.blink_visible {
|
if state.cursor.blinking && !opts.blink_visible {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, we use whatever style the terminal wants.
|
// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use ghostty_terminal::terminal::{Options, ScrollViewport, Terminal};
|
||||||
// The Zig tests drive a real Terminal + RenderState; until
|
use ghostty_vt::modes::Mode;
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn o(preedit: bool, focused: bool, blink_visible: bool) -> StyleOptions {
|
fn o(preedit: bool, focused: bool, blink_visible: bool) -> StyleOptions {
|
||||||
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]
|
#[test]
|
||||||
fn cursor_default_uses_configured_style() {
|
fn cursor_default_uses_configured_style() {
|
||||||
let s = state(true, true);
|
let mut term = term();
|
||||||
assert_eq!(style(&s, o(false, true, true)), Some(Style::Bar));
|
term.screen_mut().cursor.cursor_style = TerminalCursorStyle::Bar;
|
||||||
assert_eq!(style(&s, o(false, false, true)), Some(Style::BlockHollow));
|
term.modes.set(Mode::CursorBlinking, true);
|
||||||
assert_eq!(style(&s, o(false, false, false)), Some(Style::BlockHollow));
|
|
||||||
assert_eq!(style(&s, o(false, true, false)), None);
|
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]
|
#[test]
|
||||||
fn cursor_blinking_disabled() {
|
fn cursor_blinking_disabled() {
|
||||||
let s = state(true, false);
|
let mut term = term();
|
||||||
assert_eq!(style(&s, o(false, true, true)), Some(Style::Bar));
|
term.screen_mut().cursor.cursor_style = TerminalCursorStyle::Bar;
|
||||||
assert_eq!(style(&s, o(false, true, false)), Some(Style::Bar));
|
term.modes.set(Mode::CursorBlinking, false);
|
||||||
assert_eq!(style(&s, o(false, false, true)), Some(Style::BlockHollow));
|
|
||||||
assert_eq!(style(&s, o(false, false, false)), Some(Style::BlockHollow));
|
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]
|
#[test]
|
||||||
fn cursor_explicitly_not_visible() {
|
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 focused in [true, false] {
|
||||||
for blink 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]
|
#[test]
|
||||||
fn cursor_always_block_with_preedit() {
|
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 focused in [true, false] {
|
||||||
for blink 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.
|
// 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 focused in [true, false] {
|
||||||
for blink 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]
|
#[test]
|
||||||
fn cursor_password_input_is_lock() {
|
fn cursor_password_input_is_lock() {
|
||||||
let mut s = state(false, true);
|
let mut term = term();
|
||||||
s.password_input = true;
|
term.flags.password_input = true;
|
||||||
assert_eq!(style(&s, o(false, true, true)), Some(Style::Lock));
|
let mut state = RenderState::new();
|
||||||
assert_eq!(style(&s, o(true, true, true)), Some(Style::Block));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,46 @@
|
|||||||
//! ghostty-renderer: OpenGL renderer building blocks (see
|
//! ghostty-renderer: the OpenGL renderer (see docs/rewrite/ARCHITECTURE.md and
|
||||||
//! docs/rewrite/ARCHITECTURE.md and docs/rewrite/notes/ghostty-renderer-R1.md).
|
//! docs/rewrite/notes/ghostty-renderer-R1.md / -R2.md).
|
||||||
//!
|
//!
|
||||||
//! Terminal-independent parts of `renderer/`: the OpenGL graphics API backend
|
//! * R1: the OpenGL graphics API backend and its GPU object wrappers, shaders
|
||||||
//! and its GPU object wrappers, shaders and their std140 data layouts,
|
//! and their std140 data layouts, shadertoy custom-shader conversion, size
|
||||||
//! shadertoy custom-shader conversion, size math and cursor style logic.
|
//! 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))]
|
#![cfg_attr(test, allow(clippy::unwrap_used))]
|
||||||
|
|
||||||
pub mod backend;
|
pub mod backend;
|
||||||
|
pub mod cell;
|
||||||
pub mod cursor;
|
pub mod cursor;
|
||||||
|
pub mod generic;
|
||||||
|
pub mod image;
|
||||||
|
pub mod link;
|
||||||
pub mod math;
|
pub mod math;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod opengl;
|
pub mod opengl;
|
||||||
pub mod options;
|
pub mod options;
|
||||||
|
pub mod overlay;
|
||||||
|
pub mod row;
|
||||||
pub mod shadertoy;
|
pub mod shadertoy;
|
||||||
pub mod size;
|
pub mod size;
|
||||||
|
pub mod state;
|
||||||
|
pub mod thread;
|
||||||
|
|
||||||
pub use backend::{Backend, Health};
|
pub use backend::{Backend, Health};
|
||||||
pub use cursor::{Style as CursorStyle, style as cursor_style};
|
pub use cursor::{Style as CursorStyle, style as cursor_style};
|
||||||
|
pub use message::Message;
|
||||||
pub use opengl::OpenGL;
|
pub use opengl::OpenGL;
|
||||||
pub use size::{CellSize, Coordinate, GridSize, Padding, ScreenSize, Size};
|
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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,15 @@
|
|||||||
//! Messages that can be sent to a renderer thread (`renderer/message.zig`).
|
//! Messages that can be sent to a renderer thread (`renderer/message.zig`).
|
||||||
use std::sync::Arc;
|
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;
|
use crate::size::Size;
|
||||||
|
use crate::thread::DerivedConfig as ThreadDerivedConfig;
|
||||||
/// 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 {}
|
|
||||||
|
|
||||||
/// The messages that can be sent to a renderer thread.
|
/// The messages that can be sent to a renderer thread.
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
@@ -48,7 +33,7 @@ pub enum Message {
|
|||||||
|
|
||||||
/// Change the font grid. This can happen for any number of reasons
|
/// Change the font grid. This can happen for any number of reasons
|
||||||
/// including a font size change, family change, etc.
|
/// including a font size change, family change, etc.
|
||||||
FontGrid(FontGridChange),
|
FontGrid(Box<FontGridChange>),
|
||||||
|
|
||||||
/// Changes the size. The screen size might change, padding, grid, etc.
|
/// Changes the size. The screen size might change, padding, grid, etc.
|
||||||
Resize(Size),
|
Resize(Size),
|
||||||
@@ -78,6 +63,7 @@ pub enum Message {
|
|||||||
/// Payload of [`Message::FontGrid`].
|
/// Payload of [`Message::FontGrid`].
|
||||||
pub struct FontGridChange {
|
pub struct FontGridChange {
|
||||||
pub grid: Arc<SharedGrid>,
|
pub grid: Arc<SharedGrid>,
|
||||||
|
pub set: Arc<SharedGridSet>,
|
||||||
|
|
||||||
/// The key for the new grid. If adopting the new grid fails for any
|
/// 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
|
/// 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
|
/// After accepting the new grid, the old grid must be dereferenced
|
||||||
/// using this key.
|
/// using this key.
|
||||||
pub old_key: SharedGridKey,
|
pub old_key: SharedGridKey,
|
||||||
// TODO(integration): upstream also carries `set: *SharedGridSet`.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
impl Message {
|
||||||
/// Initialize a change_config message.
|
/// Initialize a change_config message (`initChangeConfig`).
|
||||||
// TODO(integration): `initChangeConfig(alloc, config)` builds the derived
|
pub fn init_change_config(config: &Config) -> Result<Message, link::Error> {
|
||||||
// configs from `config.Config`; add once the derived config types exist.
|
Ok(Message::ChangeConfig {
|
||||||
pub fn init_change_config(
|
thread: Box::new(ThreadDerivedConfig::new(config)),
|
||||||
thread: ThreadDerivedConfig,
|
r#impl: Box::new(RendererDerivedConfig::new(config)?),
|
||||||
r#impl: RendererDerivedConfig,
|
})
|
||||||
) -> Message {
|
|
||||||
Message::ChangeConfig {
|
|
||||||
thread: Box::new(thread),
|
|
||||||
r#impl: Box::new(r#impl),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +91,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn change_config_message_roundtrip() {
|
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 { .. }));
|
assert!(matches!(m, Message::ChangeConfig { .. }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,23 +51,7 @@ pub const MIN_VERSION_MAJOR: u32 = 4;
|
|||||||
pub const MIN_VERSION_MINOR: u32 = 3;
|
pub const MIN_VERSION_MINOR: u32 = 3;
|
||||||
|
|
||||||
/// `config.AlphaBlending`.
|
/// `config.AlphaBlending`.
|
||||||
// TODO(integration): replace with ghostty_config's AlphaBlending.
|
pub use ghostty_config::types_b::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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
@@ -296,6 +280,12 @@ impl OpenGL {
|
|||||||
Ok(())
|
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
|
/// Drop the remembered last-presented target (call when the target it
|
||||||
/// refers to is being replaced, e.g. on resize).
|
/// refers to is being replaced, e.g. on resize).
|
||||||
pub fn clear_last_target(&mut self) {
|
pub fn clear_last_target(&mut self) {
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use ghostty_font::SharedGrid;
|
use ghostty_font::SharedGrid;
|
||||||
|
|
||||||
|
use crate::generic::Host;
|
||||||
use crate::size::Size;
|
use crate::size::Size;
|
||||||
|
|
||||||
/// Options for constructing a renderer.
|
/// Options for constructing a renderer.
|
||||||
///
|
///
|
||||||
/// `config` is the derived renderer configuration (`Renderer.DerivedConfig`).
|
/// `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
|
/// Zig also carries `rt_surface` (used by the graphics API to prepare the
|
||||||
// renderer and the apprt exist; the GL loader closure replaces `rt_surface`.
|
/// 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> {
|
pub struct Options<C> {
|
||||||
/// The derived configuration for this renderer implementation.
|
/// The derived configuration for this renderer implementation.
|
||||||
pub config: C,
|
pub config: C,
|
||||||
@@ -20,4 +23,7 @@ pub struct Options<C> {
|
|||||||
|
|
||||||
/// The size of everything.
|
/// The size of everything.
|
||||||
pub size: Size,
|
pub size: Size,
|
||||||
|
|
||||||
|
/// The surface mailbox / apprt callbacks (Zig: `surface_mailbox`).
|
||||||
|
pub host: Arc<dyn Host>,
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(_))
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in new issue
Block a user