Skip to main content

libghostty_vt/
style.rs

1//! Terminal cell style attributes.
2//!
3//! A style describes the visual attributes of a terminal cell, including
4//! foreground, background, and underline colors, as well as flags for bold,
5//! italic, underline, and other text decorations.
6use std::{ffi::CStr, mem::MaybeUninit, slice};
7
8use crate::{
9    error::{Error, Result},
10    ffi,
11};
12
13/// Style identifier type.
14///
15/// Used to look up the full style from a grid reference.
16/// Obtain this from a cell via [`Cell::style_id`][crate::screen::Cell::style_id].
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub struct Id(pub(crate) ffi::StyleId);
19
20/// Terminal cell style attributes.
21///
22/// A style describes the visual attributes of a terminal cell, including
23/// foreground, background, and underline colors, as well as flags for bold,
24/// italic, underline, and other text decorations.
25#[expect(
26    clippy::struct_excessive_bools,
27    reason = "style attributes should be just a bunch of bools"
28)]
29#[expect(missing_docs, reason = "self-explanatory")]
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct Style {
32    pub fg_color: StyleColor,
33    pub bg_color: StyleColor,
34    pub underline_color: StyleColor,
35    pub bold: bool,
36    pub italic: bool,
37    pub faint: bool,
38    pub blink: bool,
39    pub inverse: bool,
40    pub invisible: bool,
41    pub strikethrough: bool,
42    pub overline: bool,
43    pub underline: Underline,
44}
45
46impl Style {
47    /// Check if a style is the default style.
48    ///
49    /// Returns true if all colors are unset and all flags are off.
50    #[must_use]
51    pub fn is_default(self) -> bool {
52        let raw = ffi::Style::from(self);
53        unsafe { ffi::ghostty_style_is_default(&raw const raw) }
54    }
55}
56
57impl Default for Style {
58    fn default() -> Self {
59        let mut style = MaybeUninit::zeroed();
60        unsafe {
61            ffi::ghostty_style_default(style.as_mut_ptr());
62        }
63
64        // SAFETY: We trust the function above to initialize everything correctly
65        Self::try_from(unsafe { style.assume_init() })
66            .expect("ghostty_style_default to init valid Style")
67    }
68}
69
70/// A color used in a style attribute.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum StyleColor {
73    /// Unset.
74    None,
75    /// Palette index.
76    Palette(PaletteIndex),
77    /// Direct RGB value.
78    Rgb(RgbColor),
79}
80
81/// RGB color value.
82#[repr(C)]
83#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
84pub struct RgbColor {
85    /// Red color component (0-255)
86    pub r: u8,
87    /// Green color component (0-255)
88    pub g: u8,
89    /// Blue color component (0-255)
90    pub b: u8,
91}
92
93/// Palette color index (0-255).
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95pub struct PaletteIndex(pub ffi::ColorPaletteIndex);
96
97impl PaletteIndex {
98    #![expect(missing_docs, reason = "self-explanatory")]
99    pub const BLACK: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BLACK);
100    pub const RED: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_RED);
101    pub const GREEN: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_GREEN);
102    pub const YELLOW: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_YELLOW);
103    pub const BLUE: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BLUE);
104    pub const MAGENTA: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_MAGENTA);
105    pub const CYAN: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_CYAN);
106    pub const WHITE: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_WHITE);
107    pub const BRIGHT_BLACK: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_BLACK);
108    pub const BRIGHT_RED: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_RED);
109    pub const BRIGHT_GREEN: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_GREEN);
110    pub const BRIGHT_YELLOW: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_YELLOW);
111    pub const BRIGHT_BLUE: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_BLUE);
112    pub const BRIGHT_MAGENTA: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_MAGENTA);
113    pub const BRIGHT_CYAN: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_CYAN);
114    pub const BRIGHT_WHITE: PaletteIndex = PaletteIndex(ffi::COLOR_NAMED_BRIGHT_WHITE);
115}
116
117/// Underline style types.
118#[repr(u32)]
119#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, int_enum::IntEnum)]
120#[non_exhaustive]
121#[expect(missing_docs, reason = "self-explanatory")]
122pub enum Underline {
123    None = ffi::SgrUnderline::NONE,
124    Single = ffi::SgrUnderline::SINGLE,
125    Double = ffi::SgrUnderline::DOUBLE,
126    Curly = ffi::SgrUnderline::CURLY,
127    Dotted = ffi::SgrUnderline::DOTTED,
128    Dashed = ffi::SgrUnderline::DASHED,
129}
130
131//----------------------------------
132// Conversion to and from FFI types
133//----------------------------------
134
135impl TryFrom<ffi::Style> for Style {
136    type Error = Error;
137    fn try_from(value: ffi::Style) -> Result<Self> {
138        Ok(Self {
139            fg_color: StyleColor::try_from(value.fg_color)?,
140            bg_color: StyleColor::try_from(value.bg_color)?,
141            underline_color: StyleColor::try_from(value.underline_color)?,
142            bold: value.bold,
143            italic: value.italic,
144            faint: value.faint,
145            blink: value.blink,
146            inverse: value.inverse,
147            invisible: value.invisible,
148            strikethrough: value.strikethrough,
149            overline: value.overline,
150            #[expect(clippy::cast_sign_loss, reason = "bindgen ain't perfect")]
151            underline: Underline::try_from(value.underline as u32)
152                .map_err(|_| Error::InvalidValue)?,
153        })
154    }
155}
156
157impl From<Style> for ffi::Style {
158    fn from(value: Style) -> Self {
159        Self {
160            size: std::mem::size_of::<Self>(),
161            fg_color: value.fg_color.into(),
162            bg_color: value.bg_color.into(),
163            underline_color: value.underline_color.into(),
164            bold: value.bold,
165            italic: value.italic,
166            faint: value.faint,
167            blink: value.blink,
168            inverse: value.inverse,
169            invisible: value.invisible,
170            strikethrough: value.strikethrough,
171            overline: value.overline,
172            #[expect(clippy::cast_possible_wrap, reason = "bindgen ain't perfect")]
173            underline: u32::from(value.underline) as i32,
174        }
175    }
176}
177
178impl TryFrom<ffi::StyleColor> for StyleColor {
179    type Error = Error;
180    fn try_from(value: ffi::StyleColor) -> Result<Self> {
181        Ok(match value.tag {
182            ffi::StyleColorTag::NONE => Self::None,
183            ffi::StyleColorTag::PALETTE => {
184                Self::Palette(PaletteIndex(unsafe { value.value.palette }))
185            }
186            ffi::StyleColorTag::RGB => Self::Rgb(unsafe { value.value.rgb }.into()),
187            _ => return Err(Error::InvalidValue),
188        })
189    }
190}
191
192impl From<StyleColor> for ffi::StyleColor {
193    fn from(value: StyleColor) -> Self {
194        match value {
195            StyleColor::None => Self {
196                tag: ffi::StyleColorTag::NONE,
197                value: ffi::StyleColorValue::default(),
198            },
199            StyleColor::Palette(PaletteIndex(palette)) => Self {
200                tag: ffi::StyleColorTag::PALETTE,
201                value: ffi::StyleColorValue { palette },
202            },
203            StyleColor::Rgb(rgb) => Self {
204                tag: ffi::StyleColorTag::RGB,
205                value: ffi::StyleColorValue { rgb: rgb.into() },
206            },
207        }
208    }
209}
210
211impl From<ffi::ColorRgb> for RgbColor {
212    fn from(value: ffi::ColorRgb) -> Self {
213        let ffi::ColorRgb { r, g, b } = value;
214        Self { r, g, b }
215    }
216}
217
218impl From<RgbColor> for ffi::ColorRgb {
219    fn from(value: RgbColor) -> Self {
220        let RgbColor { r, g, b } = value;
221        Self { r, g, b }
222    }
223}
224
225/// A 256-color palette.
226#[derive(Clone, Copy, Debug)]
227pub struct Palette(pub [RgbColor; 256]);
228
229// Saves a bit of typing
230pub(crate) type RawPalette = [ffi::ColorRgb; 256];
231
232impl From<RawPalette> for Palette {
233    fn from(v: RawPalette) -> Self {
234        Self(v.map(RgbColor::from))
235    }
236}
237impl From<Palette> for RawPalette {
238    fn from(v: Palette) -> Self {
239        v.0.map(ffi::ColorRgb::from)
240    }
241}
242
243impl Default for Palette {
244    /// Get Ghostty's built-in default 256-color palette.
245    ///
246    /// Returns Ghostty's base16 defaults, the xterm 6x6x6 color cube, and the
247    /// grayscale ramp.
248    fn default() -> Self {
249        let mut raw = [ffi::ColorRgb::default(); 256];
250        unsafe { ffi::ghostty_color_palette_default(raw.as_mut_ptr()) };
251        raw.into()
252    }
253}
254
255impl Palette {
256    /// Get the color at the given palette index.
257    pub fn get(&self, index: PaletteIndex) -> RgbColor {
258        self.0[index.0 as usize]
259    }
260
261    /// Set the color at the given palette index.
262    pub fn set(&mut self, index: PaletteIndex, color: RgbColor) {
263        self.0[index.0 as usize] = color;
264    }
265
266    /// Parse a Ghostty palette entry.
267    ///
268    /// Accepts Ghostty palette config syntax: `N=COLOR`. `N` is a palette index
269    /// from 0 to 255 in decimal or in `0x`, `0o`, or `0b`-prefixed form. Spaces and
270    /// tabs around `N` and `COLOR` are ignored. `COLOR` accepts the same syntax as
271    /// [`RgbColor::parse`].
272    pub fn parse_palette_entry(value: &str) -> Result<(PaletteIndex, RgbColor)> {
273        let mut index = MaybeUninit::uninit();
274        let mut rgb = MaybeUninit::uninit();
275        let result = unsafe {
276            ffi::ghostty_color_parse_palette_entry(
277                value.as_ptr().cast(),
278                value.len(),
279                index.as_mut_ptr(),
280                rgb.as_mut_ptr(),
281            )
282        };
283        crate::error::from_result(result)?;
284        Ok((
285            PaletteIndex(unsafe { index.assume_init() }),
286            unsafe { rgb.assume_init() }.into(),
287        ))
288    }
289
290    /// Generate a 256-color palette from base colors.
291    ///
292    /// The base palette supplies indices 0-15, which are always preserved. If
293    /// `base` is [`None`], Ghostty's default palette is used. If `skip` is
294    /// [`None`], no extra indices are skipped. Set bits in `skip` preserve those
295    /// indices from `base`. The 216-color cube at indices 16-231 is generated with
296    /// trilinear CIELAB interpolation, and the grayscale ramp at indices 232-255 is
297    /// interpolated from the background to the foreground.
298    ///
299    /// For light themes, `harmonious` controls whether the generated palette keeps
300    /// the background-to-foreground orientation. When false, Ghostty swaps the
301    /// light background and dark foreground so the cube and ramp run dark-to-light.
302    #[must_use]
303    pub fn generate(
304        base: Option<&Self>,
305        skip: Option<&PaletteMask>,
306        background: RgbColor,
307        foreground: RgbColor,
308        harmonious: bool,
309    ) -> Self {
310        let raw_base = base.map(|palette| palette.0.map(ffi::ColorRgb::from));
311        let base_ptr = raw_base
312            .as_ref()
313            .map_or(std::ptr::null(), |palette| palette.as_ptr());
314        let skip_ptr = skip
315            .as_ref()
316            .map_or(std::ptr::null(), |mask| &raw const mask.0);
317        let mut raw_out = [ffi::ColorRgb::default(); 256];
318        let bg: ffi::ColorRgb = background.into();
319        let fg: ffi::ColorRgb = foreground.into();
320
321        unsafe {
322            ffi::ghostty_color_palette_generate(
323                base_ptr,
324                skip_ptr,
325                &raw const bg,
326                &raw const fg,
327                harmonious,
328                raw_out.as_mut_ptr(),
329            );
330        }
331
332        raw_out.into()
333    }
334}
335
336/// A 256-bit mask of palette indices.
337///
338/// The mask is typically initialized to zero and then populated with
339/// [`PaletteMask::set`].
340#[derive(Clone, Copy, Debug, Default)]
341pub struct PaletteMask(ffi::ColorPaletteMask);
342
343impl PaletteMask {
344    /// Create an empty palette mask.
345    #[must_use]
346    pub fn new() -> Self {
347        Self::default()
348    }
349
350    /// Set a palette index in this mask.
351    pub fn set(&mut self, index: PaletteIndex) {
352        let index = usize::from(index.0);
353        self.0.bits[index / 64] |= 1_u64 << (index % 64);
354    }
355
356    /// Return whether a palette index is set in this mask.
357    #[must_use]
358    pub fn is_set(self, index: PaletteIndex) -> bool {
359        let index = usize::from(index.0);
360        (self.0.bits[index / 64] & (1_u64 << (index % 64))) != 0
361    }
362}
363impl PartialEq for PaletteMask {
364    fn eq(&self, v: &Self) -> bool {
365        self.0.bits.eq(&v.0.bits)
366    }
367}
368impl Eq for PaletteMask {}
369
370impl RgbColor {
371    /// Parse an X11 color name.
372    ///
373    /// The color name is resolved from Ghostty's embedded rgb.txt table. Leading
374    /// and trailing spaces and tabs are trimmed, and matching is ASCII
375    /// case-insensitive. Hex values are not accepted by this function.
376    pub fn parse_x11_color(name: &str) -> Result<Self> {
377        let mut out = MaybeUninit::uninit();
378        let result = unsafe {
379            ffi::ghostty_color_parse_x11(name.as_ptr().cast(), name.len(), out.as_mut_ptr())
380        };
381        crate::error::from_result(result)?;
382        Ok(unsafe { out.assume_init() }.into())
383    }
384
385    /// Parse a flexible Ghostty color value.
386    ///
387    /// Accepts Ghostty's terminal color syntax: X11 color names, hex colors in 3-,
388    /// 6-, 9-, or 12-digit form (the leading `#` is optional for 3- and 6-digit
389    /// values), and `rgb:<red>/<green>/<blue>` or
390    /// `rgbi:<red>/<green>/<blue>` specifications. Leading and trailing spaces and
391    /// tabs are trimmed.
392    pub fn parse(value: &str) -> Result<Self> {
393        let mut out = MaybeUninit::uninit();
394        let result = unsafe {
395            ffi::ghostty_color_parse(value.as_ptr().cast(), value.len(), out.as_mut_ptr())
396        };
397        crate::error::from_result(result)?;
398        Ok(unsafe { out.assume_init() }.into())
399    }
400
401    /// Calculate W3C relative luminance for an RGB color.
402    ///
403    /// Returns a normalized value from 0.0 for black to 1.0 for white. See
404    /// <https://www.w3.org/TR/WCAG20/#relativeluminancedef>.
405    #[must_use]
406    pub fn luminance(self) -> f64 {
407        let this: ffi::ColorRgb = self.into();
408        unsafe { ffi::ghostty_color_luminance(&raw const this) }
409    }
410
411    /// Calculate perceived luminance for an RGB color.
412    ///
413    /// Returns a normalized value from 0.0 for black to 1.0 for white. Ghostty
414    /// treats a background color as light when this exceeds 0.5. This is not the
415    /// metric used internally by [`Palette::generate`], which uses CIELAB lightness.
416    #[must_use]
417    pub fn perceived_luminance(self) -> f64 {
418        let this: ffi::ColorRgb = self.into();
419        unsafe { ffi::ghostty_color_perceived_luminance(&raw const this) }
420    }
421
422    /// Calculate the WCAG contrast ratio between two RGB colors.
423    ///
424    /// The contrast ratio is symmetric and ranges from 1.0 for identical colors to
425    /// 21.0 for black and white.
426    #[must_use]
427    pub fn contrast(self, other: RgbColor) -> f64 {
428        let this: ffi::ColorRgb = self.into();
429        let that: ffi::ColorRgb = other.into();
430        unsafe { ffi::ghostty_color_contrast(&raw const this, &raw const that) }
431    }
432}
433
434/// An entry in Ghostty's X11 color name table.
435#[derive(Clone, Copy, Debug, PartialEq, Eq)]
436pub struct X11ColorName {
437    /// Null-terminated color name.
438    pub name: &'static CStr,
439    /// The RGB value of the color.
440    pub color: RgbColor,
441}
442
443/// Ghostty's X11 color name table.
444///
445/// This is a borrowed view of the static table embedded in Ghostty.
446#[derive(Clone, Copy, Debug)]
447pub struct X11ColorNames {
448    entries: &'static [ffi::ColorX11Entry],
449}
450
451impl Default for X11ColorNames {
452    fn default() -> Self {
453        Self::new()
454    }
455}
456
457/// Iterator over Ghostty's X11 color name table.
458#[derive(Clone, Debug)]
459pub struct X11ColorNamesIter {
460    entries: slice::Iter<'static, ffi::ColorX11Entry>,
461}
462
463impl X11ColorNames {
464    /// Get Ghostty's X11 color name table.
465    ///
466    /// Entries are in rgb.txt order. Aliases are separate entries, such as
467    /// `"medium spring green"` and `"MediumSpringGreen"`. Names are the exact
468    /// supported spellings from rgb.txt; [`RgbColor::parse_x11_color`] also matches them
469    /// case-insensitively.
470    #[must_use]
471    pub fn new() -> Self {
472        let ptr = unsafe { ffi::ghostty_color_x11_names() };
473        let len = unsafe { ffi::ghostty_color_x11_name_count() };
474        let entries = unsafe { slice::from_raw_parts(ptr, len) };
475        Self { entries }
476    }
477
478    /// Get the number of X11 color name entries.
479    ///
480    /// The returned count excludes the NULL terminator.
481    #[must_use]
482    pub fn len(self) -> usize {
483        self.entries.len()
484    }
485
486    /// Return whether the X11 color name table is empty.
487    #[must_use]
488    pub fn is_empty(self) -> bool {
489        self.entries.is_empty()
490    }
491
492    /// Get an X11 color name entry by index.
493    #[must_use]
494    pub fn get(self, index: usize) -> Option<X11ColorName> {
495        self.entries.get(index).map(x11_color_name_from_entry)
496    }
497
498    /// Iterate over the X11 color name table.
499    pub fn iter(self) -> X11ColorNamesIter {
500        X11ColorNamesIter {
501            entries: self.entries.iter(),
502        }
503    }
504}
505
506impl IntoIterator for X11ColorNames {
507    type Item = X11ColorName;
508    type IntoIter = X11ColorNamesIter;
509
510    fn into_iter(self) -> Self::IntoIter {
511        self.iter()
512    }
513}
514
515impl Iterator for X11ColorNamesIter {
516    type Item = X11ColorName;
517
518    fn next(&mut self) -> Option<Self::Item> {
519        self.entries.next().map(x11_color_name_from_entry)
520    }
521
522    fn size_hint(&self) -> (usize, Option<usize>) {
523        self.entries.size_hint()
524    }
525}
526impl DoubleEndedIterator for X11ColorNamesIter {
527    fn next_back(&mut self) -> Option<Self::Item> {
528        self.entries.next_back().map(x11_color_name_from_entry)
529    }
530}
531impl ExactSizeIterator for X11ColorNamesIter {}
532impl std::iter::FusedIterator for X11ColorNamesIter {}
533
534fn x11_color_name_from_entry(entry: &ffi::ColorX11Entry) -> X11ColorName {
535    let name = unsafe { CStr::from_ptr(entry.name) };
536    X11ColorName {
537        name,
538        color: entry.color.into(),
539    }
540}