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