1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
//! CPU-only font metrics calculation for window sizing.
//!
//! This module provides font metrics calculation that works without a window or GPU.
//! It enables calculating the exact window size needed for a given terminal grid
//! (cols × rows) BEFORE creating the window, eliminating visible resize on startup.
use anyhow::Result;
use fontdb::{Database, Family, Query};
use swash::FontRef;
use crate::config::Config;
/// Font metrics calculated from font data.
#[derive(Debug, Clone)]
pub struct FontMetrics {
/// Width of a single cell in pixels
pub cell_width: f32,
/// Height of a single cell in pixels
pub cell_height: f32,
/// Font ascent (distance from baseline to top)
pub ascent: f32,
/// Font descent (distance from baseline to bottom, typically negative)
pub descent: f32,
/// Font leading (extra line spacing)
pub leading: f32,
/// Character advance width
pub char_advance: f32,
/// Font size in pixels (after DPI scaling)
pub font_size_pixels: f32,
}
/// Embedded DejaVu Sans Mono font (TTF format) - same as font_manager/loader.rs
const EMBEDDED_FONT: &[u8] = include_bytes!("../fonts/DejaVuSansMono.ttf");
/// Calculate font metrics without requiring a window or GPU.
///
/// This performs the same calculation as `CellRenderer::new()` but uses only
/// CPU-based font operations (fontdb + swash).
///
/// # Arguments
/// * `font_family` - Primary font family name (None uses embedded DejaVu Sans Mono)
/// * `font_size` - Font size in points
/// * `line_spacing` - Line height multiplier (1.0 = tight, 1.2 = default)
/// * `char_spacing` - Character width multiplier (1.0 = normal)
/// * `scale_factor` - Display scale factor (1.0 for standard DPI, 2.0 for Retina)
pub fn calculate_font_metrics(
font_family: Option<&str>,
font_size: f32,
line_spacing: f32,
char_spacing: f32,
scale_factor: f32,
) -> Result<FontMetrics> {
// Load font database
let mut font_db = Database::new();
font_db.load_system_fonts();
// Get font data - either from system or embedded
let font_data = if let Some(family_name) = font_family {
// Try to load from system
let query = Query {
families: &[Family::Name(family_name)],
weight: fontdb::Weight::NORMAL,
style: fontdb::Style::Normal,
..Query::default()
};
if let Some(id) = font_db.query(&query) {
// SAFETY: `make_shared_face_data` may hand back a memory map of the font
// file rather than an owned buffer, so the returned bytes are only valid
// while that file is not modified or truncated by another process. A valid
// `id` from `query()` is necessary but is *not* the invariant that makes
// this call sound. The mapping is copied into an owned `Vec` on the next
// line and the shared handle is dropped immediately after, which keeps the
// exposure window to a single synchronous copy.
if let Some((data, _)) = unsafe { font_db.make_shared_face_data(id) } {
data.as_ref().as_ref().to_vec()
} else {
log::warn!(
"Font '{}' found but failed to load data, using embedded font",
family_name
);
EMBEDDED_FONT.to_vec()
}
} else {
log::warn!(
"Font '{}' not found, using embedded DejaVu Sans Mono",
family_name
);
EMBEDDED_FONT.to_vec()
}
} else {
EMBEDDED_FONT.to_vec()
};
// Create FontRef for metrics calculation
let font_ref = FontRef::from_index(&font_data, 0)
.ok_or_else(|| anyhow::anyhow!("Failed to create FontRef from font data"))?;
// Calculate font size in pixels (matching CellRenderer::new logic)
let platform_dpi = if cfg!(target_os = "macos") {
72.0
} else {
96.0
};
let base_font_pixels = font_size * platform_dpi / 72.0;
let font_size_pixels = (base_font_pixels * scale_factor).max(1.0);
// Extract font metrics
let metrics = font_ref.metrics(&[]);
let scale = font_size_pixels / metrics.units_per_em as f32;
let ascent = metrics.ascent * scale;
let descent = metrics.descent * scale;
let leading = metrics.leading * scale;
// Get advance width for 'm' character (standard monospace reference)
let glyph_id = font_ref.charmap().map('m');
let char_advance = font_ref.glyph_metrics(&[]).advance_width(glyph_id) * scale;
// Calculate cell dimensions (matching CellRenderer::new logic)
let natural_line_height = ascent + descent + leading;
let cell_height = (natural_line_height * line_spacing).max(1.0);
let cell_width = (char_advance * char_spacing).max(1.0);
Ok(FontMetrics {
cell_width,
cell_height,
ascent,
descent,
leading,
char_advance,
font_size_pixels,
})
}
/// Calculate the window size needed for a given terminal grid.
///
/// # Arguments
/// * `cols` - Number of columns
/// * `rows` - Number of rows
/// * `cell_width` - Width of each cell in pixels
/// * `cell_height` - Height of each cell in pixels
/// * `padding` - Window padding in pixels
/// * `tab_bar_height` - Height of tab bar (0 if hidden)
///
/// # Returns
/// `(width, height)` in logical pixels
pub fn calculate_window_size(
cols: usize,
rows: usize,
cell_width: f32,
cell_height: f32,
padding: f32,
tab_bar_height: f32,
) -> (u32, u32) {
let content_width = cols as f32 * cell_width;
let content_height = rows as f32 * cell_height;
// Add padding on all sides, plus tab bar height
let width = (content_width + padding * 2.0).ceil() as u32;
let height = (content_height + padding * 2.0 + tab_bar_height).ceil() as u32;
(width.max(100), height.max(100)) // Minimum window size
}
/// Calculate window size directly from configuration.
///
/// This is a convenience function that combines font metrics calculation
/// with window size calculation, using values from the Config.
///
/// # Arguments
/// * `config` - Terminal configuration
/// * `scale_factor` - Display scale factor (defaults to 1.0 if not known yet)
///
/// # Returns
/// `(width, height)` in logical pixels, or error if font loading fails
pub fn window_size_from_config(config: &Config, scale_factor: f32) -> Result<(u32, u32)> {
let metrics = calculate_font_metrics(
Some(&config.font_family),
config.font_size,
config.line_spacing,
config.char_spacing,
scale_factor,
)?;
// Determine tab bar height based on mode
let tab_bar_height = match config.tabs.tab_bar_mode {
crate::config::TabBarMode::Always => config.tabs.tab_bar_height,
crate::config::TabBarMode::WhenMultiple | crate::config::TabBarMode::Never => 0.0,
};
let (width, height) = calculate_window_size(
config.cols,
config.rows,
metrics.cell_width,
metrics.cell_height,
config.window.window_padding,
tab_bar_height,
);
log::info!(
"Calculated window size: {}x{} for {}x{} grid (cell: {:.1}x{:.1}, padding: {:.1}, tab_bar: {:.1})",
width,
height,
config.cols,
config.rows,
metrics.cell_width,
metrics.cell_height,
config.window.window_padding,
tab_bar_height
);
Ok((width, height))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_font_metrics_embedded() {
// Test with embedded font (no font family specified)
let metrics = calculate_font_metrics(None, 13.0, 1.0, 1.0, 1.0)
.expect("embedded font metrics calculation should succeed");
assert!(metrics.cell_width > 0.0);
assert!(metrics.cell_height > 0.0);
assert!(metrics.font_size_pixels > 0.0);
}
#[test]
fn test_calculate_window_size() {
let (width, height) = calculate_window_size(80, 24, 8.0, 16.0, 10.0, 0.0);
// 80 * 8 + 10 * 2 = 660
assert_eq!(width, 660);
// 24 * 16 + 10 * 2 = 404
assert_eq!(height, 404);
}
#[test]
fn test_calculate_window_size_with_tab_bar() {
let (width, height) = calculate_window_size(80, 24, 8.0, 16.0, 10.0, 28.0);
assert_eq!(width, 660);
// 24 * 16 + 10 * 2 + 28 = 432
assert_eq!(height, 432);
}
#[test]
fn test_minimum_window_size() {
let (width, height) = calculate_window_size(1, 1, 1.0, 1.0, 0.0, 0.0);
// Minimum is 100x100
assert_eq!(width, 100);
assert_eq!(height, 100);
}
#[test]
fn test_line_spacing_affects_cell_height() {
let metrics_tight = calculate_font_metrics(None, 13.0, 1.0, 1.0, 1.0)
.expect("tight line spacing metrics should succeed");
let metrics_spacious = calculate_font_metrics(None, 13.0, 1.5, 1.0, 1.0)
.expect("spacious line spacing metrics should succeed");
// Cell height should be 50% larger with 1.5 line spacing
let ratio = metrics_spacious.cell_height / metrics_tight.cell_height;
assert!((ratio - 1.5).abs() < 0.01);
}
#[test]
fn test_char_spacing_affects_cell_width() {
let metrics_normal = calculate_font_metrics(None, 13.0, 1.0, 1.0, 1.0)
.expect("normal char spacing metrics should succeed");
let metrics_wide = calculate_font_metrics(None, 13.0, 1.0, 1.5, 1.0)
.expect("wide char spacing metrics should succeed");
// Cell width should be 50% larger with 1.5 char spacing
let ratio = metrics_wide.cell_width / metrics_normal.cell_width;
assert!((ratio - 1.5).abs() < 0.01);
}
#[test]
fn test_scale_factor_affects_metrics() {
let metrics_1x = calculate_font_metrics(None, 13.0, 1.0, 1.0, 1.0)
.expect("1x scale factor metrics should succeed");
let metrics_2x = calculate_font_metrics(None, 13.0, 1.0, 1.0, 2.0)
.expect("2x scale factor metrics should succeed");
// At 2x scale, metrics should be doubled
let width_ratio = metrics_2x.cell_width / metrics_1x.cell_width;
let height_ratio = metrics_2x.cell_height / metrics_1x.cell_height;
assert!((width_ratio - 2.0).abs() < 0.01);
assert!((height_ratio - 2.0).abs() < 0.01);
}
}