Skip to main content

par_term_render/renderer/
graphics.rs

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