Skip to main content

par_term_render/renderer/
graphics.rs

1use super::Renderer;
2use crate::cell_renderer::Cell;
3use crate::graphics_renderer::GraphicRenderInfo;
4use anyhow::Result;
5use par_term_emu_core_rust::graphics::TerminalGraphic;
6use par_term_emu_core_rust::graphics::placeholder::{PLACEHOLDER_CHAR, diacritic_to_number};
7
8/// Synthetic GraphicRenderInfo id namespace for Kitty virtual placements.
9///
10/// Virtual placements are keyed by the Kitty image_id (u32). The shared texture
11/// cache uses u64 ids drawn from `TerminalGraphic::id`. To avoid collisions we
12/// flag virtual-placement ids with the high bit. Phase 1 already guarantees
13/// `TerminalGraphic::id` does not use this bit.
14const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
15
16/// Build a synthetic u64 cache id from a Kitty image_id + placement_id.
17fn virtual_placement_cache_id(image_id: u32, placement_id: u32) -> u64 {
18    VIRTUAL_PLACEMENT_ID_FLAG | ((placement_id as u64) << 32) | image_id as u64
19}
20
21/// Compact RGBA buffer statistics for debug diagnostics.
22fn rgba_diag_summary(pixels: &[u8]) -> String {
23    let (rgba_pixels, _) = pixels.as_chunks::<4>();
24    let alpha = rgba_pixels.iter().filter(|px| px[3] != 0).count();
25    let first = pixels
26        .get(..4)
27        .map(|px| format!("{:02x}{:02x}{:02x}{:02x}", px[0], px[1], px[2], px[3]))
28        .unwrap_or_else(|| "none".to_string());
29    format!(
30        "buf_len={}, non_zero_alpha={}, first_rgba={}",
31        pixels.len(),
32        alpha,
33        first
34    )
35}
36
37/// Decode a Kitty Unicode-placeholder cell.
38///
39/// Returns `(image_id, placement_id, row_idx, col_idx)` if the cell holds a
40/// placeholder grapheme. The first two diacritics encode the cell's row/column
41/// index *within the placement*; the optional third diacritic supplies the
42/// most-significant byte of the image id; the cell foreground colour supplies
43/// the lower 24 bits of the image id.
44///
45/// We do not currently extract a per-placement placement_id from the underline
46/// colour — par-term-render's `Cell` representation flattens that out. Phase 1
47/// stores virtual placements keyed by `(image_id, placement_id)` with
48/// `placement_id == 0` being the common case, and `get_placeholder_graphic`
49/// falls back to any placement_id for an image when 0 is requested.
50fn decode_placeholder_cell(cell: &Cell) -> Option<(u32, u32, u16, u16)> {
51    let mut chars = cell.grapheme.chars();
52    if chars.next()? != PLACEHOLDER_CHAR {
53        return None;
54    }
55    let row_idx = diacritic_to_number(chars.next()?)?;
56    let col_idx = diacritic_to_number(chars.next()?)?;
57    // The MSB diacritic only encodes 0..=255 per spec, even though the table
58    // now exposes 297 entries; clamp the high indices to 0 so we never overflow
59    // the u8 image-ID byte.
60    let msb_u8 = chars
61        .next()
62        .and_then(diacritic_to_number)
63        .map(|n| if n <= u8::MAX as u16 { n as u8 } else { 0 })
64        .unwrap_or(0);
65
66    // fg_color is stored as RGBA; encode (R<<16 | G<<8 | B) as the low 24 bits
67    // of the image id, then OR in the MSB diacritic as the top byte.
68    let [r, g, b, _a] = cell.fg_color;
69    let image_id = ((msb_u8 as u32) << 24) | ((r as u32) << 16) | ((g as u32) << 8) | b as u32;
70    Some((image_id, 0, row_idx, col_idx))
71}
72
73/// One placeholder run grouped by `(image_id, placement_id)`.
74///
75/// Public to the crate so renderer tests can assert the grouping output without
76/// exercising the GPU pipeline.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub(crate) struct VirtualPlacementHit {
79    pub image_id: u32,
80    pub placement_id: u32,
81    pub start_col: usize,
82    pub start_row: usize,
83    pub width_cells: usize,
84    pub height_cells: usize,
85}
86
87/// Scan a cell grid for Kitty placeholder runs and return one
88/// `VirtualPlacementHit` per contiguous `(image_id, placement_id)` group.
89///
90/// The bounding box approach is sufficient for the standard "rectangle of
91/// placeholders" emitted by clients like par-textual-image; we don't attempt to
92/// split L-shaped or sparse layouts (the spec doesn't really define what those
93/// would mean, and no client produces them).
94pub(crate) fn scan_placeholder_cells(
95    cells: &[Cell],
96    cols: usize,
97    rows: usize,
98) -> Vec<VirtualPlacementHit> {
99    use std::collections::HashMap;
100
101    // (image_id, placement_id) -> (min_col, min_row, max_col, max_row)
102    let mut bboxes: HashMap<(u32, u32), (usize, usize, usize, usize)> = HashMap::new();
103
104    for row in 0..rows {
105        let row_start = row * cols;
106        if row_start >= cells.len() {
107            break;
108        }
109        let row_end = (row_start + cols).min(cells.len());
110        for (col_off, cell) in cells[row_start..row_end].iter().enumerate() {
111            let Some((image_id, placement_id, _r_idx, _c_idx)) = decode_placeholder_cell(cell)
112            else {
113                continue;
114            };
115            let col = col_off;
116            bboxes
117                .entry((image_id, placement_id))
118                .and_modify(|b| {
119                    if col < b.0 {
120                        b.0 = col;
121                    }
122                    if row < b.1 {
123                        b.1 = row;
124                    }
125                    if col > b.2 {
126                        b.2 = col;
127                    }
128                    if row > b.3 {
129                        b.3 = row;
130                    }
131                })
132                .or_insert((col, row, col, row));
133        }
134    }
135
136    let mut hits: Vec<VirtualPlacementHit> = bboxes
137        .into_iter()
138        .map(
139            |((image_id, placement_id), (min_c, min_r, max_c, max_r))| VirtualPlacementHit {
140                image_id,
141                placement_id,
142                start_col: min_c,
143                start_row: min_r,
144                width_cells: max_c - min_c + 1,
145                height_cells: max_r - min_r + 1,
146            },
147        )
148        .collect();
149    // Stable order so callers/tests don't depend on HashMap iteration order.
150    hits.sort_by_key(|h| (h.image_id, h.placement_id, h.start_row, h.start_col));
151    hits
152}
153
154impl Renderer {
155    /// Update graphics textures (Sixel, iTerm2, Kitty)
156    ///
157    /// # Arguments
158    /// * `graphics` - Graphics from the terminal with RGBA data
159    /// * `view_scroll_offset` - Current view scroll offset (0 = viewing current content)
160    /// * `scrollback_len` - Total lines in scrollback buffer
161    /// * `visible_rows` - Number of visible rows in terminal
162    pub fn update_graphics(
163        &mut self,
164        graphics: &[par_term_emu_core_rust::graphics::TerminalGraphic],
165        view_scroll_offset: usize,
166        scrollback_len: usize,
167        visible_rows: usize,
168    ) -> Result<()> {
169        // Track whether we had graphics before this update (to detect removal)
170        let had_graphics = !self.sixel_graphics.is_empty();
171
172        // Clear old graphics list
173        self.sixel_graphics.clear();
174
175        // Calculate the view window in absolute terms
176        // total_lines = scrollback_len + visible_rows
177        // When scroll_offset = 0, we view lines [scrollback_len, scrollback_len + visible_rows)
178        // When scroll_offset > 0, we view earlier lines
179        let total_lines = scrollback_len + visible_rows;
180        let view_end = total_lines.saturating_sub(view_scroll_offset);
181        let view_start = view_end.saturating_sub(visible_rows);
182
183        // Process each graphic
184        for graphic in graphics {
185            // Use the unique ID from the graphic (stable across position changes)
186            let id = graphic.id;
187            let (col, row) = graphic.position;
188
189            // Convert scroll_offset_rows from the core library's cell units (graphic.cell_dimensions.1
190            // pixels per row, defaulting to 2) into display cell rows (self.cell_renderer.cell_height()
191            // pixels per row).
192            let core_cell_height = graphic
193                .cell_dimensions
194                .map(|(_, h)| h as f32)
195                .unwrap_or(2.0)
196                .max(1.0);
197            let display_cell_height = self.cell_renderer.cell_height().max(1.0);
198            let scroll_offset_in_display_rows = (graphic.scroll_offset_rows as f32
199                * core_cell_height
200                / display_cell_height)
201                .round() as usize;
202
203            // Calculate screen row based on whether this is a scrollback graphic or current
204            let screen_row: isize = if let Some(sb_row) = graphic.scrollback_row {
205                // Scrollback graphic: sb_row is absolute index in scrollback
206                // Screen row = sb_row - view_start
207                sb_row as isize - view_start as isize
208            } else {
209                // Current graphic: position is relative to visible area
210                // Absolute position = scrollback_len + row - scroll_offset_in_display_rows
211                // This keeps the graphic at its original absolute position as scrollback grows
212                let absolute_row =
213                    scrollback_len.saturating_sub(scroll_offset_in_display_rows) + row;
214
215                log::trace!(
216                    "[RENDERER] CALC: scrollback_len={}, row={}, scroll_offset_rows={}, scroll_in_display_rows={}, absolute_row={}, view_start={}, screen_row={}",
217                    scrollback_len,
218                    row,
219                    graphic.scroll_offset_rows,
220                    scroll_offset_in_display_rows,
221                    absolute_row,
222                    view_start,
223                    absolute_row as isize - view_start as isize
224                );
225
226                absolute_row as isize - view_start as isize
227            };
228
229            if log::log_enabled!(log::Level::Debug) {
230                log::debug!(
231                    "[RENDERER] Graphics update: id={}, protocol={:?}, pos=({},{}), screen_row={}, scrollback_row={:?}, scroll_offset_rows={}, size={}x{}, pixels=({}), view=[{},{})",
232                    id,
233                    graphic.protocol,
234                    col,
235                    row,
236                    screen_row,
237                    graphic.scrollback_row,
238                    graphic.scroll_offset_rows,
239                    graphic.width,
240                    graphic.height,
241                    rgba_diag_summary(&graphic.pixels),
242                    view_start,
243                    view_end
244                );
245            }
246
247            // Create or update texture in cache
248            self.graphics_renderer.get_or_create_texture(
249                self.cell_renderer.device(),
250                self.cell_renderer.queue(),
251                id,
252                &graphic.pixels, // RGBA pixel data (Arc<Vec<u8>>)
253                graphic.width as u32,
254                graphic.height as u32,
255            )?;
256
257            // Add to render list with position and dimensions
258            let (width_cells, height_cells) = graphic.cell_span(
259                self.cell_renderer.cell_width() as u32,
260                self.cell_renderer.cell_height() as u32,
261            );
262
263            // Calculate effective clip rows based on screen position
264            // If screen_row < 0, we need to clip that many rows from the top
265            // If screen_row >= 0, no clipping needed (we can see the full graphic)
266            let effective_clip_rows = if screen_row < 0 {
267                (-screen_row) as usize
268            } else {
269                0
270            };
271
272            self.sixel_graphics.push(GraphicRenderInfo {
273                id,
274                screen_row,
275                col,
276                width_cells,
277                height_cells,
278                alpha: 1.0,
279                scroll_offset_rows: effective_clip_rows,
280                destination_offset_x: graphic.placement.x_offset,
281                destination_offset_y: graphic.placement.y_offset,
282                source_crop: [
283                    graphic.placement.source_x,
284                    graphic.placement.source_y,
285                    graphic.placement.source_width,
286                    graphic.placement.source_height,
287                ],
288                has_cols: graphic.placement.columns.filter(|&v| v > 0).is_some(),
289                has_rows: graphic.placement.rows.filter(|&v| v > 0).is_some(),
290            });
291        }
292
293        // Mark dirty when graphics change (added or removed)
294        if !graphics.is_empty() || had_graphics {
295            self.dirty = true;
296        }
297
298        Ok(())
299    }
300
301    /// Compute positioned graphics list for a single pane without touching `self.sixel_graphics`.
302    ///
303    /// Shares the same texture cache as the global path so textures are never duplicated.
304    ///
305    /// Returns a `Vec` of [`GraphicRenderInfo`] ready to pass to
306    /// [`crate::graphics_renderer::GraphicsRenderer::render_for_pane`].
307    pub fn update_pane_graphics(
308        &mut self,
309        graphics: &[par_term_emu_core_rust::graphics::TerminalGraphic],
310        view_scroll_offset: usize,
311        scrollback_len: usize,
312        visible_rows: usize,
313    ) -> Result<Vec<GraphicRenderInfo>> {
314        let total_lines = scrollback_len + visible_rows;
315        let view_end = total_lines.saturating_sub(view_scroll_offset);
316        let view_start = view_end.saturating_sub(visible_rows);
317
318        log::debug!(
319            "[PANE_GRAPHICS] update_pane_graphics: scrollback_len={}, visible_rows={}, view_scroll_offset={}, total_lines={}, view_start={}, view_end={}, graphics_count={}",
320            scrollback_len,
321            visible_rows,
322            view_scroll_offset,
323            total_lines,
324            view_start,
325            view_end,
326            graphics.len()
327        );
328
329        let mut positioned = Vec::new();
330
331        for graphic in graphics {
332            let id = graphic.id;
333            let (col, row) = graphic.position;
334
335            // Convert scroll_offset_rows from the core library's cell units (graphic.cell_dimensions.1
336            // pixels per row, defaulting to 2) into display cell rows (self.cell_renderer.cell_height()
337            // pixels per row).  Without this conversion, the absolute-row formula is wrong whenever
338            // the graphic was created before set_cell_dimensions() was called on the pane terminal.
339            let core_cell_height = graphic
340                .cell_dimensions
341                .map(|(_, h)| h as f32)
342                .unwrap_or(2.0)
343                .max(1.0);
344            let display_cell_height = self.cell_renderer.cell_height().max(1.0);
345            let scroll_offset_in_display_rows = (graphic.scroll_offset_rows as f32
346                * core_cell_height
347                / display_cell_height)
348                .round() as usize;
349
350            let screen_row: isize = if let Some(sb_row) = graphic.scrollback_row {
351                let sr = sb_row as isize - view_start as isize;
352                log::debug!(
353                    "[PANE_GRAPHICS] scrollback graphic id={}: sb_row={}, view_start={}, screen_row={}",
354                    id,
355                    sb_row,
356                    view_start,
357                    sr
358                );
359                sr
360            } else {
361                let absolute_row =
362                    scrollback_len.saturating_sub(scroll_offset_in_display_rows) + row;
363                let sr = absolute_row as isize - view_start as isize;
364                log::debug!(
365                    "[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={}",
366                    id,
367                    scrollback_len,
368                    graphic.scroll_offset_rows,
369                    core_cell_height,
370                    display_cell_height,
371                    scroll_offset_in_display_rows,
372                    row,
373                    absolute_row,
374                    view_start,
375                    sr
376                );
377                sr
378            };
379
380            if log::log_enabled!(log::Level::Debug) {
381                log::debug!(
382                    "[PANE_GRAPHICS] texture upload: id={}, protocol={:?}, size={}x{}, {}",
383                    id,
384                    graphic.protocol,
385                    graphic.width,
386                    graphic.height,
387                    rgba_diag_summary(&graphic.pixels)
388                );
389            }
390            // Upload / refresh texture in the shared cache
391            self.graphics_renderer.get_or_create_texture(
392                self.cell_renderer.device(),
393                self.cell_renderer.queue(),
394                id,
395                &graphic.pixels,
396                graphic.width as u32,
397                graphic.height as u32,
398            )?;
399
400            let (width_cells, height_cells) = graphic.cell_span(
401                self.cell_renderer.cell_width() as u32,
402                self.cell_renderer.cell_height() as u32,
403            );
404
405            let effective_clip_rows = if screen_row < 0 {
406                (-screen_row) as usize
407            } else {
408                0
409            };
410
411            positioned.push(GraphicRenderInfo {
412                id,
413                screen_row,
414                col,
415                width_cells,
416                height_cells,
417                alpha: 1.0,
418                scroll_offset_rows: effective_clip_rows,
419                destination_offset_x: graphic.placement.x_offset,
420                destination_offset_y: graphic.placement.y_offset,
421                source_crop: [
422                    graphic.placement.source_x,
423                    graphic.placement.source_y,
424                    graphic.placement.source_width,
425                    graphic.placement.source_height,
426                ],
427                has_cols: graphic.placement.columns.filter(|&v| v > 0).is_some(),
428                has_rows: graphic.placement.rows.filter(|&v| v > 0).is_some(),
429            });
430        }
431
432        Ok(positioned)
433    }
434
435    /// Compute `GraphicRenderInfo` entries for Kitty virtual placements.
436    ///
437    /// Scans `cells` (the visible grid) for runs of the Kitty placeholder
438    /// character, groups them by `(image_id, placement_id)`, and emits one
439    /// entry per group anchored at the bounding-box top-left cell. Textures are
440    /// uploaded to the shared cache under a synthetic id derived from
441    /// `image_id`, so repeated frames don't re-upload identical pixel data.
442    pub(crate) fn update_pane_virtual_placements(
443        &mut self,
444        cells: &[Cell],
445        cols: usize,
446        rows: usize,
447        virtual_placements: &[TerminalGraphic],
448    ) -> Result<Vec<GraphicRenderInfo>> {
449        let hits = scan_placeholder_cells(cells, cols, rows);
450        if hits.is_empty() {
451            return Ok(Vec::new());
452        }
453
454        let mut out = Vec::with_capacity(hits.len());
455        for hit in hits {
456            // Resolve the placement: prefer exact (image_id, placement_id), fall
457            // back to any placement for this image when placement_id == 0
458            // (matches GraphicsStore::get_placeholder_graphic semantics).
459            let graphic = virtual_placements
460                .iter()
461                .find(|g| {
462                    g.kitty_image_id == Some(hit.image_id)
463                        && g.kitty_placement_id.unwrap_or(0) == hit.placement_id
464                })
465                .or_else(|| {
466                    if hit.placement_id == 0 {
467                        virtual_placements
468                            .iter()
469                            .find(|g| g.kitty_image_id == Some(hit.image_id))
470                    } else {
471                        None
472                    }
473                });
474            let Some(graphic) = graphic else {
475                log::trace!(
476                    "[VPLACE] no virtual placement for image_id={}, placement_id={}",
477                    hit.image_id,
478                    hit.placement_id
479                );
480                continue;
481            };
482
483            let cache_id = virtual_placement_cache_id(hit.image_id, hit.placement_id);
484            self.graphics_renderer.get_or_create_texture(
485                self.cell_renderer.device(),
486                self.cell_renderer.queue(),
487                cache_id,
488                &graphic.pixels,
489                graphic.width as u32,
490                graphic.height as u32,
491            )?;
492
493            out.push(GraphicRenderInfo {
494                id: cache_id,
495                screen_row: hit.start_row as isize,
496                col: hit.start_col,
497                width_cells: hit.width_cells,
498                height_cells: hit.height_cells,
499                alpha: 1.0,
500                scroll_offset_rows: 0,
501                destination_offset_x: 0,
502                destination_offset_y: 0,
503                source_crop: [0; 4],
504                has_cols: true,
505                has_rows: true,
506            });
507        }
508        Ok(out)
509    }
510
511    /// Render inline graphics (Sixel/iTerm2/Kitty) for a single split pane.
512    ///
513    /// Uses the same `surface_view` as the cell render pass (with `LoadOp::Load`) so
514    /// graphics are composited on top of already-rendered cells.  A scissor rect derived
515    /// from `viewport` clips output to the pane's bounds.
516    #[allow(clippy::too_many_arguments)] // Surface, viewport, graphics list and grid geometry reach this call from separate owners; no struct groups them
517    pub(crate) fn render_pane_sixel_graphics(
518        &mut self,
519        surface_view: &wgpu::TextureView,
520        viewport: &crate::cell_renderer::PaneViewport,
521        graphics: &[par_term_emu_core_rust::graphics::TerminalGraphic],
522        scroll_offset: usize,
523        scrollback_len: usize,
524        visible_rows: usize,
525        cells: &[Cell],
526        cols: usize,
527        virtual_placements: &[TerminalGraphic],
528    ) -> Result<()> {
529        let mut positioned =
530            self.update_pane_graphics(graphics, scroll_offset, scrollback_len, visible_rows)?;
531
532        // Build virtual-placement entries from the cell grid scan. These render
533        // alongside the normal sixel/iTerm2/kitty graphics through the same
534        // texture pipeline, but their on-screen position comes from the
535        // placeholder cells, not from each TerminalGraphic's `position` field.
536        if !virtual_placements.is_empty() && !cells.is_empty() && cols > 0 {
537            positioned.extend(self.update_pane_virtual_placements(
538                cells,
539                cols,
540                visible_rows,
541                virtual_placements,
542            )?);
543        }
544
545        if positioned.is_empty() {
546            return Ok(());
547        }
548
549        let mut encoder =
550            self.cell_renderer
551                .device()
552                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
553                    label: Some("pane sixel encoder"),
554                });
555
556        {
557            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
558                label: Some("pane sixel render pass"),
559                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
560                    view: surface_view,
561                    resolve_target: None,
562                    ops: wgpu::Operations {
563                        load: wgpu::LoadOp::Load,
564                        store: wgpu::StoreOp::Store,
565                    },
566                    depth_slice: None,
567                })],
568                depth_stencil_attachment: None,
569                timestamp_writes: None,
570                occlusion_query_set: None,
571                multiview_mask: None,
572            });
573
574            // Clip to pane bounds
575            let (sx, sy, sw, sh) = viewport.to_scissor_rect();
576            render_pass.set_scissor_rect(sx, sy, sw, sh);
577
578            let (ox, oy) = viewport.content_origin();
579
580            log::debug!(
581                "[PANE_GRAPHICS] render_pane_sixel_graphics: scissor=({},{},{},{}), origin=({},{}), window={}x{}, positioned_count={}",
582                sx,
583                sy,
584                sw,
585                sh,
586                ox,
587                oy,
588                self.size.width,
589                self.size.height,
590                positioned.len()
591            );
592            for g in &positioned {
593                log::debug!(
594                    "[PANE_GRAPHICS]   positioned: id={}, screen_row={}, col={}, width_cells={}, height_cells={}, clip_rows={}",
595                    g.id,
596                    g.screen_row,
597                    g.col,
598                    g.width_cells,
599                    g.height_cells,
600                    g.scroll_offset_rows
601                );
602            }
603
604            self.graphics_renderer.render_for_pane(
605                self.cell_renderer.device(),
606                self.cell_renderer.queue(),
607                &mut render_pass,
608                &positioned,
609                crate::graphics_renderer::PaneRenderGeometry {
610                    window_width: self.size.width as f32,
611                    window_height: self.size.height as f32,
612                    pane_origin_x: ox,
613                    pane_origin_y: oy,
614                },
615            )?;
616        }
617
618        self.cell_renderer
619            .queue()
620            .submit(std::iter::once(encoder.finish()));
621
622        Ok(())
623    }
624
625    /// Clear all cached sixel textures
626    pub fn clear_sixel_cache(&mut self) {
627        self.graphics_renderer.clear_cache();
628        self.sixel_graphics.clear();
629        self.dirty = true;
630    }
631
632    /// Get the number of cached sixel textures
633    pub fn sixel_cache_size(&self) -> usize {
634        self.graphics_renderer.cache_size()
635    }
636
637    /// Remove a specific sixel texture from cache
638    pub fn remove_sixel_texture(&mut self, id: u64) {
639        self.graphics_renderer.remove_texture(id);
640        self.sixel_graphics.retain(|g| g.id != id);
641        self.dirty = true;
642    }
643}
644
645#[cfg(test)]
646mod virtual_placement_tests {
647    //! Tests for Kitty Unicode-placeholder rendering preprocessing.
648    //!
649    //! These exercise the cell-grid scan + bounding-box grouping that turn
650    //! placeholder runs into `VirtualPlacementHit`s. They deliberately stop
651    //! short of the GPU pipeline — `update_pane_virtual_placements` would
652    //! need a wgpu device — so we test the pure logic that feeds it.
653
654    use super::{
655        VIRTUAL_PLACEMENT_ID_FLAG, decode_placeholder_cell, scan_placeholder_cells,
656        virtual_placement_cache_id,
657    };
658    use crate::cell_renderer::Cell;
659    use par_term_emu_core_rust::graphics::placeholder::{
660        PLACEHOLDER_CHAR, create_placeholder_with_diacritics,
661    };
662
663    /// Build a placeholder cell at (row_idx, col_idx) for `image_id` (low 24
664    /// bits encoded in fg_color, no MSB diacritic).
665    fn placeholder_cell(image_id: u32, row_idx: u16, col_idx: u16) -> Cell {
666        let r = ((image_id >> 16) & 0xFF) as u8;
667        let g = ((image_id >> 8) & 0xFF) as u8;
668        let b = (image_id & 0xFF) as u8;
669        Cell {
670            grapheme: create_placeholder_with_diacritics(row_idx, col_idx, None),
671            fg_color: [r, g, b, 255],
672            ..Default::default()
673        }
674    }
675
676    fn blank_cell() -> Cell {
677        Cell {
678            grapheme: " ".to_string(),
679            ..Default::default()
680        }
681    }
682
683    fn make_grid(cells: Vec<Cell>, cols: usize) -> (Vec<Cell>, usize, usize) {
684        let rows = cells.len() / cols;
685        (cells, cols, rows)
686    }
687
688    #[test]
689    fn decode_placeholder_recovers_image_id_and_indices() {
690        let cell = placeholder_cell(0x123456, 3, 7);
691        let (image_id, placement_id, row, col) = decode_placeholder_cell(&cell).unwrap();
692        assert_eq!(image_id, 0x123456);
693        assert_eq!(placement_id, 0);
694        assert_eq!(row, 3);
695        assert_eq!(col, 7);
696    }
697
698    #[test]
699    fn decode_placeholder_rejects_non_placeholder_cells() {
700        let cell = blank_cell();
701        assert!(decode_placeholder_cell(&cell).is_none());
702
703        let mut letter = blank_cell();
704        letter.grapheme = "a".to_string();
705        assert!(decode_placeholder_cell(&letter).is_none());
706    }
707
708    #[test]
709    fn scan_finds_single_rectangle_for_single_image() {
710        // 4-col × 3-row grid; place a 3-col × 2-row placeholder rect at (1,0)
711        // for image_id=42:
712        //   . X X X
713        //   . X X X
714        //   . . . .
715        let mut cells = vec![blank_cell(); 4 * 3];
716        for r in 0..2 {
717            for c in 1..4 {
718                cells[r * 4 + c] = placeholder_cell(42, r as u16, (c - 1) as u16);
719            }
720        }
721        let (cells, cols, rows) = make_grid(cells, 4);
722
723        let hits = scan_placeholder_cells(&cells, cols, rows);
724        assert_eq!(hits.len(), 1);
725        let h = hits[0];
726        assert_eq!(h.image_id, 42);
727        assert_eq!(h.placement_id, 0);
728        assert_eq!(h.start_col, 1);
729        assert_eq!(h.start_row, 0);
730        assert_eq!(h.width_cells, 3);
731        assert_eq!(h.height_cells, 2);
732    }
733
734    #[test]
735    fn scan_groups_two_adjacent_images_separately() {
736        // 6 cols × 1 row: 3 cells of image 7 followed by 3 cells of image 99.
737        let mut cells = Vec::with_capacity(6);
738        for c in 0..3 {
739            cells.push(placeholder_cell(7, 0, c as u16));
740        }
741        for c in 0..3 {
742            cells.push(placeholder_cell(99, 0, c as u16));
743        }
744        let (cells, cols, rows) = make_grid(cells, 6);
745
746        let hits = scan_placeholder_cells(&cells, cols, rows);
747        assert_eq!(hits.len(), 2);
748
749        let h7 = hits.iter().find(|h| h.image_id == 7).unwrap();
750        assert_eq!(h7.start_col, 0);
751        assert_eq!(h7.width_cells, 3);
752        assert_eq!(h7.height_cells, 1);
753
754        let h99 = hits.iter().find(|h| h.image_id == 99).unwrap();
755        assert_eq!(h99.start_col, 3);
756        assert_eq!(h99.width_cells, 3);
757        assert_eq!(h99.height_cells, 1);
758    }
759
760    #[test]
761    fn scan_ignores_non_placeholder_cells() {
762        // A grid of all-blanks: the glyph path would draw spaces, the graphics
763        // path produces no hits. This is the test for "cell containing
764        // PLACEHOLDER_CHAR does not produce a glyph run" approached from the
765        // other direction: cells *without* the placeholder yield zero hits, so
766        // the glyph path's `ch == PLACEHOLDER_CHAR` skip can't accidentally
767        // fire on non-placeholder cells.
768        let cells = vec![blank_cell(); 6];
769        let hits = scan_placeholder_cells(&cells, 6, 1);
770        assert!(hits.is_empty());
771    }
772
773    #[test]
774    fn glyph_path_recognizes_placeholder_char() {
775        // The pane_render glyph loop suppresses glyph emission when the first
776        // char of `cell.grapheme` is U+10EEEE. This test pins down that exact
777        // predicate so it can't drift out of sync with the placeholder
778        // protocol's base char.
779        let cell = placeholder_cell(1, 0, 0);
780        let first = cell.grapheme.chars().next().unwrap();
781        assert_eq!(first, '\u{10EEEE}');
782        assert_eq!(first, PLACEHOLDER_CHAR);
783    }
784
785    #[test]
786    fn cache_id_is_disjoint_from_normal_graphic_ids() {
787        // Real TerminalGraphic ids are u64 counters from the core library and
788        // never set the high bit; virtual-placement cache ids always do, so
789        // they can't collide with a sixel/iterm2 texture in the shared cache.
790        let id_a = virtual_placement_cache_id(42, 0);
791        let id_b = virtual_placement_cache_id(42, 1);
792        assert_ne!(id_a, id_b);
793        assert!(id_a & VIRTUAL_PLACEMENT_ID_FLAG != 0);
794        assert!(id_b & VIRTUAL_PLACEMENT_ID_FLAG != 0);
795    }
796}