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