Skip to main content

par_term_render/renderer/
graphics.rs

1use super::Renderer;
2use crate::graphics_renderer::GraphicRenderInfo;
3use anyhow::Result;
4
5/// A single prettifier graphic entry passed to [`Renderer::update_prettifier_graphics`]:
6/// `(texture_id, rgba_data, pixel_width, pixel_height, screen_row, col)`.
7pub type PrettifierGraphicRef<'a> = (u64, &'a [u8], u32, u32, isize, usize);
8
9impl Renderer {
10    /// Update graphics textures (Sixel, iTerm2, Kitty)
11    ///
12    /// # Arguments
13    /// * `graphics` - Graphics from the terminal with RGBA data
14    /// * `view_scroll_offset` - Current view scroll offset (0 = viewing current content)
15    /// * `scrollback_len` - Total lines in scrollback buffer
16    /// * `visible_rows` - Number of visible rows in terminal
17    pub fn update_graphics(
18        &mut self,
19        graphics: &[par_term_emu_core_rust::graphics::TerminalGraphic],
20        view_scroll_offset: usize,
21        scrollback_len: usize,
22        visible_rows: usize,
23    ) -> Result<()> {
24        // Track whether we had graphics before this update (to detect removal)
25        let had_graphics = !self.sixel_graphics.is_empty();
26
27        // Clear old graphics list
28        self.sixel_graphics.clear();
29
30        // Calculate the view window in absolute terms
31        // total_lines = scrollback_len + visible_rows
32        // When scroll_offset = 0, we view lines [scrollback_len, scrollback_len + visible_rows)
33        // When scroll_offset > 0, we view earlier lines
34        let total_lines = scrollback_len + visible_rows;
35        let view_end = total_lines.saturating_sub(view_scroll_offset);
36        let view_start = view_end.saturating_sub(visible_rows);
37
38        // Process each graphic
39        for graphic in graphics {
40            // Use the unique ID from the graphic (stable across position changes)
41            let id = graphic.id;
42            let (col, row) = graphic.position;
43
44            // Convert scroll_offset_rows from the core library's cell units (graphic.cell_dimensions.1
45            // pixels per row, defaulting to 2) into display cell rows (self.cell_renderer.cell_height()
46            // pixels per row).
47            let core_cell_height = graphic
48                .cell_dimensions
49                .map(|(_, h)| h as f32)
50                .unwrap_or(2.0)
51                .max(1.0);
52            let display_cell_height = self.cell_renderer.cell_height().max(1.0);
53            let scroll_offset_in_display_rows = (graphic.scroll_offset_rows as f32
54                * core_cell_height
55                / display_cell_height)
56                .round() as usize;
57
58            // Calculate screen row based on whether this is a scrollback graphic or current
59            let screen_row: isize = if let Some(sb_row) = graphic.scrollback_row {
60                // Scrollback graphic: sb_row is absolute index in scrollback
61                // Screen row = sb_row - view_start
62                sb_row as isize - view_start as isize
63            } else {
64                // Current graphic: position is relative to visible area
65                // Absolute position = scrollback_len + row - scroll_offset_in_display_rows
66                // This keeps the graphic at its original absolute position as scrollback grows
67                let absolute_row =
68                    scrollback_len.saturating_sub(scroll_offset_in_display_rows) + row;
69
70                log::trace!(
71                    "[RENDERER] CALC: scrollback_len={}, row={}, scroll_offset_rows={}, scroll_in_display_rows={}, absolute_row={}, view_start={}, screen_row={}",
72                    scrollback_len,
73                    row,
74                    graphic.scroll_offset_rows,
75                    scroll_offset_in_display_rows,
76                    absolute_row,
77                    view_start,
78                    absolute_row as isize - view_start as isize
79                );
80
81                absolute_row as isize - view_start as isize
82            };
83
84            log::debug!(
85                "[RENDERER] Graphics update: id={}, protocol={:?}, pos=({},{}), screen_row={}, scrollback_row={:?}, scroll_offset_rows={}, size={}x{}, view=[{},{})",
86                id,
87                graphic.protocol,
88                col,
89                row,
90                screen_row,
91                graphic.scrollback_row,
92                graphic.scroll_offset_rows,
93                graphic.width,
94                graphic.height,
95                view_start,
96                view_end
97            );
98
99            // Create or update texture in cache
100            self.graphics_renderer.get_or_create_texture(
101                self.cell_renderer.device(),
102                self.cell_renderer.queue(),
103                id,
104                &graphic.pixels, // RGBA pixel data (Arc<Vec<u8>>)
105                graphic.width as u32,
106                graphic.height as u32,
107            )?;
108
109            // Add to render list with position and dimensions
110            // Calculate size in cells (rounding up to cover all affected cells)
111            let width_cells =
112                ((graphic.width as f32 / self.cell_renderer.cell_width()).ceil() as usize).max(1);
113            let height_cells =
114                ((graphic.height as f32 / self.cell_renderer.cell_height()).ceil() as usize).max(1);
115
116            // Calculate effective clip rows based on screen position
117            // If screen_row < 0, we need to clip that many rows from the top
118            // If screen_row >= 0, no clipping needed (we can see the full graphic)
119            let effective_clip_rows = if screen_row < 0 {
120                (-screen_row) as usize
121            } else {
122                0
123            };
124
125            self.sixel_graphics.push(GraphicRenderInfo {
126                id,
127                screen_row,
128                col,
129                width_cells,
130                height_cells,
131                alpha: 1.0,
132                scroll_offset_rows: effective_clip_rows,
133            });
134        }
135
136        // Mark dirty when graphics change (added or removed)
137        if !graphics.is_empty() || had_graphics {
138            self.dirty = true;
139        }
140
141        Ok(())
142    }
143
144    /// Compute positioned graphics list for a single pane without touching `self.sixel_graphics`.
145    ///
146    /// Shares the same texture cache as the global path so textures are never duplicated.
147    ///
148    /// Returns a `Vec` of [`GraphicRenderInfo`] ready to pass to
149    /// [`GraphicsRenderer::render_for_pane`].
150    pub fn update_pane_graphics(
151        &mut self,
152        graphics: &[par_term_emu_core_rust::graphics::TerminalGraphic],
153        view_scroll_offset: usize,
154        scrollback_len: usize,
155        visible_rows: usize,
156    ) -> Result<Vec<GraphicRenderInfo>> {
157        let total_lines = scrollback_len + visible_rows;
158        let view_end = total_lines.saturating_sub(view_scroll_offset);
159        let view_start = view_end.saturating_sub(visible_rows);
160
161        log::debug!(
162            "[PANE_GRAPHICS] update_pane_graphics: scrollback_len={}, visible_rows={}, view_scroll_offset={}, total_lines={}, view_start={}, view_end={}, graphics_count={}",
163            scrollback_len,
164            visible_rows,
165            view_scroll_offset,
166            total_lines,
167            view_start,
168            view_end,
169            graphics.len()
170        );
171
172        let mut positioned = Vec::new();
173
174        for graphic in graphics {
175            let id = graphic.id;
176            let (col, row) = graphic.position;
177
178            // Convert scroll_offset_rows from the core library's cell units (graphic.cell_dimensions.1
179            // pixels per row, defaulting to 2) into display cell rows (self.cell_renderer.cell_height()
180            // pixels per row).  Without this conversion, the absolute-row formula is wrong whenever
181            // the graphic was created before set_cell_dimensions() was called on the pane terminal.
182            let core_cell_height = graphic
183                .cell_dimensions
184                .map(|(_, h)| h as f32)
185                .unwrap_or(2.0)
186                .max(1.0);
187            let display_cell_height = self.cell_renderer.cell_height().max(1.0);
188            let scroll_offset_in_display_rows = (graphic.scroll_offset_rows as f32
189                * core_cell_height
190                / display_cell_height)
191                .round() as usize;
192
193            let screen_row: isize = if let Some(sb_row) = graphic.scrollback_row {
194                let sr = sb_row as isize - view_start as isize;
195                log::debug!(
196                    "[PANE_GRAPHICS] scrollback graphic id={}: sb_row={}, view_start={}, screen_row={}",
197                    id,
198                    sb_row,
199                    view_start,
200                    sr
201                );
202                sr
203            } else {
204                let absolute_row =
205                    scrollback_len.saturating_sub(scroll_offset_in_display_rows) + row;
206                let sr = absolute_row as isize - view_start as isize;
207                log::debug!(
208                    "[PANE_GRAPHICS] current graphic id={}: scrollback_len={}, scroll_offset_rows={}, core_cell_h={}, disp_cell_h={}, scroll_in_display_rows={}, row={}, absolute_row={}, view_start={}, screen_row={}",
209                    id,
210                    scrollback_len,
211                    graphic.scroll_offset_rows,
212                    core_cell_height,
213                    display_cell_height,
214                    scroll_offset_in_display_rows,
215                    row,
216                    absolute_row,
217                    view_start,
218                    sr
219                );
220                sr
221            };
222
223            // Upload / refresh texture in the shared cache
224            self.graphics_renderer.get_or_create_texture(
225                self.cell_renderer.device(),
226                self.cell_renderer.queue(),
227                id,
228                &graphic.pixels,
229                graphic.width as u32,
230                graphic.height as u32,
231            )?;
232
233            let width_cells =
234                ((graphic.width as f32 / self.cell_renderer.cell_width()).ceil() as usize).max(1);
235            let height_cells =
236                ((graphic.height as f32 / self.cell_renderer.cell_height()).ceil() as usize).max(1);
237
238            let effective_clip_rows = if screen_row < 0 {
239                (-screen_row) as usize
240            } else {
241                0
242            };
243
244            positioned.push(GraphicRenderInfo {
245                id,
246                screen_row,
247                col,
248                width_cells,
249                height_cells,
250                alpha: 1.0,
251                scroll_offset_rows: effective_clip_rows,
252            });
253        }
254
255        Ok(positioned)
256    }
257
258    /// Render inline graphics (Sixel/iTerm2/Kitty) for a single split pane.
259    ///
260    /// Uses the same `surface_view` as the cell render pass (with `LoadOp::Load`) so
261    /// graphics are composited on top of already-rendered cells.  A scissor rect derived
262    /// from `viewport` clips output to the pane's bounds.
263    pub(crate) fn render_pane_sixel_graphics(
264        &mut self,
265        surface_view: &wgpu::TextureView,
266        viewport: &crate::cell_renderer::PaneViewport,
267        graphics: &[par_term_emu_core_rust::graphics::TerminalGraphic],
268        scroll_offset: usize,
269        scrollback_len: usize,
270        visible_rows: usize,
271    ) -> Result<()> {
272        let positioned =
273            self.update_pane_graphics(graphics, scroll_offset, scrollback_len, visible_rows)?;
274
275        if positioned.is_empty() {
276            return Ok(());
277        }
278
279        let mut encoder =
280            self.cell_renderer
281                .device()
282                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
283                    label: Some("pane sixel encoder"),
284                });
285
286        {
287            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
288                label: Some("pane sixel render pass"),
289                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
290                    view: surface_view,
291                    resolve_target: None,
292                    ops: wgpu::Operations {
293                        load: wgpu::LoadOp::Load,
294                        store: wgpu::StoreOp::Store,
295                    },
296                    depth_slice: None,
297                })],
298                depth_stencil_attachment: None,
299                timestamp_writes: None,
300                occlusion_query_set: None,
301            });
302
303            // Clip to pane bounds
304            let (sx, sy, sw, sh) = viewport.to_scissor_rect();
305            render_pass.set_scissor_rect(sx, sy, sw, sh);
306
307            let (ox, oy) = viewport.content_origin();
308
309            log::debug!(
310                "[PANE_GRAPHICS] render_pane_sixel_graphics: scissor=({},{},{},{}), origin=({},{}), window={}x{}, positioned_count={}",
311                sx,
312                sy,
313                sw,
314                sh,
315                ox,
316                oy,
317                self.size.width,
318                self.size.height,
319                positioned.len()
320            );
321            for g in &positioned {
322                log::debug!(
323                    "[PANE_GRAPHICS]   positioned: id={}, screen_row={}, col={}, width_cells={}, height_cells={}, clip_rows={}",
324                    g.id,
325                    g.screen_row,
326                    g.col,
327                    g.width_cells,
328                    g.height_cells,
329                    g.scroll_offset_rows
330                );
331            }
332
333            self.graphics_renderer.render_for_pane(
334                self.cell_renderer.device(),
335                self.cell_renderer.queue(),
336                &mut render_pass,
337                &positioned,
338                crate::graphics_renderer::PaneRenderGeometry {
339                    window_width: self.size.width as f32,
340                    window_height: self.size.height as f32,
341                    pane_origin_x: ox,
342                    pane_origin_y: oy,
343                },
344            )?;
345        }
346
347        self.cell_renderer
348            .queue()
349            .submit(std::iter::once(encoder.finish()));
350
351        Ok(())
352    }
353
354    /// Upload prettifier diagram graphics and append them to the sixel render list.
355    ///
356    /// Each graphic is specified by:
357    /// - `id`: Unique texture ID (should not collide with terminal graphic IDs)
358    /// - `rgba_data`: Pre-decoded RGBA pixel data
359    /// - `pixel_width`, `pixel_height`: Image dimensions in pixels
360    /// - `screen_row`: Row on screen where the graphic starts (0-based)
361    /// - `col`: Column on screen where the graphic starts
362    ///
363    /// Graphics are composited on top of terminal cells in the same pass as
364    /// sixel/iTerm2/Kitty graphics.
365    pub fn update_prettifier_graphics(
366        &mut self,
367        graphics: &[PrettifierGraphicRef<'_>],
368    ) -> Result<()> {
369        for &(id, rgba_data, pixel_width, pixel_height, screen_row, col) in graphics {
370            if rgba_data.is_empty() || pixel_width == 0 || pixel_height == 0 {
371                continue;
372            }
373
374            // Upload / refresh texture in the shared cache
375            self.graphics_renderer.get_or_create_texture(
376                self.cell_renderer.device(),
377                self.cell_renderer.queue(),
378                id,
379                rgba_data,
380                pixel_width,
381                pixel_height,
382            )?;
383
384            // Calculate size in cells
385            let width_cells =
386                ((pixel_width as f32 / self.cell_renderer.cell_width()).ceil() as usize).max(1);
387            let height_cells =
388                ((pixel_height as f32 / self.cell_renderer.cell_height()).ceil() as usize).max(1);
389
390            // Clip rows if graphic extends above the viewport
391            let effective_clip_rows = if screen_row < 0 {
392                (-screen_row) as usize
393            } else {
394                0
395            };
396
397            self.sixel_graphics.push(GraphicRenderInfo {
398                id,
399                screen_row,
400                col,
401                width_cells,
402                height_cells,
403                alpha: 1.0,
404                scroll_offset_rows: effective_clip_rows,
405            });
406        }
407
408        if !graphics.is_empty() {
409            self.dirty = true;
410        }
411
412        Ok(())
413    }
414
415    /// Clear all cached sixel textures
416    pub fn clear_sixel_cache(&mut self) {
417        self.graphics_renderer.clear_cache();
418        self.sixel_graphics.clear();
419        self.dirty = true;
420    }
421
422    /// Get the number of cached sixel textures
423    pub fn sixel_cache_size(&self) -> usize {
424        self.graphics_renderer.cache_size()
425    }
426
427    /// Remove a specific sixel texture from cache
428    pub fn remove_sixel_texture(&mut self, id: u64) {
429        self.graphics_renderer.remove_texture(id);
430        self.sixel_graphics.retain(|g| g.id != id);
431        self.dirty = true;
432    }
433}