Skip to main content

par_term_render/renderer/
mod.rs

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