Skip to main content

par_term_render/cell_renderer/
font.rs

1use super::CellRenderer;
2
3/// Font configuration (base values), scaled metrics, shaping, and rendering options.
4pub(crate) struct FontState {
5    // Base configuration (before scale factor)
6    pub(crate) base_font_size: f32,
7    pub(crate) line_spacing: f32,
8    pub(crate) char_spacing: f32,
9    // Scaled metrics (scaled by current scale_factor)
10    pub(crate) font_ascent: f32,
11    pub(crate) font_descent: f32,
12    pub(crate) font_leading: f32,
13    pub(crate) font_size_pixels: f32,
14    pub(crate) char_advance: f32,
15    // Shaping options
16    #[allow(dead_code)] // Config stored for future text shaping pipeline integration
17    pub(crate) enable_text_shaping: bool,
18    pub(crate) enable_ligatures: bool,
19    pub(crate) enable_kerning: bool,
20    // Rendering options
21    /// Enable anti-aliasing for font rendering
22    pub(crate) font_antialias: bool,
23    /// Enable hinting for font rendering
24    pub(crate) font_hinting: bool,
25    /// Thin strokes mode for font rendering
26    pub(crate) font_thin_strokes: par_term_config::ThinStrokesMode,
27    /// Minimum contrast between text and background (iTerm2-compatible)
28    /// 0.0 = disabled, values near 1.0 = nearly black & white
29    pub(crate) minimum_contrast: f32,
30}
31
32/// Threshold below which the background is considered "dark" for contrast purposes.
33const DARK_BACKGROUND_THRESHOLD: f32 = 0.5;
34
35/// Minimum contrast ratio change that triggers a re-render of all rows.
36/// Changes smaller than this are ignored to avoid unnecessary redraws.
37const CONTRAST_CHANGE_EPSILON: f32 = 0.001;
38
39impl CellRenderer {
40    /// Update font anti-aliasing setting.
41    /// Returns true if the setting changed (requiring glyph cache clear).
42    pub fn update_font_antialias(&mut self, enabled: bool) -> bool {
43        if self.font.font_antialias != enabled {
44            self.font.font_antialias = enabled;
45            self.clear_glyph_cache();
46            self.dirty_rows.fill(true);
47            true
48        } else {
49            false
50        }
51    }
52
53    /// Update font hinting setting.
54    /// Returns true if the setting changed (requiring glyph cache clear).
55    pub fn update_font_hinting(&mut self, enabled: bool) -> bool {
56        if self.font.font_hinting != enabled {
57            self.font.font_hinting = enabled;
58            self.clear_glyph_cache();
59            self.dirty_rows.fill(true);
60            true
61        } else {
62            false
63        }
64    }
65
66    /// Update thin strokes mode.
67    /// Returns true if the setting changed (requiring glyph cache clear).
68    pub fn update_font_thin_strokes(&mut self, mode: par_term_config::ThinStrokesMode) -> bool {
69        if self.font.font_thin_strokes != mode {
70            self.font.font_thin_strokes = mode;
71            self.clear_glyph_cache();
72            self.dirty_rows.fill(true);
73            true
74        } else {
75            false
76        }
77    }
78
79    /// Update minimum contrast value.
80    /// Returns true if the setting changed (requiring redraw).
81    pub fn update_minimum_contrast(&mut self, value: f32) -> bool {
82        // Clamp to valid range: 0.0 (disabled) to 1.0 (max contrast)
83        let value = value.clamp(0.0, 1.0);
84        if (self.font.minimum_contrast - value).abs() > CONTRAST_CHANGE_EPSILON {
85            self.font.minimum_contrast = value;
86            self.dirty_rows.fill(true);
87            true
88        } else {
89            false
90        }
91    }
92
93    /// Adjust foreground color to meet minimum contrast against background.
94    /// Uses iTerm2-compatible perceived brightness algorithm:
95    /// brightness = 0.30*R + 0.59*G + 0.11*B
96    /// Ensures the absolute brightness difference between fg and bg meets the threshold.
97    /// Returns the adjusted color [R, G, B, A] with preserved alpha.
98    pub(crate) fn ensure_minimum_contrast(&self, fg: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
99        let min_contrast = self.font.minimum_contrast;
100        // If minimum_contrast is 0.0 (disabled) or negligible, no adjustment needed
101        if min_contrast <= 0.0 {
102            return fg;
103        }
104
105        /// Perceived brightness using iTerm2's coefficients (BT.601 luma).
106        fn perceived_brightness(r: f32, g: f32, b: f32) -> f32 {
107            0.30 * r + 0.59 * g + 0.11 * b
108        }
109
110        let fg_brightness = perceived_brightness(fg[0], fg[1], fg[2]);
111        let bg_brightness = perceived_brightness(bg[0], bg[1], bg[2]);
112        let brightness_diff = (fg_brightness - bg_brightness).abs();
113
114        // If already meets minimum contrast, return unchanged
115        if brightness_diff >= min_contrast {
116            return fg;
117        }
118
119        // Need to adjust. Determine target brightness.
120        let error = min_contrast - brightness_diff;
121        let mut target_brightness = if fg_brightness < bg_brightness {
122            // fg is darker — try to make it even darker
123            fg_brightness - error
124        } else {
125            // fg is brighter — try to make it even brighter
126            fg_brightness + error
127        };
128
129        // If target is out of range, try the opposite direction
130        if target_brightness < 0.0 {
131            let alternative = bg_brightness + min_contrast;
132            let base_contrast = bg_brightness;
133            let alt_contrast = alternative.min(1.0) - bg_brightness;
134            if alt_contrast > base_contrast {
135                target_brightness = alternative;
136            }
137        } else if target_brightness > 1.0 {
138            let alternative = bg_brightness - min_contrast;
139            let base_contrast = 1.0 - bg_brightness;
140            let alt_contrast = bg_brightness - alternative.max(0.0);
141            if alt_contrast > base_contrast {
142                target_brightness = alternative;
143            }
144        }
145
146        target_brightness = target_brightness.clamp(0.0, 1.0);
147
148        // Interpolate from current color toward black (k=0) or white (k=1)
149        // to reach target brightness. Solve for parameter p analytically.
150        let k: f32 = if fg_brightness < target_brightness {
151            1.0 // move toward white
152        } else {
153            0.0 // move toward black
154        };
155
156        let denom = perceived_brightness(k - fg[0], k - fg[1], k - fg[2]);
157        let p = if denom.abs() < 1e-10 {
158            0.0
159        } else {
160            ((target_brightness - perceived_brightness(fg[0], fg[1], fg[2])) / denom)
161                .clamp(0.0, 1.0)
162        };
163
164        [
165            p * k + (1.0 - p) * fg[0],
166            p * k + (1.0 - p) * fg[1],
167            p * k + (1.0 - p) * fg[2],
168            fg[3],
169        ]
170    }
171
172    /// Check if thin strokes should be applied based on current mode and context.
173    pub(crate) fn should_use_thin_strokes(&self) -> bool {
174        use par_term_config::ThinStrokesMode;
175
176        // Check if we're on a Retina/HiDPI display (scale factor > 1.5)
177        let is_retina = self.scale_factor > 1.5;
178
179        // Check if background is dark (average < 128)
180        let bg_brightness =
181            (self.background_color[0] + self.background_color[1] + self.background_color[2]) / 3.0;
182        let is_dark_background = bg_brightness < DARK_BACKGROUND_THRESHOLD;
183
184        match self.font.font_thin_strokes {
185            ThinStrokesMode::Never => false,
186            ThinStrokesMode::Always => true,
187            ThinStrokesMode::RetinaOnly => is_retina,
188            ThinStrokesMode::DarkBackgroundsOnly => is_dark_background,
189            ThinStrokesMode::RetinaDarkBackgroundsOnly => is_retina && is_dark_background,
190        }
191    }
192}