Files
ghostty-rs/crates/ghostty-renderer/src/size.rs
T
ddidderr 568ce784f9 feat(renderer): port the OpenGL backend, shaders and size math
Ports the terminal-independent half of ZIG/src/renderer (OpenGL only):
- opengl/*: buffers, frames, pipelines, render passes, samplers,
  targets, textures and the OpenGL GraphicsAPI backend on top of glow;
  the GTK GLArea glue is replaced by a get_proc_address loader closure
- the GLSL shaders, copied verbatim and embedded; uniform/vertex structs
  as bytemuck Pod types with std140 offsets asserted in tests
- shadertoy custom shaders converted with naga (GLSL in -> validate ->
  GLSL 430 out) instead of glslang + spirv-cross
- size (screen/grid/cell/padding math), cursor style logic, backend
  health, options and message types

The crate opts into unsafe_code = "deny"; unsafe glow calls live only in
opengl/gl.rs. A headless EGL integration test builds every pipeline and
the converted shadertoy shaders on Mesa and reads back rendered pixels.

Note: Zig's shadertoy Uniforms order the selection background/foreground
colors opposite to the GLSL block (likely an upstream bug); kept as in
Zig for parity and recorded in the notes.

Deferred to the next renderer pass: generic, cell, image, link, row,
State, Thread, Overlay (need terminal render state).

Test Plan:
- cargo test -p ghostty-renderer (33 tests incl. tests/egl_headless.rs)
2026-10-11 22:26:09 +02:00

499 lines
16 KiB
Rust

//! Screen/grid/cell/padding size math (`renderer/size.zig`).
/// Terminal cell count integer (`terminal.size.CellCountInt`).
// TODO(integration): re-export from ghostty-terminal once it is a dependency.
pub type CellCountInt = u16;
/// All relevant sizes for a rendered terminal. These are all the sizes that
/// any functionality should need to know about the terminal in order to
/// convert between any coordinate systems.
///
/// See the individual field type documentation for more information on each
/// field. One important note is that any pixel values should already be scaled
/// to the current DPI of the screen. If the DPI changes, the sizes should be
/// recalculated and we expect this to be done by the caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Size {
pub screen: ScreenSize,
pub cell: CellSize,
pub padding: Padding,
}
impl Size {
/// Return the grid size for this size. The grid size is calculated by
/// taking the screen size, removing padding, and dividing by the cell
/// dimensions.
pub fn grid(&self) -> GridSize {
GridSize::init(self.screen.sub_padding(self.padding), self.cell)
}
/// The size of the terminal. This is the same as the screen without
/// padding.
pub fn terminal(&self) -> ScreenSize {
self.screen.sub_padding(self.padding)
}
/// Set the padding to be balanced around the grid. The balanced
/// padding is calculated AFTER the explicit padding is taken
/// into account.
pub fn balance_padding(&mut self, explicit: Padding) {
// This ensure grid() does the right thing
self.padding = explicit;
// Now we can calculate the balanced padding
self.padding = Padding::balanced(self.screen, self.grid(), self.cell);
// The top/bottom padding is interesting. Subjectively, lots of padding
// at the top looks bad. So instead of always being equal (like left/right),
// we force the top padding to be at most equal to the maximum left padding,
// which is the balanced explicit horizontal padding plus half a cell width.
let max_padding_left = (explicit.left + explicit.right + self.cell.width) / 2;
let vshift = self.padding.top.saturating_sub(max_padding_left);
self.padding.top -= vshift;
self.padding.bottom += vshift;
}
}
/// A coordinate. This is defined as a tagged union to allow for different
/// coordinate systems to be represented.
///
/// A coordinate is only valid within the context of a stable Size value.
/// If any of the sizes in the Size struct change, the coordinate is no
/// longer valid and must be recalculated. A conversion function is provided
/// to migrate to a new Size (which may result in failure).
///
/// The coordinate systems are:
///
/// * surface: (0, 0) is the top-left of the surface (with padding). Negative
/// values are allowed and are off the surface. Likewise, values greater
/// than the surface size are off the surface. Units are pixels.
///
/// * terminal: (0, 0) is the top-left of the terminal grid. This is the
/// same as the surface but with the padding removed. Negative values and
/// values greater than the grid size are allowed and are off the terminal.
/// Units are pixels.
///
/// * grid: (0, 0) is the top-left of the grid. Units are in cells. Negative
/// values are not allowed but values greater than the grid size are
/// possible and are off the grid.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Coordinate {
Surface(SurfaceCoord),
Terminal(TerminalCoord),
Grid(GridCoord),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoordinateTag {
Surface,
Terminal,
Grid,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SurfaceCoord {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TerminalCoord {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GridCoord {
pub x: CellCountInt,
pub y: CellCountInt,
}
impl Coordinate {
pub fn tag(&self) -> CoordinateTag {
match self {
Coordinate::Surface(_) => CoordinateTag::Surface,
Coordinate::Terminal(_) => CoordinateTag::Terminal,
Coordinate::Grid(_) => CoordinateTag::Grid,
}
}
/// Convert a coordinate to a different space within the same Size.
pub fn convert(self, to: CoordinateTag, size: Size) -> Coordinate {
// Unlikely fast-path but avoid work.
if self.tag() == to {
return self;
}
// To avoid the combinatorial explosion of conversion functions, we
// convert to the surface system first and then reconvert from there.
let surface = self.convert_to_surface(size);
match to {
CoordinateTag::Surface => Coordinate::Surface(surface),
CoordinateTag::Terminal => Coordinate::Terminal(TerminalCoord {
x: surface.x - f64::from(size.padding.left),
y: surface.y - f64::from(size.padding.top),
}),
CoordinateTag::Grid => {
// Get rid of the padding.
let Coordinate::Terminal(term) =
Coordinate::Surface(surface).convert(CoordinateTag::Terminal, size)
else {
unreachable!()
};
// We need our grid to clamp
let grid = size.grid();
// Calculate the grid position.
let cell_width = f64::from(size.cell.width);
let cell_height = f64::from(size.cell.height);
let clamped_x = term.x.max(0.0);
let clamped_y = term.y.max(0.0);
let col = (clamped_x / cell_width) as CellCountInt;
let row = (clamped_y / cell_height) as CellCountInt;
let clamped_col = col.min(grid.columns - 1);
let clamped_row = row.min(grid.rows - 1);
Coordinate::Grid(GridCoord {
x: clamped_col,
y: clamped_row,
})
}
}
}
/// Convert a coordinate to the surface coordinate system.
fn convert_to_surface(self, size: Size) -> SurfaceCoord {
match self {
Coordinate::Surface(v) => v,
Coordinate::Terminal(v) => SurfaceCoord {
x: v.x + f64::from(size.padding.left),
y: v.y + f64::from(size.padding.top),
},
Coordinate::Grid(v) => {
let col = f64::from(v.x);
let row = f64::from(v.y);
let cell_width = f64::from(size.cell.width);
let cell_height = f64::from(size.cell.height);
let padding_left = f64::from(size.padding.left);
let padding_top = f64::from(size.padding.top);
SurfaceCoord {
x: col * cell_width + padding_left,
y: row * cell_height + padding_top,
}
}
}
}
}
/// The dimensions of a single "cell" in the terminal grid.
///
/// The dimensions are dependent on the current loaded set of font glyphs.
/// We calculate the width based on the widest character and the height based
/// on the height requirement for an underscore (the "lowest" -- visually --
/// character).
///
/// The units for the width and height are in world space. They have to
/// be normalized for any renderer implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CellSize {
pub width: u32,
pub height: u32,
}
/// The dimensions of the screen that the grid is rendered to. This is the
/// terminal screen, so it is likely a subset of the window size. The dimensions
/// should be in pixels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ScreenSize {
pub width: u32,
pub height: u32,
}
impl ScreenSize {
/// Subtract padding from the screen size.
pub fn sub_padding(self, padding: Padding) -> ScreenSize {
ScreenSize {
width: self.width.saturating_sub(padding.left + padding.right),
height: self.height.saturating_sub(padding.top + padding.bottom),
}
}
/// Calculates the amount of blank space around the grid. This is possible
/// when padding isn't balanced.
///
/// The "self" screen size here should be the unpadded screen.
pub fn blank_padding(self, padding: Padding, grid: GridSize, cell: CellSize) -> Padding {
let grid_width = u32::from(grid.columns) * cell.width;
let grid_height = u32::from(grid.rows) * cell.height;
let padded_width = grid_width + (padding.left + padding.right);
let padded_height = grid_height + (padding.top + padding.bottom);
// Note these have to use a saturating subtraction to avoid underflow
// because our padding can cause the padded sizes to be larger than
// our real screen if the screen is shrunk to a minimal size such
// as 1x1.
let leftover_width = self.width.saturating_sub(padded_width);
let leftover_height = self.height.saturating_sub(padded_height);
Padding {
top: 0,
bottom: leftover_height,
right: leftover_width,
left: 0,
}
}
/// Returns true if two sizes are equal.
pub fn equals(self, other: ScreenSize) -> bool {
self == other
}
}
/// The dimensions of the grid itself, in rows/columns units.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GridSize {
pub columns: CellCountInt,
pub rows: CellCountInt,
}
impl GridSize {
/// Initialize a grid size based on a screen and cell size.
pub fn init(screen: ScreenSize, cell: CellSize) -> GridSize {
let mut result = GridSize::default();
result.update(screen, cell);
result
}
/// Update the columns/rows for the grid based on the given screen and
/// cell size.
pub fn update(&mut self, screen: ScreenSize, cell: CellSize) {
let cell_width = cell.width as f32;
let cell_height = cell.height as f32;
let screen_width = screen.width as f32;
let screen_height = screen.height as f32;
let calc_cols = (screen_width / cell_width) as CellCountInt;
let calc_rows = (screen_height / cell_height) as CellCountInt;
self.columns = calc_cols.max(1);
self.rows = calc_rows.max(1);
}
/// Returns true if two sizes are equal.
pub fn equals(self, other: GridSize) -> bool {
self == other
}
}
/// The padding to add to a screen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Padding {
pub top: u32,
pub bottom: u32,
pub right: u32,
pub left: u32,
}
impl Padding {
/// Returns padding that balances the whitespace around the screen
/// for the given grid and cell sizes.
pub fn balanced(screen: ScreenSize, grid: GridSize, cell: CellSize) -> Padding {
// Turn our cell sizes into floats for the math
let cell_width = cell.width as f32;
let cell_height = cell.height as f32;
// The size of our full grid
let grid_width = f32::from(grid.columns) * cell_width;
let grid_height = f32::from(grid.rows) * cell_height;
// The empty space to the right of a line and bottom of the last row
let space_right = screen.width as f32 - grid_width;
let space_bot = screen.height as f32 - grid_height;
// The padding is split equally along both axes.
let padding_right = (space_right / 2.0).floor();
let padding_left = padding_right;
let padding_bot = (space_bot / 2.0).floor();
let padding_top = padding_bot;
let zero = 0.0f32;
Padding {
top: padding_top.max(zero) as u32,
bottom: padding_bot.max(zero) as u32,
right: padding_right.max(zero) as u32,
left: padding_left.max(zero) as u32,
}
}
/// Equality test between two paddings.
pub fn eql(self, other: Padding) -> bool {
self == other
}
}
/// Add another padding to this one (`Padding.add`).
impl std::ops::Add for Padding {
type Output = Padding;
fn add(self, other: Padding) -> Padding {
Padding {
top: self.top + other.top,
bottom: self.bottom + other.bottom,
right: self.right + other.right,
left: self.left + other.left,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn padding_balanced_on_zero() {
// On some systems, our screen can be zero-sized for a bit, and we
// don't want to end up with negative padding.
let grid = GridSize {
columns: 100,
rows: 37,
};
let cell = CellSize {
width: 10,
height: 20,
};
let screen = ScreenSize {
width: 0,
height: 0,
};
let padding = Padding::balanced(screen, grid, cell);
assert_eq!(Padding::default(), padding);
}
#[test]
fn grid_size_update_exact() {
let mut grid = GridSize::default();
grid.update(
ScreenSize {
width: 100,
height: 40,
},
CellSize {
width: 5,
height: 10,
},
);
assert_eq!(20, grid.columns);
assert_eq!(4, grid.rows);
}
#[test]
fn grid_size_update_rounding() {
let mut grid = GridSize::default();
grid.update(
ScreenSize {
width: 20,
height: 40,
},
CellSize {
width: 6,
height: 15,
},
);
assert_eq!(3, grid.columns);
assert_eq!(2, grid.rows);
}
#[test]
fn coordinate_conversion() {
// A size for testing purposes. Purposely easy to calculate numbers.
let test_size = Size {
screen: ScreenSize {
width: 100,
height: 100,
},
cell: CellSize {
width: 5,
height: 10,
},
padding: Padding::default(),
};
let g = |x, y| Coordinate::Grid(GridCoord { x, y });
let s = |x, y| Coordinate::Surface(SurfaceCoord { x, y });
let table = [
(g(0, 0), s(0.0, 0.0)),
(g(1, 0), s(6.0, 0.0)),
(g(1, 1), s(6.0, 10.0)),
(g(0, 0), s(-10.0, -10.0)),
(
g(test_size.grid().columns - 1, test_size.grid().rows - 1),
s(100_000.0, 100_000.0),
),
];
for (expected, input) in table {
let actual = input.convert(expected.tag(), test_size);
assert_eq!(expected, actual);
}
}
#[test]
fn balance_padding_keeps_top_at_most_max_left() {
let mut size = Size {
screen: ScreenSize {
width: 105,
height: 200,
},
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding::default(),
};
size.balance_padding(Padding::default());
// grid is 10x10 -> 100x200; leftover width 5 => left 2, right 2.
assert_eq!(size.padding.left, 2);
assert_eq!(size.padding.top, 0);
}
#[test]
fn padding_add() {
let a = Padding {
top: 1,
bottom: 2,
right: 3,
left: 4,
};
let sum = a + a;
assert_eq!((sum.top, sum.bottom, sum.right, sum.left), (2, 4, 6, 8));
assert!(sum.eql(a + a));
}
#[test]
fn terminal_and_blank_padding() {
let size = Size {
screen: ScreenSize {
width: 110,
height: 70,
},
cell: CellSize {
width: 10,
height: 20,
},
padding: Padding {
top: 5,
bottom: 5,
left: 5,
right: 5,
},
};
let term = size.terminal();
assert_eq!((term.width, term.height), (100, 60));
let grid = size.grid();
assert_eq!((grid.columns, grid.rows), (10, 3));
let blank = term.blank_padding(size.padding, grid, size.cell);
// 100 - (100 + 10) saturates to 0; 60 - (60 + 10) saturates to 0.
assert_eq!(blank, Padding::default());
}
}