Skip to main content

par_term_render/renderer/
mod.rs

1// ARC-009 TODO: This file is 743 lines (limit: 800 — approaching threshold). When it
2// exceeds 800 lines, extract into sibling sub-modules under renderer/:
3//
4//   frame_timing.rs   — Frame throttle logic and vsync bookkeeping
5//   resize_ops.rs     — Window/surface resize and snap-to-grid
6//
7// The renderer/ directory already has render_passes.rs, rendering.rs, egui_render.rs,
8// graphics.rs, shaders/, params.rs, state.rs — follow that existing split pattern.
9//
10// Tracking: Issue ARC-009 in AUDIT.md.
11
12use crate::cell_renderer::{Cell, CellRenderer, CellRendererConfig, PaneViewport};
13use crate::custom_shader_renderer::CustomShaderRenderer;
14use crate::graphics_renderer::GraphicsRenderer;
15use anyhow::Result;
16use winit::dpi::PhysicalSize;
17
18mod egui_render;
19pub mod graphics;
20pub mod params;
21
22mod render_passes;
23mod rendering;
24pub mod shaders;
25mod state;
26
27// Re-export SeparatorMark from par-term-config
28pub use par_term_config::SeparatorMark;
29pub use params::RendererParams;
30pub use rendering::SplitPanesRenderParams;
31
32/// Compute which separator marks are visible in the current viewport.
33///
34/// Maps absolute scrollback line numbers to screen rows for the current view.
35/// Returns only the marks whose absolute line index falls within the visible
36/// window `[viewport_start, viewport_start + visible_lines)`, converting each
37/// to a zero-based screen row. No deduplication or merging is performed; marks
38/// are returned in the same order they appear in `marks`.
39pub fn compute_visible_separator_marks(
40    marks: &[par_term_config::ScrollbackMark],
41    scrollback_len: usize,
42    scroll_offset: usize,
43    visible_lines: usize,
44) -> Vec<SeparatorMark> {
45    let mut out = Vec::new();
46    fill_visible_separator_marks(
47        &mut out,
48        marks,
49        scrollback_len,
50        scroll_offset,
51        visible_lines,
52    );
53    out
54}
55
56/// Fill `out` with the separator marks visible in the current viewport, reusing
57/// the provided allocation to avoid a per-call heap allocation on the render hot path.
58///
59/// The buffer is cleared on entry. Semantics are identical to
60/// [`compute_visible_separator_marks`].
61pub(crate) fn fill_visible_separator_marks(
62    out: &mut Vec<SeparatorMark>,
63    marks: &[par_term_config::ScrollbackMark],
64    scrollback_len: usize,
65    scroll_offset: usize,
66    visible_lines: usize,
67) {
68    out.clear();
69    let viewport_start = scrollback_len.saturating_sub(scroll_offset);
70    let viewport_end = viewport_start + visible_lines;
71    for mark in marks {
72        if mark.line >= viewport_start && mark.line < viewport_end {
73            let screen_row = mark.line - viewport_start;
74            out.push((screen_row, mark.exit_code, mark.color));
75        }
76    }
77}
78
79/// Information needed to render a single pane
80pub struct PaneRenderInfo<'a> {
81    /// Viewport bounds and state for this pane
82    pub viewport: PaneViewport,
83    /// Cells to render (should match viewport grid size)
84    pub cells: &'a [Cell],
85    /// Grid dimensions (cols, rows)
86    pub grid_size: (usize, usize),
87    /// Cursor position within this pane (col, row), or None if no cursor visible
88    pub cursor_pos: Option<(usize, usize)>,
89    /// Cursor opacity (0.0 = hidden, 1.0 = fully visible)
90    pub cursor_opacity: f32,
91    /// Whether this pane has a scrollbar visible
92    pub show_scrollbar: bool,
93    /// Scrollback marks for this pane
94    pub marks: Vec<par_term_config::ScrollbackMark>,
95    /// Scrollback length for this pane (needed for separator mark mapping)
96    pub scrollback_len: usize,
97    /// Current scroll offset for this pane (needed for separator mark mapping)
98    pub scroll_offset: usize,
99    /// Per-pane background image override (None = use global background)
100    pub background: Option<par_term_config::PaneBackground>,
101    /// Inline graphics (Sixel/iTerm2/Kitty) to render for this pane
102    pub graphics: Vec<par_term_emu_core_rust::graphics::TerminalGraphic>,
103}
104
105/// Information needed to render a pane divider
106#[derive(Clone, Copy, Debug)]
107pub struct DividerRenderInfo {
108    /// X position in pixels
109    pub x: f32,
110    /// Y position in pixels
111    pub y: f32,
112    /// Width in pixels
113    pub width: f32,
114    /// Height in pixels
115    pub height: f32,
116    /// Whether this divider is currently being hovered
117    pub hovered: bool,
118}
119
120impl DividerRenderInfo {
121    /// Create from a DividerRect
122    pub fn from_rect(rect: &par_term_config::DividerRect, hovered: bool) -> Self {
123        Self {
124            x: rect.x,
125            y: rect.y,
126            width: rect.width,
127            height: rect.height,
128            hovered,
129        }
130    }
131}
132
133/// Information needed to render a pane title bar
134#[derive(Clone, Debug)]
135pub struct PaneTitleInfo {
136    /// X position of the title bar in pixels
137    pub x: f32,
138    /// Y position of the title bar in pixels
139    pub y: f32,
140    /// Width of the title bar in pixels
141    pub width: f32,
142    /// Height of the title bar in pixels
143    pub height: f32,
144    /// Title text to display
145    pub title: String,
146    /// Whether this pane is focused
147    pub focused: bool,
148    /// Text color [R, G, B] as floats (0.0-1.0)
149    pub text_color: [f32; 3],
150    /// Background color [R, G, B] as floats (0.0-1.0)
151    pub bg_color: [f32; 3],
152}
153
154/// Settings for rendering pane dividers and focus indicators
155#[derive(Clone, Copy, Debug)]
156pub struct PaneDividerSettings {
157    /// Color for dividers [R, G, B] as floats (0.0-1.0)
158    pub divider_color: [f32; 3],
159    /// Color when hovering over dividers [R, G, B] as floats (0.0-1.0)
160    pub hover_color: [f32; 3],
161    /// Whether to show focus indicator around focused pane
162    pub show_focus_indicator: bool,
163    /// Color for focus indicator [R, G, B] as floats (0.0-1.0)
164    pub focus_color: [f32; 3],
165    /// Width of focus indicator border in pixels
166    pub focus_width: f32,
167    /// Style of dividers (solid, double, dashed, shadow)
168    pub divider_style: par_term_config::DividerStyle,
169}
170
171impl Default for PaneDividerSettings {
172    fn default() -> Self {
173        Self {
174            divider_color: [0.3, 0.3, 0.3],
175            hover_color: [0.5, 0.6, 0.8],
176            show_focus_indicator: true,
177            focus_color: [0.4, 0.6, 1.0],
178            focus_width: 1.0,
179            divider_style: par_term_config::DividerStyle::default(),
180        }
181    }
182}
183
184/// Renderer for the terminal using custom wgpu cell renderer
185pub struct Renderer {
186    // Cell renderer (owns the scrollbar)
187    pub(crate) cell_renderer: CellRenderer,
188
189    // Graphics renderer for sixel images
190    pub(crate) graphics_renderer: GraphicsRenderer,
191
192    // Current sixel graphics to render.
193    // Note: screen_row is isize to allow negative values for graphics scrolled off top
194    pub(crate) sixel_graphics: Vec<crate::graphics_renderer::GraphicRenderInfo>,
195
196    // egui renderer for settings UI
197    pub(crate) egui_renderer: egui_wgpu::Renderer,
198
199    // Custom shader renderer for post-processing effects (background shader)
200    pub(crate) custom_shader_renderer: Option<CustomShaderRenderer>,
201    // Track current shader path to detect changes
202    pub(crate) custom_shader_path: Option<String>,
203
204    // Cursor shader renderer for cursor-specific effects (separate from background shader)
205    pub(crate) cursor_shader_renderer: Option<CustomShaderRenderer>,
206    // Track current cursor shader path to detect changes
207    pub(crate) cursor_shader_path: Option<String>,
208
209    // Cached for convenience
210    pub(crate) size: PhysicalSize<u32>,
211
212    // Dirty flag for optimization - only render when content has changed
213    pub(crate) dirty: bool,
214
215    // Cached scrollbar state to avoid redundant GPU uploads.
216    // Includes scroll position, line counts, marks, window size, AND pane viewport
217    // bounds so that pane splits/resizes correctly trigger a scrollbar geometry update.
218    pub(crate) last_scrollbar_state: (usize, usize, usize, usize, u32, u32, u32, u32, u32, u32),
219
220    // Skip cursor shader when alt screen is active (TUI apps like vim, htop)
221    pub(crate) cursor_shader_disabled_for_alt_screen: bool,
222
223    // Debug overlay text
224    pub(crate) debug_text: Option<String>,
225
226    // Scratch buffer for divider instances, reused each frame to avoid
227    // per-call heap allocations in `render_dividers`.
228    pub(crate) scratch_divider_instances: Vec<crate::cell_renderer::BackgroundInstance>,
229}
230
231impl Renderer {
232    /// Create a new renderer
233    pub async fn new(params: RendererParams<'_>) -> Result<Self> {
234        let window = params.window;
235        let font_family = params.font_family;
236        let font_family_bold = params.font_family_bold;
237        let font_family_italic = params.font_family_italic;
238        let font_family_bold_italic = params.font_family_bold_italic;
239        let font_ranges = params.font_ranges;
240        let font_size = params.font_size;
241        let line_spacing = params.line_spacing;
242        let char_spacing = params.char_spacing;
243        let scrollbar_position = params.scrollbar_position;
244        let scrollbar_thumb_color = params.scrollbar_thumb_color;
245        let scrollbar_track_color = params.scrollbar_track_color;
246        let enable_text_shaping = params.enable_text_shaping;
247        let enable_ligatures = params.enable_ligatures;
248        let enable_kerning = params.enable_kerning;
249        let font_antialias = params.font_antialias;
250        let font_hinting = params.font_hinting;
251        let font_thin_strokes = params.font_thin_strokes;
252        let minimum_contrast = params.minimum_contrast;
253        let vsync_mode = params.vsync_mode;
254        let power_preference = params.power_preference;
255        let window_opacity = params.window_opacity;
256        let background_color = params.background_color;
257        let background_image_path = params.background_image_path;
258        let background_image_enabled = params.background_image_enabled;
259        let background_image_mode = params.background_image_mode;
260        let background_image_opacity = params.background_image_opacity;
261        let custom_shader_path = params.custom_shader_path;
262        let custom_shader_enabled = params.custom_shader_enabled;
263        let custom_shader_animation = params.custom_shader_animation;
264        let custom_shader_animation_speed = params.custom_shader_animation_speed;
265        let custom_shader_full_content = params.custom_shader_full_content;
266        let custom_shader_brightness = params.custom_shader_brightness;
267        let custom_shader_channel_paths = params.custom_shader_channel_paths;
268        let custom_shader_cubemap_path = params.custom_shader_cubemap_path;
269        let use_background_as_channel0 = params.use_background_as_channel0;
270        let image_scaling_mode = params.image_scaling_mode;
271        let image_preserve_aspect_ratio = params.image_preserve_aspect_ratio;
272        let cursor_shader_path = params.cursor_shader_path;
273        let cursor_shader_enabled = params.cursor_shader_enabled;
274        let cursor_shader_animation = params.cursor_shader_animation;
275        let cursor_shader_animation_speed = params.cursor_shader_animation_speed;
276
277        let size = window.inner_size();
278        let scale_factor = window.scale_factor();
279
280        // Standard DPI for the platform
281        // macOS typically uses 72 DPI for points, Windows and most Linux use 96 DPI
282        let platform_dpi = if cfg!(target_os = "macos") {
283            72.0
284        } else {
285            96.0
286        };
287
288        // Convert font size from points to pixels for cell size calculation, honoring DPI and scale
289        let base_font_pixels = font_size * platform_dpi / 72.0;
290        let font_size_pixels = (base_font_pixels * scale_factor as f32).max(1.0);
291
292        // Preliminary font lookup to get metrics for accurate cell height
293        let font_manager = par_term_fonts::font_manager::FontManager::new(
294            font_family,
295            font_family_bold,
296            font_family_italic,
297            font_family_bold_italic,
298            font_ranges,
299        )?;
300
301        let (font_ascent, font_descent, font_leading, char_advance) = {
302            let primary_font = font_manager
303                .get_font(0)
304                .expect("Primary font at index 0 must exist after FontManager initialization");
305            let metrics = primary_font.metrics(&[]);
306            let scale = font_size_pixels / metrics.units_per_em as f32;
307
308            // Get advance width of a standard character ('m' is common for monospace width)
309            let glyph_id = primary_font.charmap().map('m');
310            let advance = primary_font.glyph_metrics(&[]).advance_width(glyph_id) * scale;
311
312            (
313                metrics.ascent * scale,
314                metrics.descent * scale,
315                metrics.leading * scale,
316                advance,
317            )
318        };
319
320        // Use font metrics for cell height if line_spacing is 1.0
321        // Natural line height = ascent + descent + leading
322        let natural_line_height = font_ascent + font_descent + font_leading;
323        let char_height = (natural_line_height * line_spacing).max(1.0).round();
324
325        // Scale logical pixel values (config) to physical pixels (wgpu surface)
326        let scale = scale_factor as f32;
327        let window_padding = params.window_padding * scale;
328        let scrollbar_width = params.scrollbar_width * scale;
329
330        // Calculate available space after padding and scrollbar
331        let available_width = (size.width as f32 - window_padding * 2.0 - scrollbar_width).max(0.0);
332        let available_height = (size.height as f32 - window_padding * 2.0).max(0.0);
333
334        // Calculate terminal dimensions based on font size in pixels and spacing
335        let char_width = (char_advance * char_spacing).max(1.0).round(); // Configurable character width (rounded to integer pixels)
336        let cols = (available_width / char_width).max(1.0) as usize;
337        let rows = (available_height / char_height).max(1.0) as usize;
338
339        // Create cell renderer with font fallback support (owns scrollbar)
340        let bg_path = if background_image_enabled {
341            background_image_path
342        } else {
343            None
344        };
345        log::info!(
346            "Renderer::new: background_image_enabled={}, path={:?}",
347            background_image_enabled,
348            bg_path
349        );
350        let cell_renderer = CellRenderer::new(
351            window.clone(),
352            CellRendererConfig {
353                font_family,
354                font_family_bold,
355                font_family_italic,
356                font_family_bold_italic,
357                font_ranges,
358                font_size,
359                cols,
360                rows,
361                window_padding,
362                line_spacing,
363                char_spacing,
364                scrollbar_position,
365                scrollbar_width,
366                scrollbar_thumb_color,
367                scrollbar_track_color,
368                enable_text_shaping,
369                enable_ligatures,
370                enable_kerning,
371                font_antialias,
372                font_hinting,
373                font_thin_strokes,
374                minimum_contrast,
375                vsync_mode,
376                power_preference,
377                window_opacity,
378                background_color,
379                background_image_path: bg_path,
380                background_image_mode,
381                background_image_opacity,
382            },
383        )
384        .await?;
385
386        // Create egui renderer for settings UI
387        let egui_renderer = egui_wgpu::Renderer::new(
388            cell_renderer.device(),
389            cell_renderer.surface_format(),
390            egui_wgpu::RendererOptions {
391                msaa_samples: 1,
392                depth_stencil_format: None,
393                dithering: false,
394                predictable_texture_filtering: false,
395            },
396        );
397
398        // Create graphics renderer for sixel images
399        let graphics_renderer = GraphicsRenderer::new(
400            cell_renderer.device(),
401            cell_renderer.surface_format(),
402            cell_renderer.cell_width(),
403            cell_renderer.cell_height(),
404            cell_renderer.window_padding(),
405            image_scaling_mode,
406            image_preserve_aspect_ratio,
407        )?;
408
409        // Create custom shader renderer if configured
410        let (mut custom_shader_renderer, initial_shader_path) = shaders::init_custom_shader(
411            &cell_renderer,
412            shaders::CustomShaderInitParams {
413                size_width: size.width,
414                size_height: size.height,
415                window_padding,
416                path: custom_shader_path,
417                enabled: custom_shader_enabled,
418                animation: custom_shader_animation,
419                animation_speed: custom_shader_animation_speed,
420                window_opacity,
421                full_content: custom_shader_full_content,
422                brightness: custom_shader_brightness,
423                channel_paths: custom_shader_channel_paths,
424                cubemap_path: custom_shader_cubemap_path,
425                use_background_as_channel0,
426            },
427        );
428
429        // Create cursor shader renderer if configured (separate from background shader)
430        let (mut cursor_shader_renderer, initial_cursor_shader_path) = shaders::init_cursor_shader(
431            &cell_renderer,
432            shaders::CursorShaderInitParams {
433                size_width: size.width,
434                size_height: size.height,
435                window_padding,
436                path: cursor_shader_path,
437                enabled: cursor_shader_enabled,
438                animation: cursor_shader_animation,
439                animation_speed: cursor_shader_animation_speed,
440                window_opacity,
441            },
442        );
443
444        // Sync DPI scale factor to shader renderers for cursor sizing
445        if let Some(ref mut cs) = custom_shader_renderer {
446            cs.set_scale_factor(scale);
447        }
448        if let Some(ref mut cs) = cursor_shader_renderer {
449            cs.set_scale_factor(scale);
450        }
451
452        log::info!(
453            "[renderer] Renderer created: custom_shader_loaded={}, cursor_shader_loaded={}",
454            initial_shader_path.is_some(),
455            initial_cursor_shader_path.is_some()
456        );
457
458        Ok(Self {
459            cell_renderer,
460            graphics_renderer,
461            sixel_graphics: Vec::new(),
462            egui_renderer,
463            custom_shader_renderer,
464            custom_shader_path: initial_shader_path,
465            cursor_shader_renderer,
466            cursor_shader_path: initial_cursor_shader_path,
467            size,
468            dirty: true, // Start dirty to ensure initial render
469            last_scrollbar_state: (usize::MAX, 0, 0, 0, 0, 0, 0, 0, 0, 0), // Force first update
470            cursor_shader_disabled_for_alt_screen: false,
471            debug_text: None,
472            scratch_divider_instances: Vec::new(),
473        })
474    }
475
476    /// Resize the renderer and recalculate grid dimensions based on padding/font metrics
477    pub fn resize(&mut self, new_size: PhysicalSize<u32>) -> (usize, usize) {
478        if new_size.width > 0 && new_size.height > 0 {
479            self.size = new_size;
480            self.dirty = true; // Mark dirty on resize
481            let result = self.cell_renderer.resize(new_size.width, new_size.height);
482
483            // Update graphics renderer cell dimensions
484            self.graphics_renderer.update_cell_dimensions(
485                self.cell_renderer.cell_width(),
486                self.cell_renderer.cell_height(),
487                self.cell_renderer.window_padding(),
488            );
489
490            // Update custom shader renderer dimensions
491            if let Some(ref mut custom_shader) = self.custom_shader_renderer {
492                custom_shader.resize(self.cell_renderer.device(), new_size.width, new_size.height);
493                // Sync cell dimensions for cursor position calculation
494                custom_shader.update_cell_dimensions(
495                    self.cell_renderer.cell_width(),
496                    self.cell_renderer.cell_height(),
497                    self.cell_renderer.window_padding(),
498                );
499            }
500
501            // Update cursor shader renderer dimensions
502            if let Some(ref mut cursor_shader) = self.cursor_shader_renderer {
503                cursor_shader.resize(self.cell_renderer.device(), new_size.width, new_size.height);
504                // Sync cell dimensions for cursor position calculation
505                cursor_shader.update_cell_dimensions(
506                    self.cell_renderer.cell_width(),
507                    self.cell_renderer.cell_height(),
508                    self.cell_renderer.window_padding(),
509                );
510            }
511
512            return result;
513        }
514
515        self.cell_renderer.grid_size()
516    }
517
518    /// Update scale factor and resize so the PTY grid matches the new DPI.
519    pub fn handle_scale_factor_change(
520        &mut self,
521        scale_factor: f64,
522        new_size: PhysicalSize<u32>,
523    ) -> (usize, usize) {
524        let old_scale = self.cell_renderer.scale_factor;
525        self.cell_renderer.update_scale_factor(scale_factor);
526        let new_scale = self.cell_renderer.scale_factor;
527
528        // Rescale physical pixel values when DPI changes
529        if old_scale > 0.0 && (old_scale - new_scale).abs() > f32::EPSILON {
530            // Rescale content_offset_y
531            let logical_offset_y = self.cell_renderer.content_offset_y() / old_scale;
532            let new_physical_offset_y = logical_offset_y * new_scale;
533            self.cell_renderer
534                .set_content_offset_y(new_physical_offset_y);
535            self.graphics_renderer
536                .set_content_offset_y(new_physical_offset_y);
537            if let Some(ref mut cs) = self.custom_shader_renderer {
538                cs.set_content_offset_y(new_physical_offset_y);
539            }
540            if let Some(ref mut cs) = self.cursor_shader_renderer {
541                cs.set_content_offset_y(new_physical_offset_y);
542            }
543
544            // Rescale content_offset_x
545            let logical_offset_x = self.cell_renderer.content_offset_x() / old_scale;
546            let new_physical_offset_x = logical_offset_x * new_scale;
547            self.cell_renderer
548                .set_content_offset_x(new_physical_offset_x);
549            self.graphics_renderer
550                .set_content_offset_x(new_physical_offset_x);
551            if let Some(ref mut cs) = self.custom_shader_renderer {
552                cs.set_content_offset_x(new_physical_offset_x);
553            }
554            if let Some(ref mut cs) = self.cursor_shader_renderer {
555                cs.set_content_offset_x(new_physical_offset_x);
556            }
557
558            // Rescale content_inset_bottom
559            let logical_inset_bottom = self.cell_renderer.content_inset_bottom() / old_scale;
560            let new_physical_inset_bottom = logical_inset_bottom * new_scale;
561            self.cell_renderer
562                .set_content_inset_bottom(new_physical_inset_bottom);
563
564            // Rescale egui_bottom_inset (status bar)
565            if self.cell_renderer.grid.egui_bottom_inset > 0.0 {
566                let logical_egui_bottom = self.cell_renderer.grid.egui_bottom_inset / old_scale;
567                self.cell_renderer.grid.egui_bottom_inset = logical_egui_bottom * new_scale;
568            }
569
570            // Rescale content_inset_right (AI Inspector panel)
571            if self.cell_renderer.grid.content_inset_right > 0.0 {
572                let logical_inset_right = self.cell_renderer.grid.content_inset_right / old_scale;
573                self.cell_renderer.grid.content_inset_right = logical_inset_right * new_scale;
574            }
575
576            // Rescale egui_right_inset
577            if self.cell_renderer.grid.egui_right_inset > 0.0 {
578                let logical_egui_right = self.cell_renderer.grid.egui_right_inset / old_scale;
579                self.cell_renderer.grid.egui_right_inset = logical_egui_right * new_scale;
580            }
581
582            // Rescale window_padding
583            let logical_padding = self.cell_renderer.window_padding() / old_scale;
584            let new_physical_padding = logical_padding * new_scale;
585            self.cell_renderer
586                .update_window_padding(new_physical_padding);
587
588            // Sync new scale factor to shader renderers for cursor sizing
589            if let Some(ref mut cs) = self.custom_shader_renderer {
590                cs.set_scale_factor(new_scale);
591            }
592            if let Some(ref mut cs) = self.cursor_shader_renderer {
593                cs.set_scale_factor(new_scale);
594            }
595        }
596
597        self.resize(new_size)
598    }
599}
600
601// Layout and sizing accessors — simple getters/setters for grid geometry, padding,
602// and content offsets. Co-located here with resize/handle_scale_factor_change since
603// all of these deal with the spatial layout of the renderer.
604impl Renderer {
605    /// Get the current size
606    pub fn size(&self) -> PhysicalSize<u32> {
607        self.size
608    }
609
610    /// Get the current grid dimensions (columns, rows)
611    pub fn grid_size(&self) -> (usize, usize) {
612        self.cell_renderer.grid_size()
613    }
614
615    /// Get cell width in pixels
616    pub fn cell_width(&self) -> f32 {
617        self.cell_renderer.cell_width()
618    }
619
620    /// Get cell height in pixels
621    pub fn cell_height(&self) -> f32 {
622        self.cell_renderer.cell_height()
623    }
624
625    /// Get window padding in physical pixels (scaled by DPI)
626    pub fn window_padding(&self) -> f32 {
627        self.cell_renderer.window_padding()
628    }
629
630    /// Get the scrollbar width in physical pixels
631    pub fn scrollbar_width(&self) -> f32 {
632        self.cell_renderer.scrollbar.width()
633    }
634
635    /// Returns total non-terminal pixel overhead as (horizontal_px, vertical_px).
636    /// See `CellRenderer::chrome_overhead` for details.
637    pub fn chrome_overhead(&self) -> (f32, f32) {
638        self.cell_renderer.chrome_overhead()
639    }
640
641    /// Get the vertical content offset in physical pixels (e.g., tab bar height scaled by DPI)
642    pub fn content_offset_y(&self) -> f32 {
643        self.cell_renderer.content_offset_y()
644    }
645
646    /// Get the display scale factor (e.g., 2.0 on Retina displays)
647    pub fn scale_factor(&self) -> f32 {
648        self.cell_renderer.scale_factor
649    }
650
651    /// Set the vertical content offset (e.g., tab bar height) in logical pixels.
652    /// The offset is scaled by the display scale factor to physical pixels internally,
653    /// since the cell renderer works in physical pixel coordinates while egui (tab bar)
654    /// uses logical pixels.
655    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
656    pub fn set_content_offset_y(&mut self, logical_offset: f32) -> Option<(usize, usize)> {
657        // Scale from logical pixels (egui/config) to physical pixels (wgpu surface)
658        let physical_offset = logical_offset * self.cell_renderer.scale_factor;
659        let result = self.cell_renderer.set_content_offset_y(physical_offset);
660        // Always update graphics renderer offset, even if grid size didn't change
661        self.graphics_renderer.set_content_offset_y(physical_offset);
662        // Update custom shader renderer content offset
663        if let Some(ref mut custom_shader) = self.custom_shader_renderer {
664            custom_shader.set_content_offset_y(physical_offset);
665        }
666        // Update cursor shader renderer content offset
667        if let Some(ref mut cursor_shader) = self.cursor_shader_renderer {
668            cursor_shader.set_content_offset_y(physical_offset);
669        }
670        if result.is_some() {
671            self.dirty = true;
672        }
673        result
674    }
675
676    /// Get the horizontal content offset in physical pixels
677    pub fn content_offset_x(&self) -> f32 {
678        self.cell_renderer.content_offset_x()
679    }
680
681    /// Set the horizontal content offset (e.g., tab bar on left) in logical pixels.
682    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
683    pub fn set_content_offset_x(&mut self, logical_offset: f32) -> Option<(usize, usize)> {
684        let physical_offset = logical_offset * self.cell_renderer.scale_factor;
685        let result = self.cell_renderer.set_content_offset_x(physical_offset);
686        self.graphics_renderer.set_content_offset_x(physical_offset);
687        if let Some(ref mut custom_shader) = self.custom_shader_renderer {
688            custom_shader.set_content_offset_x(physical_offset);
689        }
690        if let Some(ref mut cursor_shader) = self.cursor_shader_renderer {
691            cursor_shader.set_content_offset_x(physical_offset);
692        }
693        if result.is_some() {
694            self.dirty = true;
695        }
696        result
697    }
698
699    /// Get the bottom content inset in physical pixels
700    pub fn content_inset_bottom(&self) -> f32 {
701        self.cell_renderer.content_inset_bottom()
702    }
703
704    /// Get the right content inset in physical pixels
705    pub fn content_inset_right(&self) -> f32 {
706        self.cell_renderer.content_inset_right()
707    }
708
709    /// Set the bottom content inset (e.g., tab bar at bottom) in logical pixels.
710    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
711    pub fn set_content_inset_bottom(&mut self, logical_inset: f32) -> Option<(usize, usize)> {
712        let physical_inset = logical_inset * self.cell_renderer.scale_factor;
713        let result = self.cell_renderer.set_content_inset_bottom(physical_inset);
714        if result.is_some() {
715            self.dirty = true;
716            // Invalidate the scrollbar cache — the track height depends on
717            // the bottom inset, so the scrollbar must be repositioned.
718            self.last_scrollbar_state = (usize::MAX, 0, 0, 0, 0, 0, 0, 0, 0, 0);
719        }
720        result
721    }
722
723    /// Set the right content inset (e.g., AI Inspector panel) in logical pixels.
724    /// Returns Some((cols, rows)) if grid size changed, None otherwise.
725    pub fn set_content_inset_right(&mut self, logical_inset: f32) -> Option<(usize, usize)> {
726        let physical_inset = logical_inset * self.cell_renderer.scale_factor;
727        let result = self.cell_renderer.set_content_inset_right(physical_inset);
728
729        // Also update custom shader renderer to exclude panel area from effects
730        if let Some(ref mut custom_shader) = self.custom_shader_renderer {
731            custom_shader.set_content_inset_right(physical_inset);
732        }
733        // Also update cursor shader renderer
734        if let Some(ref mut cursor_shader) = self.cursor_shader_renderer {
735            cursor_shader.set_content_inset_right(physical_inset);
736        }
737
738        if result.is_some() {
739            self.dirty = true;
740            // Invalidate the scrollbar cache so the next update_scrollbar()
741            // repositions the scrollbar at the new right inset. Without this,
742            // the cache guard sees the same (scroll_offset, visible_lines,
743            // total_lines) tuple and skips the GPU upload, leaving the
744            // scrollbar stuck at the old position.
745            self.last_scrollbar_state = (usize::MAX, 0, 0, 0, 0, 0, 0, 0, 0, 0);
746        }
747        result
748    }
749
750    /// Set the additional bottom inset from egui panels (status bar, tmux bar).
751    ///
752    /// This inset reduces the terminal grid height so content does not render
753    /// behind the status bar. Also affects scrollbar bounds.
754    /// Returns `Some((cols, rows))` if the grid was resized.
755    pub fn set_egui_bottom_inset(&mut self, logical_inset: f32) -> Option<(usize, usize)> {
756        let physical_inset = logical_inset * self.cell_renderer.scale_factor;
757        if (self.cell_renderer.grid.egui_bottom_inset - physical_inset).abs() > f32::EPSILON {
758            self.cell_renderer.grid.egui_bottom_inset = physical_inset;
759            let (w, h) = (
760                self.cell_renderer.config.width,
761                self.cell_renderer.config.height,
762            );
763            return Some(self.cell_renderer.resize(w, h));
764        }
765        None
766    }
767
768    /// Set the additional right inset from egui panels (AI Inspector).
769    ///
770    /// This inset is added to `content_inset_right` for scrollbar bounds only.
771    /// egui panels already claim space before wgpu rendering, so this doesn't
772    /// affect the terminal grid sizing.
773    pub fn set_egui_right_inset(&mut self, logical_inset: f32) {
774        let physical_inset = logical_inset * self.cell_renderer.scale_factor;
775        self.cell_renderer.grid.egui_right_inset = physical_inset;
776    }
777}