Files
ghostty-rs/crates/ghostty-font/src/metrics.rs
T
ddidderr 04a6b7c97a feat(font): port font metrics, atlas and the sprite font
Ports ZIG/src/font/{Metrics,Atlas,Glyph}.zig and the whole sprite font
(font/sprite/**): box drawing, block elements, braille, branch/git
graph symbols, geometric shapes, powerline, and the symbols for legacy
computing (+ supplement, incl. the octants table).

Ghostty draws sprites with z2d; we use tiny-skia for path coverage,
composited onto an alpha8 canvas, while following the Zig geometry
(positions, thickness, rounding). Comptime discovery of draw functions
is replaced by explicit, overlap-checked codepoint range tables.

Upstream reference sheets are vendored in testdata/ and compared with a
tolerance: braille and U+1CD00 are pixel-identical; other sheets differ
only in antialiasing on thin diagonals/curves. Review renders are written
to target/sprite_sheets/.

The crate temporarily drops its config/unicode/terminal deps until the
rest of the font engine is ported (Phase 3).

Test Plan:
- cargo test -p ghostty-font (49 tests incl. reference sheet comparison)
- inspect target/sprite_sheets/review-all-18x36.png
2026-10-11 21:53:29 +02:00

909 lines
35 KiB
Rust

//! Font metrics for the terminal grid. Port of `font/Metrics.zig`.
use std::collections::HashMap;
use std::str::FromStr;
/// Metrics describing the terminal grid cell for a font.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Metrics {
/// Recommended cell width and height for a monospace grid using this font.
pub cell_width: u32,
pub cell_height: u32,
/// Distance in pixels from the bottom of the cell to the text baseline.
pub cell_baseline: u32,
/// Distance in pixels from the top of the cell to the top of the underline.
pub underline_position: u32,
/// Thickness in pixels of the underline.
pub underline_thickness: u32,
/// Distance in pixels from the top of the cell to the top of the strikethrough.
pub strikethrough_position: u32,
/// Thickness in pixels of the strikethrough.
pub strikethrough_thickness: u32,
/// Distance in pixels from the top of the cell to the top of the overline.
/// Can be negative to adjust the position above the top of the cell.
pub overline_position: i32,
/// Thickness in pixels of the overline.
pub overline_thickness: u32,
/// Thickness in pixels of box drawing characters.
pub box_thickness: u32,
/// The thickness in pixels of the cursor sprite. This has a default value
/// because it is not determined by fonts but rather by user configuration.
pub cursor_thickness: u32,
/// The height in pixels of the cursor sprite.
pub cursor_height: u32,
/// The constraint height for nerd fonts icons.
pub icon_height: f64,
/// The constraint height for nerd fonts icons limited to a single cell width.
pub icon_height_single: f64,
/// The unrounded face width, used in scaling calculations.
pub face_width: f64,
/// The unrounded face height, used in scaling calculations.
pub face_height: f64,
/// The offset from the bottom of the cell to the bottom
/// of the face's bounding box, based on the rounded and
/// potentially adjusted cell height.
pub face_y: f64,
}
/// Minimum acceptable values for some fields to prevent modifiers
/// from being able to, for example, cause 0-thickness underlines.
mod minimums {
pub const CELL_WIDTH: u32 = 1;
pub const CELL_HEIGHT: u32 = 1;
pub const UNDERLINE_THICKNESS: u32 = 1;
pub const STRIKETHROUGH_THICKNESS: u32 = 1;
pub const OVERLINE_THICKNESS: u32 = 1;
pub const BOX_THICKNESS: u32 = 1;
pub const CURSOR_THICKNESS: u32 = 1;
pub const CURSOR_HEIGHT: u32 = 1;
pub const ICON_HEIGHT: f64 = 1.0;
pub const ICON_HEIGHT_SINGLE: f64 = 1.0;
pub const FACE_HEIGHT: f64 = 1.0;
pub const FACE_WIDTH: f64 = 1.0;
}
/// Metrics extracted from a font face, based on
/// the metadata tables and glyph measurements.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct FaceMetrics {
/// Pixels per em, dividing the other values in this struct by this should
/// yield sizes in ems, to allow comparing metrics from faces of different
/// sizes.
pub px_per_em: f64,
/// The minimum cell width that can contain any glyph in the ASCII range.
///
/// Determined by measuring all printable glyphs in the ASCII range.
pub cell_width: f64,
/// The typographic ascent metric from the font.
/// This represents the maximum vertical position of the highest ascender.
///
/// Relative to the baseline, in px, +Y=up
pub ascent: f64,
/// The typographic descent metric from the font.
/// This represents the minimum vertical position of the lowest descender.
///
/// Relative to the baseline, in px, +Y=up
///
/// Note:
/// As this value is generally below the baseline, it is typically negative.
pub descent: f64,
/// The typographic line gap (aka "leading") metric from the font.
/// This represents the additional space to be added between lines in
/// addition to the space defined by the ascent and descent metrics.
///
/// Positive value in px
pub line_gap: f64,
/// The TOP of the underline stroke.
///
/// Relative to the baseline, in px, +Y=up
pub underline_position: Option<f64>,
/// The thickness of the underline stroke in px.
pub underline_thickness: Option<f64>,
/// The TOP of the strikethrough stroke.
///
/// Relative to the baseline, in px, +Y=up
pub strikethrough_position: Option<f64>,
/// The thickness of the strikethrough stroke in px.
pub strikethrough_thickness: Option<f64>,
/// The height of capital letters in the font, either derived from
/// a provided cap height metric or measured from the height of the
/// capital H glyph.
pub cap_height: Option<f64>,
/// The height of lowercase letters in the font, either derived from
/// a provided ex height metric or measured from the height of the
/// lowercase x glyph.
pub ex_height: Option<f64>,
/// The measured height of the bounding box containing all printable
/// ASCII characters. This can be different from ascent - descent for
/// two reasons: non-letter symbols like @ and $ often exceed the
/// the ascender and descender lines; and fonts often bake the line
/// gap into the ascent and descent metrics (as per, e.g., the Google
/// Fonts guidelines: https://simoncozens.github.io/gf-docs/metrics.html).
///
/// Positive value in px
pub ascii_height: Option<f64>,
/// The width of the character "水" (CJK water ideograph, U+6C34),
/// if present. This is used for font size adjustment, to normalize
/// the width of CJK fonts mixed with latin fonts.
///
/// NOTE: IC = Ideograph Character
pub ic_width: Option<f64>,
}
fn positive(v: Option<f64>) -> Option<f64> {
v.filter(|v| *v > 0.0)
}
impl FaceMetrics {
/// Convenience function for getting the line height
/// (ascent - descent + line_gap).
#[inline]
pub fn line_height(&self) -> f64 {
self.ascent - self.descent + self.line_gap
}
/// Convenience function for getting the cap height. If this is not
/// defined in the font, we estimate it as 75% of the ascent.
#[inline]
pub fn cap_height(&self) -> f64 {
positive(self.cap_height).unwrap_or(0.75 * self.ascent)
}
/// Convenience function for getting the ex height. If this is not
/// defined in the font, we estimate it as 75% of the cap height.
#[inline]
pub fn ex_height(&self) -> f64 {
positive(self.ex_height).unwrap_or_else(|| 0.75 * self.cap_height())
}
/// Convenience function for getting the ASCII height. If we
/// couldn't measure this, we use 1.5 * cap_height as our
/// estimator, based on measurements across programming fonts.
#[inline]
pub fn ascii_height(&self) -> f64 {
positive(self.ascii_height).unwrap_or_else(|| 1.5 * self.cap_height())
}
/// Convenience function for getting the ideograph width. If this is
/// not defined in the font, we estimate it as the minimum of the
/// ascii height and two cell widths.
#[inline]
pub fn ic_width(&self) -> f64 {
positive(self.ic_width).unwrap_or_else(|| self.ascii_height().min(2.0 * self.cell_width))
}
/// Convenience function for getting the underline thickness. If
/// this is not defined in the font, we estimate it as 15% of the ex
/// height.
#[inline]
pub fn underline_thickness(&self) -> f64 {
positive(self.underline_thickness).unwrap_or_else(|| 0.15 * self.ex_height())
}
/// Convenience function for getting the strikethrough thickness. If
/// this is not defined in the font, we set it equal to the
/// underline thickness.
#[inline]
pub fn strikethrough_thickness(&self) -> f64 {
positive(self.strikethrough_thickness).unwrap_or_else(|| self.underline_thickness())
}
// NOTE: The getters below return positions, not sizes, so both
// positive and negative values are valid, hence no sign validation.
/// Convenience function for getting the underline position. If
/// this is not defined in the font, we place it one underline
/// thickness below the baseline.
#[inline]
pub fn underline_position(&self) -> f64 {
self.underline_position
.unwrap_or_else(|| -self.underline_thickness())
}
/// Convenience function for getting the strikethrough position. If
/// this is not defined in the font, we center it at half the ex
/// height, so that it's perfectly centered on lower case text.
#[inline]
pub fn strikethrough_position(&self) -> f64 {
self.strikethrough_position
.unwrap_or_else(|| f64::midpoint(self.ex_height(), self.strikethrough_thickness()))
}
}
/// Zig's `@round`: round half away from zero (same as `f64::round`).
#[inline]
fn zround(v: f64) -> f64 {
v.round()
}
impl Metrics {
/// All-zero metrics, except `cursor_thickness` which defaults to 1.
/// (Zig's private `init()`; used by tests.)
#[cfg(test)]
fn init() -> Metrics {
Metrics {
cell_width: 0,
cell_height: 0,
cell_baseline: 0,
underline_position: 0,
underline_thickness: 0,
strikethrough_position: 0,
strikethrough_thickness: 0,
overline_position: 0,
overline_thickness: 0,
box_thickness: 0,
cursor_thickness: 1,
cursor_height: 0,
icon_height: 0.0,
icon_height_single: 0.0,
face_width: 0.0,
face_height: 0.0,
face_y: 0.0,
}
}
/// Calculate our metrics based on values extracted from a font.
///
/// Try to pass values with as much precision as possible,
/// do not round them before using them for this function.
///
/// For any nullable options that are not provided, estimates will be used.
pub fn calc(face: FaceMetrics) -> Metrics {
// These are the unrounded advance width and line height values,
// which are retained separately from the rounded cell width and
// height values (below), for calculations that need to know how
// much error there is between the design dimensions of the font
// and the pixel dimensions of our cells.
let face_width = face.cell_width;
let face_height = face.line_height();
// The cell width and height values need to be integers since they
// represent pixel dimensions of the grid cells in the terminal.
//
// We use round for the cell width to limit the difference from
// the "true" width value to no more than 0.5px. This is a better
// approximation of the authorial intent of the font than ceiling
// would be, and makes the apparent spacing match better between
// low and high DPI displays.
//
// This does mean that it's possible for a glyph to overflow the
// edge of the cell by a pixel if it has no side bearings, but in
// reality such glyphs are generally meant to connect to adjacent
// glyphs in some way so it's not really an issue.
//
// The same is true for the height. Some fonts are poorly authored
// and have a descender on a normal glyph that extends right up to
// the descent value of the face, and this can result in the glyph
// overflowing the bottom of the cell by a pixel, which isn't good
// but if we try to prevent it by increasing the cell height then
// we get line heights that are too large for most users and even
// more inconsistent across DPIs.
//
// Users who experience such cell-height overflows should:
//
// 1. Nag the font author to either redesign the glyph to not go
// so low, or else adjust the descent value in the metadata.
//
// 2. Add an `adjust-cell-height` entry to their config to give
// the cell enough room for the glyph.
let cell_width = zround(face_width);
let cell_height = zround(face_height);
// We split our line gap in two parts, and put half of it on the top
// of the cell and the other half on the bottom, so that our text never
// bumps up against either edge of the cell vertically.
let half_line_gap = face.line_gap / 2.0;
// NOTE: Unlike all our other metrics, `cell_baseline` is
// relative to the BOTTOM of the cell rather than the top.
let face_baseline = half_line_gap - face.descent;
// We calculate the baseline by trying to center the face vertically
// in the pixel-rounded cell height, so that before rounding it will
// be an even distance from the top and bottom of the cell, meaning
// it either sticks out the same amount or is inset the same amount,
// depending on whether the cell height was rounded up or down from
// the line height. We do this by adding half the difference between
// the cell height and the face height.
let cell_baseline = zround(face_baseline - (cell_height - face_height) / 2.0);
// We keep track of the offset from the bottom of the cell
// to the bottom of the face's "true" bounding box, which at
// this point, since nothing has been scaled yet, is equivalent
// to the offset between the baseline we draw at (cell_baseline)
// and the one the font wants (face_baseline).
let face_y = cell_baseline - face_baseline;
// We calculate a top_to_baseline to make following calculations simpler.
let top_to_baseline = cell_height - cell_baseline;
// Get the other font metrics or their estimates. See doc comments
// in FaceMetrics for explanations of the estimation heuristics.
let cap_height = face.cap_height();
let underline_thickness = 1.0f64.max(face.underline_thickness().ceil());
let strikethrough_thickness = 1.0f64.max(face.strikethrough_thickness().ceil());
let underline_position = zround(top_to_baseline - face.underline_position());
let strikethrough_position = zround(top_to_baseline - face.strikethrough_position());
// Same heuristic as the font_patcher script. We store icon_height
// separately from face_height such that modifiers can apply to the former
// without affecting the latter.
let icon_height = face_height;
let icon_height_single = (2.0 * cap_height + face_height) / 3.0;
let mut result = Metrics {
cell_width: cell_width as u32,
cell_height: cell_height as u32,
cell_baseline: cell_baseline as u32,
underline_position: underline_position as u32,
underline_thickness: underline_thickness as u32,
strikethrough_position: strikethrough_position as u32,
strikethrough_thickness: strikethrough_thickness as u32,
overline_position: 0,
overline_thickness: underline_thickness as u32,
box_thickness: underline_thickness as u32,
cursor_thickness: 1,
cursor_height: cell_height as u32,
icon_height,
icon_height_single,
face_width,
face_height,
face_y,
};
// Ensure all metrics are within their allowable range.
result.clamp();
result
}
/// Apply a set of modifiers.
pub fn apply(&mut self, mods: &ModifierSet) {
// Zig iterates a hash map (unspecified order); we iterate in Key
// declaration order for determinism.
let mut entries: Vec<(Key, Modifier)> = mods.iter().map(|(k, v)| (*k, *v)).collect();
entries.sort_by_key(|(k, _)| *k);
for (key, modifier) in entries {
match key {
// We clamp these values to a minimum of 1 to prevent divide-by-zero
// in downstream operations.
Key::CellWidth | Key::CellHeight => {
let original = if key == Key::CellWidth {
self.cell_width
} else {
self.cell_height
};
// Compute the new value. If it is the same avoid the work.
let new = modifier.apply_u32(original).max(1);
if new == original {
continue;
}
if key == Key::CellWidth {
self.cell_width = new;
continue;
}
self.cell_height = new;
// For cell height, we have to also modify some positions
// that are absolute from the top of the cell. The main goal
// here is to center the baseline so that text is vertically
// centered in the cell.
let original_f64 = f64::from(original);
let new_f64 = f64::from(new);
let diff = new_f64 - original_f64;
let half_diff = diff / 2.0;
// If the diff is even, the number of pixels we add
// will be the same for the top and the bottom, but
// if the diff is odd then we want to add the extra
// pixel to the edge of the cell that needs it most.
//
// How much the edge "needs it" depends on whether
// the face is higher or lower than it should be to
// be perfectly centered in the cell.
//
// If the face were perfectly centered then face_y
// would be equal to half of the difference between
// the cell height and the face height.
let position_with_respect_to_center =
self.face_y - (original_f64 - self.face_height) / 2.0;
let (diff_top, diff_bottom) = if position_with_respect_to_center > 0.0 {
// The baseline is higher than it should be, so we
// add the extra to the top, or if it's a negative
// diff it gets added to the bottom because of how
// floor and ceil work.
(half_diff.ceil(), half_diff.floor())
} else {
// The baseline is lower than it should be, so we
// add the extra to the bottom, or vice versa for
// negative diffs.
(half_diff.floor(), half_diff.ceil())
};
// The cell baseline and face_y values are relative to the
// bottom of the cell so we add the bottom diff to them.
add_float_to_int(&mut self.cell_baseline, diff_bottom);
self.face_y += diff_bottom;
// These are all relative to the top of the cell.
add_float_to_int(&mut self.underline_position, diff_top);
add_float_to_int(&mut self.strikethrough_position, diff_top);
self.overline_position = self.overline_position.saturating_add(diff_top as i32);
}
Key::IconHeight => {
self.icon_height = modifier.apply_f64(self.icon_height);
self.icon_height_single = modifier.apply_f64(self.icon_height_single);
}
Key::CellBaseline => self.cell_baseline = modifier.apply_u32(self.cell_baseline),
Key::UnderlinePosition => {
self.underline_position = modifier.apply_u32(self.underline_position);
}
Key::UnderlineThickness => {
self.underline_thickness = modifier.apply_u32(self.underline_thickness);
}
Key::StrikethroughPosition => {
self.strikethrough_position = modifier.apply_u32(self.strikethrough_position);
}
Key::StrikethroughThickness => {
self.strikethrough_thickness = modifier.apply_u32(self.strikethrough_thickness);
}
Key::OverlinePosition => {
self.overline_position = modifier.apply_i32(self.overline_position);
}
Key::OverlineThickness => {
self.overline_thickness = modifier.apply_u32(self.overline_thickness);
}
Key::BoxThickness => self.box_thickness = modifier.apply_u32(self.box_thickness),
Key::CursorThickness => {
self.cursor_thickness = modifier.apply_u32(self.cursor_thickness);
}
Key::CursorHeight => self.cursor_height = modifier.apply_u32(self.cursor_height),
Key::IconHeightSingle => {
self.icon_height_single = modifier.apply_f64(self.icon_height_single);
}
Key::FaceWidth => self.face_width = modifier.apply_f64(self.face_width),
Key::FaceHeight => self.face_height = modifier.apply_f64(self.face_height),
Key::FaceY => self.face_y = modifier.apply_f64(self.face_y),
}
}
// Prevent modifiers from pushing metrics out of their allowable range.
self.clamp();
}
/// Clamp all metrics to their allowable range.
fn clamp(&mut self) {
self.cell_width = self.cell_width.max(minimums::CELL_WIDTH);
self.cell_height = self.cell_height.max(minimums::CELL_HEIGHT);
self.underline_thickness = self.underline_thickness.max(minimums::UNDERLINE_THICKNESS);
self.strikethrough_thickness = self
.strikethrough_thickness
.max(minimums::STRIKETHROUGH_THICKNESS);
self.overline_thickness = self.overline_thickness.max(minimums::OVERLINE_THICKNESS);
self.box_thickness = self.box_thickness.max(minimums::BOX_THICKNESS);
self.cursor_thickness = self.cursor_thickness.max(minimums::CURSOR_THICKNESS);
self.cursor_height = self.cursor_height.max(minimums::CURSOR_HEIGHT);
self.icon_height = self.icon_height.max(minimums::ICON_HEIGHT);
self.icon_height_single = self.icon_height_single.max(minimums::ICON_HEIGHT_SINGLE);
self.face_height = self.face_height.max(minimums::FACE_HEIGHT);
self.face_width = self.face_width.max(minimums::FACE_WIDTH);
}
}
/// Helper function for adding an f64 to a u32.
///
/// Performs saturating addition or subtraction
/// depending on the sign of the provided float.
///
/// The f64 is asserted to have an integer value.
#[inline]
fn add_float_to_int(int: &mut u32, float: f64) {
debug_assert!((float.floor() - float).abs() < f64::EPSILON);
*int = if float >= 0.0 {
int.saturating_add(float as u32)
} else {
int.saturating_sub((-float) as u32)
};
}
/// A set of modifiers to apply to metrics. We use a hash map because
/// we expect most metrics to be unmodified and want to take up as
/// little space as possible.
pub type ModifierSet = HashMap<Key, Modifier>;
/// Error returned by [`Modifier::parse`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum ModifierParseError {
#[error("invalid format")]
InvalidFormat,
#[error("value required")]
ValueRequired,
}
/// A modifier to apply to a metrics value. The modifier value represents
/// a delta, so percent is a percentage to change, not a percentage of.
/// For example, "20%" is 20% larger, not 20% of the value. Likewise,
/// an absolute value of "20" is 20 larger, not literally 20.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Modifier {
Percent(f64),
Absolute(i32),
}
impl Modifier {
/// Parses the modifier value. If the value ends in "%" it is assumed
/// to be a percent, otherwise the value is parsed as an integer.
pub fn parse(input: &str) -> Result<Modifier, ModifierParseError> {
if input.is_empty() {
return Err(ModifierParseError::InvalidFormat);
}
if let Some(num) = input.strip_suffix('%') {
let mut percent = parse_float(num).ok_or(ModifierParseError::InvalidFormat)?;
percent /= 100.0;
if percent <= -1.0 {
return Ok(Modifier::Percent(0.0));
}
if percent < 0.0 {
return Ok(Modifier::Percent(1.0 + percent));
}
return Ok(Modifier::Percent(1.0 + percent));
}
parse_int_i32(input)
.map(Modifier::Absolute)
.ok_or(ModifierParseError::InvalidFormat)
}
/// So it works with the config framework.
pub fn parse_cli(input: Option<&str>) -> Result<Modifier, ModifierParseError> {
Modifier::parse(input.ok_or(ModifierParseError::ValueRequired)?)
}
/// Used by config formatter. Returns the value text of the config entry.
pub fn format_entry(&self) -> String {
match self {
Modifier::Percent(v) => format!("{}%", (v - 1.0) * 100.0),
Modifier::Absolute(v) => format!("{v}"),
}
}
/// Apply a modifier to an unsigned 32-bit value.
pub fn apply_u32(&self, v: u32) -> u32 {
match *self {
Modifier::Percent(p) => {
let p_clamped = p.max(0.0);
(f64::from(v) * p_clamped).round() as u32
}
Modifier::Absolute(abs) => {
let applied = i64::from(v).saturating_add(i64::from(abs));
let clamped = applied.max(0);
u32::try_from(clamped).unwrap_or(u32::MAX)
}
}
}
/// Apply a modifier to a signed 32-bit value.
pub fn apply_i32(&self, v: i32) -> i32 {
match *self {
Modifier::Percent(p) => {
let p_clamped = p.max(0.0);
(f64::from(v) * p_clamped).round() as i32
}
Modifier::Absolute(abs) => {
let applied = i64::from(v).saturating_add(i64::from(abs));
// Mirrors Zig: out-of-range yields maxInt * sign.
i32::try_from(applied).unwrap_or(if applied > 0 { i32::MAX } else { -i32::MAX })
}
}
}
/// Apply a modifier to a float value.
pub fn apply_f64(&self, v: f64) -> f64 {
match *self {
Modifier::Percent(p) => v * p.max(0.0),
Modifier::Absolute(abs) => v + f64::from(abs),
}
}
}
impl FromStr for Modifier {
type Err = ModifierParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Modifier::parse(s)
}
}
/// Zig `std.fmt.parseInt(i32, s, 10)`: optional sign, digits only
/// (underscores are only accepted with base 0, so not here).
fn parse_int_i32(s: &str) -> Option<i32> {
let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse::<i32>().ok()
}
/// Approximation of Zig `std.fmt.parseFloat(f64, s)` for decimal input.
fn parse_float(s: &str) -> Option<f64> {
if s.is_empty() || s.contains(char::is_whitespace) {
return None;
}
s.parse::<f64>().ok()
}
/// Key is an enum of all the available metrics keys (every u32/i32/f64
/// field of [`Metrics`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Key {
CellWidth,
CellHeight,
CellBaseline,
UnderlinePosition,
UnderlineThickness,
StrikethroughPosition,
StrikethroughThickness,
OverlinePosition,
OverlineThickness,
BoxThickness,
CursorThickness,
CursorHeight,
IconHeight,
IconHeightSingle,
FaceWidth,
FaceHeight,
FaceY,
}
impl Key {
/// The snake_case field name, matching the Zig `@tagName`.
pub fn name(&self) -> &'static str {
match self {
Key::CellWidth => "cell_width",
Key::CellHeight => "cell_height",
Key::CellBaseline => "cell_baseline",
Key::UnderlinePosition => "underline_position",
Key::UnderlineThickness => "underline_thickness",
Key::StrikethroughPosition => "strikethrough_position",
Key::StrikethroughThickness => "strikethrough_thickness",
Key::OverlinePosition => "overline_position",
Key::OverlineThickness => "overline_thickness",
Key::BoxThickness => "box_thickness",
Key::CursorThickness => "cursor_thickness",
Key::CursorHeight => "cursor_height",
Key::IconHeight => "icon_height",
Key::IconHeightSingle => "icon_height_single",
Key::FaceWidth => "face_width",
Key::FaceHeight => "face_height",
Key::FaceY => "face_y",
}
}
}
impl FromStr for Key {
type Err = ();
fn from_str(s: &str) -> Result<Self, ()> {
Ok(match s {
"cell_width" => Key::CellWidth,
"cell_height" => Key::CellHeight,
"cell_baseline" => Key::CellBaseline,
"underline_position" => Key::UnderlinePosition,
"underline_thickness" => Key::UnderlineThickness,
"strikethrough_position" => Key::StrikethroughPosition,
"strikethrough_thickness" => Key::StrikethroughThickness,
"overline_position" => Key::OverlinePosition,
"overline_thickness" => Key::OverlineThickness,
"box_thickness" => Key::BoxThickness,
"cursor_thickness" => Key::CursorThickness,
"cursor_height" => Key::CursorHeight,
"icon_height" => Key::IconHeight,
"icon_height_single" => Key::IconHeightSingle,
"face_width" => Key::FaceWidth,
"face_height" => Key::FaceHeight,
"face_y" => Key::FaceY,
_ => return Err(()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formatconfig_percent() {
let p = Modifier::parse_cli(Some("24%")).unwrap();
// Zig test checks the config-formatted line "a = 24%\n"; the config
// entry formatter lives in ghostty-config, so check the value text.
assert_eq!(p.format_entry(), "24%");
}
#[test]
fn formatconfig_absolute() {
let p = Modifier::parse_cli(Some("-30")).unwrap();
assert_eq!(p.format_entry(), "-30");
}
#[test]
fn metrics_apply_modifiers() {
let mut set = ModifierSet::new();
set.insert(Key::CellWidth, Modifier::Percent(1.2));
let mut m = Metrics::init();
m.cell_width = 100;
m.apply(&set);
assert_eq!(120, m.cell_width);
}
#[test]
fn metrics_adjust_cell_height_smaller() {
let mut set = ModifierSet::new();
// We choose numbers such that the subtracted number of pixels is odd,
// as that's the case that could most easily have off-by-one errors.
// Here we're removing 25 pixels: 13 on the bottom, 12 on top, split
// that way because we're simulating a face that's 0.33px higher than
// it "should" be (due to rounding).
set.insert(Key::CellHeight, Modifier::Percent(0.75));
let mut m = Metrics::init();
m.face_y = 0.33;
m.cell_baseline = 50;
m.underline_position = 55;
m.strikethrough_position = 30;
m.overline_position = 0;
m.cell_height = 100;
m.face_height = 99.67;
m.cursor_height = 100;
m.apply(&set);
assert_eq!(-12.67, m.face_y);
assert_eq!(75, m.cell_height);
assert_eq!(37, m.cell_baseline);
assert_eq!(43, m.underline_position);
assert_eq!(18, m.strikethrough_position);
assert_eq!(-12, m.overline_position);
// Cursor height is separate from cell height and does not follow it.
assert_eq!(100, m.cursor_height);
}
#[test]
fn metrics_adjust_cell_height_larger() {
let mut set = ModifierSet::new();
// We choose numbers such that the added number of pixels is odd,
// as that's the case that could most easily have off-by-one errors.
// Here we're adding 75 pixels: 37 on the bottom, 38 on top, split
// that way because we're simulating a face that's 0.33px higher
// than it "should" be (due to rounding).
set.insert(Key::CellHeight, Modifier::Percent(1.75));
let mut m = Metrics::init();
m.face_y = 0.33;
m.cell_baseline = 50;
m.underline_position = 55;
m.strikethrough_position = 30;
m.overline_position = 0;
m.cell_height = 100;
m.face_height = 99.67;
m.cursor_height = 100;
m.apply(&set);
assert_eq!(37.33, m.face_y);
assert_eq!(175, m.cell_height);
assert_eq!(87, m.cell_baseline);
assert_eq!(93, m.underline_position);
assert_eq!(68, m.strikethrough_position);
assert_eq!(38, m.overline_position);
// Cursor height is separate from cell height and does not follow it.
assert_eq!(100, m.cursor_height);
}
#[test]
fn metrics_adjust_icon_height_by_percentage() {
let mut set = ModifierSet::new();
set.insert(Key::IconHeight, Modifier::Percent(0.75));
let mut m = Metrics::init();
m.icon_height = 100.0;
m.icon_height_single = 80.0;
m.face_height = 100.0;
m.face_y = 1.0;
m.apply(&set);
assert_eq!(75.0, m.icon_height);
assert_eq!(60.0, m.icon_height_single);
// Face metrics not affected
assert_eq!(100.0, m.face_height);
assert_eq!(1.0, m.face_y);
}
#[test]
fn metrics_adjust_icon_height_by_absolute_pixels() {
let mut set = ModifierSet::new();
set.insert(Key::IconHeight, Modifier::Absolute(-5));
let mut m = Metrics::init();
m.icon_height = 100.0;
m.icon_height_single = 80.0;
m.face_height = 100.0;
m.face_y = 1.0;
m.apply(&set);
assert_eq!(95.0, m.icon_height);
assert_eq!(75.0, m.icon_height_single);
// Face metrics not affected
assert_eq!(100.0, m.face_height);
assert_eq!(1.0, m.face_y);
}
#[test]
fn modifier_parse_absolute() {
assert_eq!(Modifier::Absolute(100), Modifier::parse("100").unwrap());
assert_eq!(Modifier::Absolute(-100), Modifier::parse("-100").unwrap());
}
#[test]
fn modifier_parse_percent() {
assert_eq!(Modifier::Percent(1.2), Modifier::parse("20%").unwrap());
assert_eq!(Modifier::Percent(0.8), Modifier::parse("-20%").unwrap());
assert_eq!(Modifier::Percent(1.0), Modifier::parse("0%").unwrap());
}
#[test]
fn modifier_parse_errors() {
assert_eq!(Err(ModifierParseError::InvalidFormat), Modifier::parse(""));
assert_eq!(
Err(ModifierParseError::InvalidFormat),
Modifier::parse("abc")
);
assert_eq!(Err(ModifierParseError::InvalidFormat), Modifier::parse("%"));
assert_eq!(Ok(Modifier::Percent(0.0)), Modifier::parse("-150%"));
assert_eq!(
Err(ModifierParseError::ValueRequired),
Modifier::parse_cli(None)
);
}
#[test]
fn modifier_percent() {
assert_eq!(80, Modifier::Percent(0.8).apply_u32(100));
assert_eq!(180, Modifier::Percent(1.8).apply_u32(100));
}
#[test]
fn modifier_absolute() {
assert_eq!(0, Modifier::Absolute(-100).apply_u32(100));
assert_eq!(0, Modifier::Absolute(-120).apply_u32(100));
assert_eq!(200, Modifier::Absolute(100).apply_u32(100));
}
#[test]
fn key_names_roundtrip() {
for k in [Key::CellWidth, Key::FaceY, Key::IconHeightSingle] {
assert_eq!(Ok(k), k.name().parse::<Key>());
}
}
}