Skip to main content

par_term_render/cell_renderer/
layout.rs

1use super::{BackgroundInstance, Cell, CellRenderer, RowCacheEntry, TextInstance, pipeline};
2
3/// Terminal grid dimensions, cell sizes, padding, and content offsets.
4pub(crate) struct GridLayout {
5    pub(crate) cols: usize,
6    pub(crate) rows: usize,
7    pub(crate) cell_width: f32,
8    pub(crate) cell_height: f32,
9    pub(crate) window_padding: f32,
10    /// Vertical offset for terminal content (e.g., tab bar at top).
11    /// Content is rendered starting at y = window_padding + content_offset_y.
12    pub(crate) content_offset_y: f32,
13    /// Horizontal offset for terminal content (e.g., tab bar on left).
14    /// Content is rendered starting at x = window_padding + content_offset_x.
15    pub(crate) content_offset_x: f32,
16    /// Bottom inset for terminal content (e.g., tab bar at bottom).
17    /// Reduces available height without shifting content vertically.
18    pub(crate) content_inset_bottom: f32,
19    /// Right inset for terminal content (e.g., AI Inspector panel).
20    /// Reduces available width without shifting content horizontally.
21    pub(crate) content_inset_right: f32,
22    /// Additional bottom inset from egui panels (status bar, tmux bar).
23    /// This is added to content_inset_bottom for scrollbar bounds only,
24    /// since egui panels already claim space before wgpu rendering.
25    pub(crate) egui_bottom_inset: f32,
26    /// Additional right inset from egui panels (AI Inspector).
27    /// This is added to content_inset_right for scrollbar bounds only,
28    /// since egui panels already claim space before wgpu rendering.
29    pub(crate) egui_right_inset: f32,
30}
31
32impl CellRenderer {
33    pub fn cell_width(&self) -> f32 {
34        self.grid.cell_width
35    }
36    pub fn cell_height(&self) -> f32 {
37        self.grid.cell_height
38    }
39    pub fn window_padding(&self) -> f32 {
40        self.grid.window_padding
41    }
42    pub fn content_offset_y(&self) -> f32 {
43        self.grid.content_offset_y
44    }
45    /// Set the vertical content offset (e.g., tab bar height at top).
46    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
47    pub fn set_content_offset_y(&mut self, offset: f32) -> Option<(usize, usize)> {
48        if (self.grid.content_offset_y - offset).abs() > f32::EPSILON {
49            self.grid.content_offset_y = offset;
50            let size = (self.config.width, self.config.height);
51            return Some(self.resize(size.0, size.1));
52        }
53        None
54    }
55    pub fn content_offset_x(&self) -> f32 {
56        self.grid.content_offset_x
57    }
58    /// Set the horizontal content offset (e.g., tab bar on left).
59    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
60    pub fn set_content_offset_x(&mut self, offset: f32) -> Option<(usize, usize)> {
61        if (self.grid.content_offset_x - offset).abs() > f32::EPSILON {
62            self.grid.content_offset_x = offset;
63            let size = (self.config.width, self.config.height);
64            return Some(self.resize(size.0, size.1));
65        }
66        None
67    }
68    pub fn content_inset_bottom(&self) -> f32 {
69        self.grid.content_inset_bottom
70    }
71    /// Set the bottom content inset (e.g., tab bar at bottom).
72    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
73    pub fn set_content_inset_bottom(&mut self, inset: f32) -> Option<(usize, usize)> {
74        if (self.grid.content_inset_bottom - inset).abs() > f32::EPSILON {
75            self.grid.content_inset_bottom = inset;
76            let size = (self.config.width, self.config.height);
77            return Some(self.resize(size.0, size.1));
78        }
79        None
80    }
81    pub fn content_inset_right(&self) -> f32 {
82        self.grid.content_inset_right
83    }
84    /// Set the right content inset (e.g., AI Inspector panel).
85    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
86    pub fn set_content_inset_right(&mut self, inset: f32) -> Option<(usize, usize)> {
87        if (self.grid.content_inset_right - inset).abs() > f32::EPSILON {
88            log::info!(
89                "[SCROLLBAR] set_content_inset_right: {:.1} -> {:.1} (physical px)",
90                self.grid.content_inset_right,
91                inset
92            );
93            self.grid.content_inset_right = inset;
94            let size = (self.config.width, self.config.height);
95            return Some(self.resize(size.0, size.1));
96        }
97        None
98    }
99    pub fn grid_size(&self) -> (usize, usize) {
100        (self.grid.cols, self.grid.rows)
101    }
102
103    pub fn resize(&mut self, width: u32, height: u32) -> (usize, usize) {
104        if width == 0 || height == 0 {
105            return (self.grid.cols, self.grid.rows);
106        }
107        let (width, height) = super::surface::clamp_surface_extent(
108            width,
109            height,
110            self.device.limits().max_texture_dimension_2d,
111        );
112        // `Surface::configure` is not idempotent: it drains the GPU queue,
113        // tears down the swapchain, and re-sets every CAMetalLayer property
114        // (colorspace included since wgpu 30). Inset-driven calls arrive with
115        // an unchanged extent, so configure only when it actually changed;
116        // forced recovery reconfigures go through `reconfigure_surface`.
117        if self.config.width != width || self.config.height != height {
118            log::info!(
119                "Configuring surface {}x{} (was {}x{})",
120                width,
121                height,
122                self.config.width,
123                self.config.height
124            );
125            self.config.width = width;
126            self.config.height = height;
127            self.configure_surface();
128        } else {
129            log::debug!(
130                "Surface extent unchanged ({}x{}), skipping configure",
131                width,
132                height
133            );
134        }
135
136        // Match the pane render path formula (see `chrome_overhead` below),
137        // which is always active. Width: no scrollbar deduction here — the pane
138        // render path conditionally subtracts scrollbar_width via RendererSizing
139        // when the scrollbar is visible.
140        // Height: 1× padding (top margin is content_offset_y, not padding).
141        let available_width = (width as f32
142            - self.grid.window_padding * 2.0
143            - self.grid.content_offset_x
144            - self.grid.content_inset_right)
145            .max(0.0);
146        let available_height = (height as f32
147            - self.grid.window_padding
148            - self.grid.content_offset_y
149            - self.grid.content_inset_bottom
150            - self.grid.egui_bottom_inset)
151            .max(0.0);
152        let new_cols = (available_width / self.grid.cell_width).max(1.0) as usize;
153        let new_rows = (available_height / self.grid.cell_height).max(1.0) as usize;
154
155        if new_cols != self.grid.cols || new_rows != self.grid.rows {
156            self.grid.cols = new_cols;
157            self.grid.rows = new_rows;
158            self.cells = vec![Cell::default(); self.grid.cols * self.grid.rows];
159            self.dirty_rows = vec![true; self.grid.rows];
160            self.row_cache = vec![None::<RowCacheEntry>; self.grid.rows];
161            self.recreate_instance_buffers();
162        }
163
164        self.update_bg_image_uniforms(None);
165        (self.grid.cols, self.grid.rows)
166    }
167
168    /// Returns total non-terminal pixel overhead as (horizontal_px, vertical_px).
169    ///
170    /// Matches the pane render path formula, which is always active:
171    ///   Horizontal: window_padding*2 + content_offset_x + content_inset_right
172    ///   Vertical:   window_padding + content_offset_y + content_inset_bottom + egui_bottom_inset
173    ///
174    /// Note: scrollbar is NOT included — it is conditionally subtracted in the
175    /// pane render path (RendererSizing.scrollbar_width) only when visible.
176    /// Height uses 1× padding (bottom only; top margin is content_offset_y).
177    pub fn chrome_overhead(&self) -> (f32, f32) {
178        let chrome_x = self.grid.window_padding * 2.0
179            + self.grid.content_offset_x
180            + self.grid.content_inset_right;
181        let chrome_y = self.grid.window_padding
182            + self.grid.content_offset_y
183            + self.grid.content_inset_bottom
184            + self.grid.egui_bottom_inset;
185        (chrome_x, chrome_y)
186    }
187
188    /// Instance capacity the single-grid (offscreen) layout needs:
189    /// cells + cursor overlays + one separator per row + one gutter bar per row.
190    pub(crate) fn single_grid_instance_capacity(&self) -> (usize, usize) {
191        (
192            super::render::SingleGridLayout::new(self.grid.cols, self.grid.rows).bg_instances(),
193            self.grid.cols * self.grid.rows * super::TEXT_INSTANCES_PER_CELL,
194        )
195    }
196
197    pub(crate) fn recreate_instance_buffers(&mut self) {
198        let (bg, text) = self.single_grid_instance_capacity();
199        self.allocate_instance_buffers(bg, text);
200    }
201
202    /// Ensure both instance buffers (GPU and CPU side) can hold the given capacities.
203    ///
204    /// Existing allocations are reused when they already suffice: grid resizes used to
205    /// wholesale-reallocate the GPU buffers and the big CPU mirror arrays on every
206    /// grid change (a Malloc Large / resize-spike contributor). Arrays hold at their
207    /// high-water capacity; draw ranges come from the actual_* counts, never the caps.
208    pub(crate) fn allocate_instance_buffers(&mut self, max_bg: usize, max_text: usize) {
209        if max_bg > self.buffers.max_bg_instances || max_text > self.buffers.max_text_instances {
210            let (bg_buf, text_buf) =
211                pipeline::create_instance_buffers(&self.device, max_bg, max_text);
212            self.buffers.bg_instance_buffer = bg_buf;
213            self.buffers.text_instance_buffer = text_buf;
214        }
215        self.buffers.max_bg_instances = max_bg;
216        self.buffers.max_text_instances = max_text;
217        // Reset actual counts - will be updated when instance buffers are built
218        self.buffers.actual_bg_instances = 0;
219        self.buffers.actual_text_instances = 0;
220        self.buffers.pane_bg_cursor = 0;
221        self.buffers.pane_text_cursor = 0;
222        // A new allocation may well be the fix for a previous over-run, so let it
223        // be reported again if it still does not fit.
224        self.buffers.overflow_reported = false;
225
226        // Reuse when capacity suffices; vec![x; n] here was a wholesale realloc on
227        // every resize. resize() corrects len without reallocating in that case.
228        if self.bg_instances.capacity() >= max_bg {
229            self.bg_instances.resize(max_bg, BackgroundInstance::BLANK);
230        } else {
231            self.bg_instances = vec![
232                BackgroundInstance {
233                    position: [0.0, 0.0],
234                    size: [0.0, 0.0],
235                    color: [0.0, 0.0, 0.0, 0.0],
236                };
237                max_bg
238            ];
239        }
240        if self.text_instances.capacity() >= max_text {
241            self.text_instances.resize(
242                max_text,
243                TextInstance {
244                    position: [0.0, 0.0],
245                    size: [0.0, 0.0],
246                    tex_offset: [0.0, 0.0],
247                    tex_size: [0.0, 0.0],
248                    color: [0.0, 0.0, 0.0, 0.0],
249                    is_colored: 0,
250                },
251            );
252        } else {
253            self.text_instances = vec![
254                TextInstance {
255                    position: [0.0, 0.0],
256                    size: [0.0, 0.0],
257                    tex_offset: [0.0, 0.0],
258                    tex_size: [0.0, 0.0],
259                    color: [0.0, 0.0, 0.0, 0.0],
260                    is_colored: 0,
261                };
262                max_text
263            ];
264        }
265
266        // Resize scratch buffers to match new grid; keep existing allocations if large enough
267        self.scratch_row_bg.reserve(
268            self.grid
269                .cols
270                .saturating_sub(self.scratch_row_bg.capacity()),
271        );
272        self.scratch_row_text
273            .reserve((self.grid.cols * 2).saturating_sub(self.scratch_row_text.capacity()));
274    }
275
276    /// Update scale factor and recalculate all font metrics and cell dimensions.
277    /// This is called when the window is dragged between displays with different DPIs.
278    pub fn update_scale_factor(&mut self, scale_factor: f64) {
279        let new_scale = scale_factor as f32;
280
281        // Skip if scale factor hasn't changed
282        if (self.scale_factor - new_scale).abs() < f32::EPSILON {
283            return;
284        }
285
286        log::info!(
287            "Recalculating font metrics for scale factor change: {} -> {}",
288            self.scale_factor,
289            new_scale
290        );
291
292        self.scale_factor = new_scale;
293
294        // Recalculate font_size_pixels based on new scale factor
295        let platform_dpi = if cfg!(target_os = "macos") {
296            crate::cell_renderer::MACOS_PLATFORM_DPI
297        } else {
298            crate::cell_renderer::DEFAULT_PLATFORM_DPI
299        };
300        let base_font_pixels =
301            self.font.base_font_size * platform_dpi / crate::cell_renderer::FONT_REFERENCE_DPI;
302        self.font.font_size_pixels = (base_font_pixels * new_scale).max(1.0);
303
304        // Re-extract font metrics at new scale
305        let (font_ascent, font_descent, font_leading, char_advance) = {
306            let primary_font = self.font_manager.get_font(0).expect(
307                "Primary font at index 0 must exist in FontManager when updating scale factor",
308            );
309            let metrics = primary_font.metrics(&[]);
310            let scale = self.font.font_size_pixels / metrics.units_per_em as f32;
311            let glyph_id = primary_font.charmap().map('m');
312            let advance = primary_font.glyph_metrics(&[]).advance_width(glyph_id) * scale;
313            (
314                metrics.ascent * scale,
315                metrics.descent * scale,
316                metrics.leading * scale,
317                advance,
318            )
319        };
320
321        self.font.font_ascent = font_ascent;
322        self.font.font_descent = font_descent;
323        self.font.font_leading = font_leading;
324        self.font.char_advance = char_advance;
325
326        // Recalculate cell dimensions (rounded to integer pixels for uniform glyph brightness)
327        let natural_line_height = font_ascent + font_descent + font_leading;
328        self.grid.cell_height = (natural_line_height * self.font.line_spacing)
329            .max(1.0)
330            .round();
331        self.grid.cell_width = (char_advance * self.font.char_spacing).max(1.0).round();
332
333        log::info!(
334            "New cell dimensions: {}x{} (font_size_pixels: {})",
335            self.grid.cell_width,
336            self.grid.cell_height,
337            self.font.font_size_pixels
338        );
339
340        // Clear glyph cache - glyphs need to be re-rasterized at new DPI
341        self.clear_glyph_cache();
342
343        // Mark all rows as dirty to force re-rendering
344        self.dirty_rows.fill(true);
345    }
346
347    pub fn update_window_padding(&mut self, padding: f32) -> Option<(usize, usize)> {
348        if (self.grid.window_padding - padding).abs() > f32::EPSILON {
349            self.grid.window_padding = padding;
350            let size = (self.config.width, self.config.height);
351            return Some(self.resize(size.0, size.1));
352        }
353        None
354    }
355}