Skip to main content

urushi_graphics/
kitty.rs

1//! Stateless and retained Kitty graphics presentation.
2
3use std::collections::{HashMap, HashSet};
4use std::fmt::Write as _;
5use std::io;
6
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD;
9use urushi::{Key, Position, ResolvedView, Size, View};
10use urushi_terminal::{
11    Command, CommandWriter, ControlString, CursorMove, PixelSize as CellPixelSize,
12    Position as TerminalPosition, TerminalOutput,
13};
14
15use crate::{GraphicPlacement, Image, PixelPosition, PixelSize, RgbaRaster};
16
17const RAW_CHUNK_BYTES: usize = 3_072;
18const MAX_UPLOADED_IMAGES: usize = 64;
19const MAX_UPLOADED_BYTES: usize = 64 * 1024 * 1024;
20
21/// Renderer-owned Kitty protocol state for reconciling successive image frames.
22///
23/// A lifecycle retains terminal upload and placement identities, but owns no
24/// terminal connection, session, frame scheduling, or application state. The
25/// renderer keeps this value across frames for one terminal presentation and
26/// calls [`Self::clear`] before resize teardown or exit.
27///
28/// [`Self::present`] commits its candidate state only after every command and
29/// the final flush succeed. After a partial failure, the next presentation
30/// first deletes every image that may have reached the terminal, then rebuilds
31/// the desired frame with fresh protocol identifiers.
32#[derive(Debug)]
33pub struct KittyLifecycle {
34    committed: KittyState,
35    possible_image_ids: HashSet<u32>,
36    needs_reset: bool,
37    cursor_restore_pending: bool,
38}
39
40impl Default for KittyLifecycle {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl KittyLifecycle {
47    #[must_use]
48    pub fn new() -> Self {
49        Self {
50            committed: KittyState::new(),
51            possible_image_ids: HashSet::new(),
52            needs_reset: false,
53            cursor_restore_pending: false,
54        }
55    }
56
57    /// Reconciles and presents one immutable desired image frame.
58    ///
59    /// `resolved` must be the result of resolving `view`; the lifecycle finds
60    /// the Image snapshots embedded in that View and matches their keys to the
61    /// resolved anchors without requiring a parallel image list.
62    ///
63    /// Images without a visible, non-empty resolved placement are absent from
64    /// the desired frame. A partial placement keeps the same upload and
65    /// placement identities while its source crop and visible cell rectangle
66    /// change. Hidden uploads may remain cached for later reuse.
67    pub fn present(
68        &mut self,
69        view: &View,
70        resolved: &ResolvedView,
71        cell_pixels: Option<CellPixelSize>,
72        terminal: &mut (impl CommandWriter + ?Sized),
73    ) -> io::Result<()> {
74        self.restore_cursor(terminal)?;
75        let desired = DesiredFrame::new(resolved, crate::image::collect(view), cell_pixels);
76        let mut operations = Vec::new();
77        let mut candidate = if self.needs_reset {
78            let mut image_ids = self.possible_image_ids();
79            image_ids.sort_unstable();
80            image_ids.dedup();
81            operations.extend(
82                image_ids
83                    .into_iter()
84                    .map(|image_id| KittyOperation::DeleteImage { image_id }),
85            );
86            KittyState::after(&self.committed)
87        } else {
88            self.committed.clone()
89        };
90        operations.extend(candidate.reconcile(&desired)?);
91
92        for operation in operations {
93            if let Err(error) = write_operation(
94                operation,
95                &desired,
96                terminal,
97                &mut self.cursor_restore_pending,
98            ) {
99                self.retain_possible_state(&candidate);
100                return Err(error);
101            }
102        }
103        if let Err(error) = TerminalOutput::flush(terminal) {
104            self.retain_possible_state(&candidate);
105            return Err(error);
106        }
107
108        self.committed = candidate;
109        self.possible_image_ids.clear();
110        self.needs_reset = false;
111        self.cursor_restore_pending = false;
112        Ok(())
113    }
114
115    /// Deletes every Kitty image that may belong to this lifecycle.
116    ///
117    /// Call this when a resize invalidates placement geometry and when the
118    /// renderer releases the terminal presentation. A failed cleanup remains
119    /// pending so a later `clear` or [`Self::present`] retries conservatively.
120    pub fn clear(&mut self, terminal: &mut (impl CommandWriter + ?Sized)) -> io::Result<()> {
121        self.restore_cursor(terminal)?;
122        let mut image_ids = self.possible_image_ids();
123        image_ids.sort_unstable();
124        image_ids.dedup();
125        for image_id in image_ids {
126            if let Err(error) = write_delete_image(image_id, terminal) {
127                self.needs_reset = true;
128                return Err(error);
129            }
130        }
131        if let Err(error) = TerminalOutput::flush(terminal) {
132            self.needs_reset = true;
133            return Err(error);
134        }
135
136        self.committed.clear();
137        self.possible_image_ids.clear();
138        self.needs_reset = false;
139        Ok(())
140    }
141
142    fn restore_cursor(&mut self, terminal: &mut (impl CommandWriter + ?Sized)) -> io::Result<()> {
143        if !self.cursor_restore_pending {
144            return Ok(());
145        }
146        terminal.write_command(Command::RestoreCursorPosition)?;
147        TerminalOutput::flush(terminal)?;
148        self.cursor_restore_pending = false;
149        Ok(())
150    }
151
152    fn possible_image_ids(&self) -> Vec<u32> {
153        self.committed
154            .uploaded
155            .values()
156            .map(|uploaded| uploaded.image_id)
157            .chain(self.possible_image_ids.iter().copied())
158            .collect()
159    }
160
161    fn retain_possible_state(&mut self, candidate: &KittyState) {
162        self.possible_image_ids.extend(
163            self.committed
164                .uploaded
165                .values()
166                .map(|uploaded| uploaded.image_id),
167        );
168        self.possible_image_ids.extend(
169            candidate
170                .uploaded
171                .values()
172                .map(|uploaded| uploaded.image_id),
173        );
174        self.committed.next_image_id = self.committed.next_image_id.max(candidate.next_image_id);
175        self.committed.next_placement_id = self
176            .committed
177            .next_placement_id
178            .max(candidate.next_placement_id);
179        self.committed.generation = self.committed.generation.max(candidate.generation);
180        self.needs_reset = true;
181    }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185struct Uploaded {
186    image_id: u32,
187    last_used: u64,
188    byte_len: usize,
189    retire_when_unused: bool,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193struct VisiblePlacement {
194    placement_id: u32,
195    image_id: u32,
196    placement: DesiredPlacement,
197}
198
199#[derive(Debug, Clone, Default)]
200struct KittyState {
201    uploaded: HashMap<Key, Uploaded>,
202    visible: HashMap<Key, VisiblePlacement>,
203    next_image_id: u32,
204    next_placement_id: u32,
205    generation: u64,
206}
207
208impl KittyState {
209    fn new() -> Self {
210        Self {
211            next_image_id: 1,
212            next_placement_id: 1,
213            ..Self::default()
214        }
215    }
216
217    fn after(previous: &Self) -> Self {
218        Self {
219            next_image_id: previous.next_image_id,
220            next_placement_id: previous.next_placement_id,
221            generation: previous.generation,
222            ..Self::default()
223        }
224    }
225
226    fn clear(&mut self) {
227        self.uploaded.clear();
228        self.visible.clear();
229    }
230
231    fn reconcile(&mut self, frame: &DesiredFrame<'_>) -> io::Result<Vec<KittyOperation>> {
232        self.generation = self.generation.saturating_add(1);
233        let mut operations = Vec::new();
234        let mut retired_images = HashSet::new();
235        let mut adopted_images = HashSet::new();
236        let desired_placements = frame
237            .placements
238            .iter()
239            .map(|placement| placement.key)
240            .collect::<HashSet<_>>();
241        let mut stale = self
242            .visible
243            .iter()
244            .filter(|(key, _)| !desired_placements.contains(key))
245            .map(|(key, placement)| (*key, placement.placement_id))
246            .collect::<Vec<_>>();
247        stale.sort_by_key(|(_, placement_id)| *placement_id);
248        for (key, _) in stale {
249            if let Some(placement) = self.visible.remove(&key) {
250                operations.push(KittyOperation::DeletePlacement {
251                    image_id: placement.image_id,
252                    placement_id: placement.placement_id,
253                });
254            }
255        }
256
257        for desired in &frame.placements {
258            let uploaded = if let Some(uploaded) = self.uploaded.get_mut(&desired.asset_key) {
259                uploaded.last_used = self.generation;
260                *uploaded
261            } else {
262                let image_id = self.allocate_image_id()?;
263                let uploaded = Uploaded {
264                    image_id,
265                    last_used: self.generation,
266                    byte_len: frame.assets[&desired.asset_key].bytes().len(),
267                    retire_when_unused: false,
268                };
269                self.uploaded.insert(desired.asset_key, uploaded);
270                operations.push(KittyOperation::Upload {
271                    image_id,
272                    asset_key: desired.asset_key,
273                });
274                uploaded
275            };
276
277            match self.visible.get(&desired.key).copied() {
278                Some(current)
279                    if current.image_id == uploaded.image_id && current.placement == *desired => {}
280                Some(current) => {
281                    if current.image_id != uploaded.image_id {
282                        retired_images.insert(current.image_id);
283                        adopted_images.insert(uploaded.image_id);
284                        operations.push(KittyOperation::DeletePlacement {
285                            image_id: current.image_id,
286                            placement_id: current.placement_id,
287                        });
288                    }
289                    let placement_id = current.placement_id;
290                    operations.push(KittyOperation::Place {
291                        image_id: uploaded.image_id,
292                        placement_id,
293                        placement: *desired,
294                    });
295                    self.visible.insert(
296                        desired.key,
297                        VisiblePlacement {
298                            placement_id,
299                            image_id: uploaded.image_id,
300                            placement: *desired,
301                        },
302                    );
303                }
304                None => {
305                    adopted_images.insert(uploaded.image_id);
306                    let placement_id = self.allocate_placement_id()?;
307                    operations.push(KittyOperation::Place {
308                        image_id: uploaded.image_id,
309                        placement_id,
310                        placement: *desired,
311                    });
312                    self.visible.insert(
313                        desired.key,
314                        VisiblePlacement {
315                            placement_id,
316                            image_id: uploaded.image_id,
317                            placement: *desired,
318                        },
319                    );
320                }
321            }
322        }
323
324        for uploaded in self.uploaded.values_mut() {
325            if adopted_images.contains(&uploaded.image_id) {
326                uploaded.retire_when_unused = false;
327            } else if retired_images.contains(&uploaded.image_id) {
328                uploaded.retire_when_unused = true;
329            }
330        }
331
332        let referenced = self
333            .visible
334            .values()
335            .map(|placement| placement.image_id)
336            .collect::<HashSet<_>>();
337        let mut unused = self
338            .uploaded
339            .iter()
340            .filter(|(_, uploaded)| !referenced.contains(&uploaded.image_id))
341            .map(|(key, uploaded)| (*key, *uploaded))
342            .collect::<Vec<_>>();
343        unused.sort_by_key(|(_, uploaded)| (uploaded.last_used, uploaded.image_id));
344        let mut retained_count = self.uploaded.len();
345        let mut retained_bytes = self.uploaded.values().fold(0usize, |total, uploaded| {
346            total.saturating_add(uploaded.byte_len)
347        });
348        let mut evicted = HashSet::new();
349        for (_, uploaded) in &unused {
350            if uploaded.retire_when_unused {
351                retained_count -= 1;
352                retained_bytes = retained_bytes.saturating_sub(uploaded.byte_len);
353                evicted.insert(uploaded.image_id);
354            }
355        }
356        for (_, uploaded) in &unused {
357            if retained_count <= MAX_UPLOADED_IMAGES && retained_bytes <= MAX_UPLOADED_BYTES {
358                break;
359            }
360            if evicted.contains(&uploaded.image_id) {
361                continue;
362            }
363            retained_count -= 1;
364            retained_bytes = retained_bytes.saturating_sub(uploaded.byte_len);
365            evicted.insert(uploaded.image_id);
366        }
367        for (key, uploaded) in unused {
368            if !evicted.contains(&uploaded.image_id) {
369                continue;
370            }
371            self.uploaded.remove(&key);
372            operations.push(KittyOperation::DeleteImage {
373                image_id: uploaded.image_id,
374            });
375        }
376        Ok(operations)
377    }
378
379    fn allocate_image_id(&mut self) -> io::Result<u32> {
380        let image_id = self.next_image_id;
381        self.next_image_id = image_id
382            .checked_add(1)
383            .ok_or_else(|| io::Error::other("the Kitty image identifier space is exhausted"))?;
384        Ok(image_id)
385    }
386
387    fn allocate_placement_id(&mut self) -> io::Result<u32> {
388        let placement_id = self.next_placement_id;
389        self.next_placement_id = placement_id
390            .checked_add(1)
391            .ok_or_else(|| io::Error::other("the Kitty placement identifier space is exhausted"))?;
392        Ok(placement_id)
393    }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397struct DesiredPlacement {
398    key: Key,
399    asset_key: Key,
400    origin: Position,
401    size: Size,
402    source_offset: PixelPosition,
403    source_size: PixelSize,
404}
405
406struct DesiredFrame<'a> {
407    assets: HashMap<Key, &'a RgbaRaster>,
408    placements: Vec<DesiredPlacement>,
409}
410
411impl<'a> DesiredFrame<'a> {
412    fn new(
413        view: &ResolvedView,
414        images: impl IntoIterator<Item = &'a Image>,
415        cell_pixels: Option<CellPixelSize>,
416    ) -> Self {
417        let mut assets = HashMap::new();
418        let mut placements = Vec::new();
419        for image in images {
420            let Some(placement) = image.placement(view, cell_pixels) else {
421                continue;
422            };
423            let (Some(origin), Some(size), Some(source_offset), Some(source_size)) = (
424                placement.visible_origin(),
425                placement.visible_size(),
426                placement.source_offset(),
427                placement.source_size(),
428            ) else {
429                continue;
430            };
431            assets
432                .entry(placement.raster().key())
433                .or_insert(placement.raster());
434            placements.push(DesiredPlacement {
435                key: placement.key(),
436                asset_key: placement.raster().key(),
437                origin,
438                size,
439                source_offset,
440                source_size,
441            });
442        }
443        Self { assets, placements }
444    }
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
448enum KittyOperation {
449    Upload {
450        image_id: u32,
451        asset_key: Key,
452    },
453    DeletePlacement {
454        image_id: u32,
455        placement_id: u32,
456    },
457    Place {
458        image_id: u32,
459        placement_id: u32,
460        placement: DesiredPlacement,
461    },
462    DeleteImage {
463        image_id: u32,
464    },
465}
466
467/// Queues every visible image through a terminal command connection.
468///
469/// This is the stateless first-frame path: each call transmits the complete
470/// RGBA asset with the Kitty graphics protocol and displays the visible source
471/// crop in the anchor resolved with the fallback cells. The terminal backend
472/// owns APC framing and physical output.
473pub fn render_kitty<'a>(
474    view: &ResolvedView,
475    images: impl IntoIterator<Item = &'a Image>,
476    cell_pixels: Option<CellPixelSize>,
477    terminal: &mut (impl CommandWriter + ?Sized),
478) -> io::Result<()> {
479    for placement in images
480        .into_iter()
481        .filter_map(|image| image.placement(view, cell_pixels))
482        .filter(|placement| {
483            placement
484                .visible_size()
485                .is_some_and(|size| !size.is_empty())
486        })
487    {
488        write_placement(placement, terminal)?;
489    }
490    TerminalOutput::flush(terminal)
491}
492
493fn write_placement(
494    placement: GraphicPlacement<'_>,
495    terminal: &mut (impl CommandWriter + ?Sized),
496) -> io::Result<()> {
497    let raster = placement.raster();
498    let mut chunks = raster.bytes().chunks(RAW_CHUNK_BYTES).peekable();
499    let mut payload = String::with_capacity(RAW_CHUNK_BYTES / 3 * 4 + 128);
500    let origin = placement.visible_origin().ok_or_else(|| {
501        io::Error::new(io::ErrorKind::InvalidInput, "a Kitty image must be visible")
502    })?;
503    let size = placement.visible_size().ok_or_else(|| {
504        io::Error::new(io::ErrorKind::InvalidInput, "a Kitty image must be visible")
505    })?;
506    let source_offset = placement.source_offset().ok_or_else(|| {
507        io::Error::new(
508            io::ErrorKind::InvalidInput,
509            "a Kitty source crop must be non-empty",
510        )
511    })?;
512    let source_size = placement.source_size().ok_or_else(|| {
513        io::Error::new(
514            io::ErrorKind::InvalidInput,
515            "a Kitty source crop must be non-empty",
516        )
517    })?;
518    let column = usize::try_from(origin.x).map_err(|_| {
519        io::Error::new(
520            io::ErrorKind::InvalidInput,
521            "a Kitty image column cannot be negative",
522        )
523    })?;
524    let row = usize::try_from(origin.y).map_err(|_| {
525        io::Error::new(
526            io::ErrorKind::InvalidInput,
527            "a Kitty image row cannot be negative",
528        )
529    })?;
530
531    terminal.write_command(Command::SaveCursorPosition)?;
532    terminal.write_command(Command::MoveCursor(CursorMove::To(TerminalPosition::new(
533        column, row,
534    ))))?;
535    let mut first = true;
536    while let Some(chunk) = chunks.next() {
537        payload.clear();
538        let more = u8::from(chunks.peek().is_some());
539        if first {
540            write!(
541                payload,
542                "Ga=T,f=32,s={},v={},x={},y={},w={},h={},c={},r={},C=1,q=2,m={more};",
543                raster.size().width(),
544                raster.size().height(),
545                source_offset.x(),
546                source_offset.y(),
547                source_size.width(),
548                source_size.height(),
549                size.width(),
550                size.height(),
551            )
552            .map_err(io::Error::other)?;
553        } else {
554            write!(payload, "Gm={more};").map_err(io::Error::other)?;
555        }
556        STANDARD.encode_string(chunk, &mut payload);
557        let payload = ControlString::try_from(payload.as_str())
558            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
559        terminal.write_command(Command::ApplicationProgram(payload))?;
560        first = false;
561    }
562    terminal.write_command(Command::RestoreCursorPosition)
563}
564
565fn write_operation(
566    operation: KittyOperation,
567    frame: &DesiredFrame<'_>,
568    terminal: &mut (impl CommandWriter + ?Sized),
569    cursor_restore_pending: &mut bool,
570) -> io::Result<()> {
571    match operation {
572        KittyOperation::Upload {
573            image_id,
574            asset_key,
575        } => {
576            let raster = frame.assets.get(&asset_key).ok_or_else(|| {
577                io::Error::new(
578                    io::ErrorKind::InvalidData,
579                    "a desired Kitty raster asset is missing",
580                )
581            })?;
582            write_upload(image_id, raster, terminal)
583        }
584        KittyOperation::DeletePlacement {
585            image_id,
586            placement_id,
587        } => write_application_program(
588            &format!("Ga=d,d=i,i={image_id},p={placement_id},q=2"),
589            terminal,
590        ),
591        KittyOperation::Place {
592            image_id,
593            placement_id,
594            placement,
595        } => write_retained_placement(
596            image_id,
597            placement_id,
598            placement,
599            terminal,
600            cursor_restore_pending,
601        ),
602        KittyOperation::DeleteImage { image_id } => write_delete_image(image_id, terminal),
603    }
604}
605
606fn write_upload(
607    image_id: u32,
608    raster: &RgbaRaster,
609    terminal: &mut (impl CommandWriter + ?Sized),
610) -> io::Result<()> {
611    let mut chunks = raster.bytes().chunks(RAW_CHUNK_BYTES).peekable();
612    let mut payload = String::with_capacity(RAW_CHUNK_BYTES / 3 * 4 + 128);
613    let mut first = true;
614    while let Some(chunk) = chunks.next() {
615        payload.clear();
616        let more = u8::from(chunks.peek().is_some());
617        if first {
618            write!(
619                payload,
620                "Ga=t,f=32,s={},v={},i={image_id},q=2,m={more};",
621                raster.size().width(),
622                raster.size().height(),
623            )
624            .map_err(io::Error::other)?;
625        } else {
626            write!(payload, "Gm={more};").map_err(io::Error::other)?;
627        }
628        STANDARD.encode_string(chunk, &mut payload);
629        write_application_program(&payload, terminal)?;
630        first = false;
631    }
632    Ok(())
633}
634
635fn write_retained_placement(
636    image_id: u32,
637    placement_id: u32,
638    placement: DesiredPlacement,
639    terminal: &mut (impl CommandWriter + ?Sized),
640    cursor_restore_pending: &mut bool,
641) -> io::Result<()> {
642    let column = usize::try_from(placement.origin.x).map_err(|_| {
643        io::Error::new(
644            io::ErrorKind::InvalidInput,
645            "a Kitty image column cannot be negative",
646        )
647    })?;
648    let row = usize::try_from(placement.origin.y).map_err(|_| {
649        io::Error::new(
650            io::ErrorKind::InvalidInput,
651            "a Kitty image row cannot be negative",
652        )
653    })?;
654
655    terminal.write_command(Command::SaveCursorPosition)?;
656    *cursor_restore_pending = true;
657    let result = terminal
658        .write_command(Command::MoveCursor(CursorMove::To(TerminalPosition::new(
659            column, row,
660        ))))
661        .and_then(|()| {
662            write_application_program(
663                &format!(
664                    "Ga=p,i={image_id},p={placement_id},x={},y={},w={},h={},c={},r={},C=1,q=2",
665                    placement.source_offset.x(),
666                    placement.source_offset.y(),
667                    placement.source_size.width(),
668                    placement.source_size.height(),
669                    placement.size.width(),
670                    placement.size.height(),
671                ),
672                terminal,
673            )
674        });
675    match result {
676        Ok(()) => terminal.write_command(Command::RestoreCursorPosition),
677        Err(error) => {
678            let _ = terminal.write_command(Command::RestoreCursorPosition);
679            Err(error)
680        }
681    }
682}
683
684fn write_delete_image(
685    image_id: u32,
686    terminal: &mut (impl CommandWriter + ?Sized),
687) -> io::Result<()> {
688    write_application_program(&format!("Ga=d,d=I,i={image_id},q=2"), terminal)
689}
690
691fn write_application_program(
692    payload: &str,
693    terminal: &mut (impl CommandWriter + ?Sized),
694) -> io::Result<()> {
695    let payload = ControlString::try_from(payload)
696        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
697    terminal.write_command(Command::ApplicationProgram(payload))
698}
699
700#[cfg(test)]
701mod tests {
702    use urushi::{
703        Available, Canvas, CanvasContext, CanvasItem, Position, Projection, ProjectionBoundary,
704        Size, TextStyle, View, Viewport, resolve,
705    };
706    use urushi_terminal::{CommandWriter, TerminalOutput, backend::ansi::AnsiWriter};
707
708    use crate::{CellSize, ImagePresentation, PixelSize};
709
710    use super::*;
711
712    #[derive(Default)]
713    struct RecordingTerminal {
714        commands: Vec<String>,
715        command_count: usize,
716        flushes: usize,
717        fail_command: Option<usize>,
718        fail_flush: bool,
719    }
720
721    impl RecordingTerminal {
722        fn fail_command(index: usize) -> Self {
723            Self {
724                fail_command: Some(index),
725                ..Self::default()
726            }
727        }
728
729        fn application_programs(&self) -> impl Iterator<Item = &str> {
730            self.commands
731                .iter()
732                .filter_map(|command| command.strip_prefix("APC "))
733        }
734    }
735
736    impl TerminalOutput for RecordingTerminal {
737        fn flush(&mut self) -> io::Result<()> {
738            self.flushes += 1;
739            if self.fail_flush {
740                Err(io::Error::other("injected flush failure"))
741            } else {
742                Ok(())
743            }
744        }
745    }
746
747    impl CommandWriter for RecordingTerminal {
748        fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
749            let index = self.command_count;
750            self.command_count += 1;
751            if self.fail_command == Some(index) {
752                return Err(io::Error::other("injected command failure"));
753            }
754            self.commands.push(match command {
755                Command::ApplicationProgram(payload) => format!("APC {}", payload.as_str()),
756                Command::SaveCursorPosition => "save".to_owned(),
757                Command::RestoreCursorPosition => "restore".to_owned(),
758                Command::MoveCursor(CursorMove::To(position)) => {
759                    format!("move {},{}", position.column(), position.row())
760                }
761                other => format!("{other:?}"),
762            });
763            Ok(())
764        }
765    }
766
767    fn image_view(image: &Image, column: usize) -> (View, ResolvedView) {
768        let image = ImagePresentation::new().compose(image, CellSize::new(2, 1));
769        let view = if column == 0 {
770            image
771        } else {
772            View::row(
773                urushi::VerticalAlign::Top,
774                [View::text(" ".repeat(column), TextStyle::new()), image],
775            )
776        };
777        let resolved = resolve(&view, Available::NONE).unwrap();
778        (view, resolved)
779    }
780
781    fn empty_view() -> (View, ResolvedView) {
782        let view = View::empty();
783        let resolved = resolve(&view, Available::NONE).unwrap();
784        (view, resolved)
785    }
786
787    fn present_image(
788        lifecycle: &mut KittyLifecycle,
789        image: &Image,
790        column: usize,
791        terminal: &mut (impl CommandWriter + ?Sized),
792    ) -> io::Result<()> {
793        let (view, resolved) = image_view(image, column);
794        lifecycle.present(&view, &resolved, Some(CellPixelSize::new(8, 16)), terminal)
795    }
796
797    fn present_empty(
798        lifecycle: &mut KittyLifecycle,
799        terminal: &mut (impl CommandWriter + ?Sized),
800    ) -> io::Result<()> {
801        let (view, resolved) = empty_view();
802        lifecycle.present(&view, &resolved, Some(CellPixelSize::new(8, 16)), terminal)
803    }
804
805    #[test]
806    fn writes_one_rgba_image_at_the_resolved_anchor() {
807        let image = Image::rgba(
808            "placement",
809            "asset",
810            PixelSize::new(2, 1),
811            [255, 0, 0, 255, 0, 255, 0, 255],
812        )
813        .unwrap()
814        .fallback("alt");
815        let view = ImagePresentation::new().compose(&image, CellSize::new(3, 2));
816        let resolved = resolve(&view, Available::NONE).unwrap();
817        let mut output = AnsiWriter::new(Vec::new());
818
819        render_kitty(
820            &resolved,
821            [&image],
822            Some(CellPixelSize::new(8, 16)),
823            &mut output,
824        )
825        .unwrap();
826
827        let output = String::from_utf8(output.into_inner()).unwrap();
828        assert!(output.starts_with("\x1b7\x1b[1;1H\x1b_G"));
829        assert!(output.contains("a=T,f=32,s=2,v=1,x=0,y=0,w=2,h=1,c=3,r=2,C=1,q=2,m=0;"));
830        assert!(output.ends_with("\x1b\\\x1b8"));
831        assert!(output.contains(&STANDARD.encode(image.raster().bytes())));
832    }
833
834    #[test]
835    fn renders_the_visible_crop_of_an_image_crossing_the_view_edge() {
836        #[derive(Debug, Clone, PartialEq)]
837        struct PartlyVisibleImage(View);
838
839        impl CanvasItem for PartlyVisibleImage {
840            fn draw(&self, context: &mut CanvasContext) {
841                context.view(Position::new(-1, 0), self.0.clone(), Some(2), Some(2));
842            }
843        }
844
845        let image = Image::rgba(
846            "placement",
847            "asset",
848            PixelSize::new(1, 1),
849            [255, 255, 255, 255],
850        )
851        .unwrap();
852        let image_view = ImagePresentation::new().compose(&image, CellSize::new(2, 2));
853        let view = View::canvas(
854            Canvas::new()
855                .extent(Size::new(1, 2))
856                .item(PartlyVisibleImage(image_view)),
857        );
858        let resolved = resolve(&view, Available::size(1, 1)).unwrap();
859        let mut output = AnsiWriter::new(Vec::new());
860
861        render_kitty(
862            &resolved,
863            [&image],
864            Some(CellPixelSize::new(8, 16)),
865            &mut output,
866        )
867        .unwrap();
868
869        let output = String::from_utf8(output.into_inner()).unwrap();
870        assert!(output.starts_with("\x1b7\x1b[1;1H"));
871        assert!(output.contains("x=0,y=0,w=1,h=1,c=1,r=1"));
872    }
873
874    #[test]
875    fn renders_the_accumulated_visible_crop_after_a_nested_shift() {
876        #[derive(Debug, Clone, PartialEq)]
877        struct PartlyVisibleImage(View);
878
879        impl CanvasItem for PartlyVisibleImage {
880            fn draw(&self, context: &mut CanvasContext) {
881                context.view(Position::new(-1, 0), self.0.clone(), Some(2), Some(2));
882            }
883        }
884
885        let image = Image::rgba(
886            "placement",
887            "asset",
888            PixelSize::new(1, 1),
889            [255, 255, 255, 255],
890        )
891        .unwrap();
892        let image_view = ImagePresentation::new().compose(&image, CellSize::new(2, 2));
893        let clipped = View::canvas(
894            Canvas::new()
895                .extent(Size::new(1, 2))
896                .item(PartlyVisibleImage(image_view)),
897        );
898        let view = View::row(
899            urushi::VerticalAlign::Top,
900            [View::text("x", TextStyle::new()), clipped],
901        );
902        let resolved = resolve(&view, Available::NONE).unwrap();
903        let placement = image
904            .placement(&resolved, Some(CellPixelSize::new(8, 16)))
905            .unwrap();
906        let mut output = AnsiWriter::new(Vec::new());
907
908        assert_eq!(placement.logical_origin(), Position::new(0, 0));
909        assert_eq!(placement.logical_size(), Size::new(2, 2));
910        assert_eq!(placement.visible_origin(), Some(Position::new(1, 0)));
911        assert_eq!(placement.visible_size(), Some(Size::new(1, 2)));
912        render_kitty(
913            &resolved,
914            [&image],
915            Some(CellPixelSize::new(8, 16)),
916            &mut output,
917        )
918        .unwrap();
919
920        let output = String::from_utf8(output.into_inner()).unwrap();
921        assert!(output.starts_with("\x1b7\x1b[1;2H"));
922        assert!(output.contains("x=0,y=0,w=1,h=1,c=1,r=2"));
923    }
924
925    #[test]
926    fn omits_an_empty_cell_placement() {
927        let image = Image::rgba(
928            "placement",
929            "asset",
930            PixelSize::new(1, 1),
931            [255, 255, 255, 255],
932        )
933        .unwrap();
934        let view = ImagePresentation::new().compose(&image, CellSize::ZERO);
935        let resolved = resolve(&view, Available::NONE).unwrap();
936        let mut output = AnsiWriter::new(Vec::new());
937
938        render_kitty(
939            &resolved,
940            [&image],
941            Some(CellPixelSize::new(8, 16)),
942            &mut output,
943        )
944        .unwrap();
945
946        assert!(output.into_inner().is_empty());
947    }
948
949    #[test]
950    fn chunks_payloads_without_splitting_base64_groups() {
951        let rgba = vec![7; RAW_CHUNK_BYTES + 4];
952        let image = Image::rgba("placement", "asset", PixelSize::new(769, 1), rgba).unwrap();
953        let view = ImagePresentation::new().compose(&image, CellSize::new(1, 1));
954        let resolved = resolve(&view, Available::NONE).unwrap();
955        let mut output = AnsiWriter::new(Vec::new());
956
957        render_kitty(
958            &resolved,
959            [&image],
960            Some(CellPixelSize::new(8, 16)),
961            &mut output,
962        )
963        .unwrap();
964
965        let output = String::from_utf8(output.into_inner()).unwrap();
966        assert!(output.contains("m=1;"));
967        assert!(output.contains("\x1b\\\x1b_Gm=0;"));
968    }
969
970    #[test]
971    fn lifecycle_uploads_once_and_reconciles_move_hide_and_reappearance() {
972        let image =
973            Image::rgba("placement", "asset", PixelSize::new(1, 1), [255, 0, 0, 255]).unwrap();
974        let mut lifecycle = KittyLifecycle::new();
975        let mut first = RecordingTerminal::default();
976
977        present_image(&mut lifecycle, &image, 0, &mut first).unwrap();
978
979        let first = first.application_programs().collect::<Vec<_>>();
980        assert_eq!(
981            first
982                .iter()
983                .filter(|payload| payload.contains("a=t"))
984                .count(),
985            1
986        );
987        assert_eq!(
988            first
989                .iter()
990                .filter(|payload| payload.contains("a=p"))
991                .count(),
992            1
993        );
994
995        let mut unchanged = RecordingTerminal::default();
996        present_image(&mut lifecycle, &image, 0, &mut unchanged).unwrap();
997        assert_eq!(unchanged.application_programs().count(), 0);
998        assert_eq!(unchanged.flushes, 1);
999
1000        let mut moved = RecordingTerminal::default();
1001        present_image(&mut lifecycle, &image, 3, &mut moved).unwrap();
1002        let moved = moved.application_programs().collect::<Vec<_>>();
1003        assert_eq!(
1004            moved
1005                .iter()
1006                .filter(|payload| payload.contains("a=t"))
1007                .count(),
1008            0
1009        );
1010        assert_eq!(
1011            moved
1012                .iter()
1013                .filter(|payload| payload.contains("a=p,i=1,p=1"))
1014                .count(),
1015            1
1016        );
1017
1018        let mut hidden = RecordingTerminal::default();
1019        present_empty(&mut lifecycle, &mut hidden).unwrap();
1020        assert!(
1021            hidden
1022                .application_programs()
1023                .any(|payload| { payload.contains("a=d,d=i,i=1,p=1") })
1024        );
1025
1026        let mut shown = RecordingTerminal::default();
1027        present_image(&mut lifecycle, &image, 0, &mut shown).unwrap();
1028        let shown = shown.application_programs().collect::<Vec<_>>();
1029        assert_eq!(
1030            shown
1031                .iter()
1032                .filter(|payload| payload.contains("a=t"))
1033                .count(),
1034            0
1035        );
1036        assert_eq!(
1037            shown
1038                .iter()
1039                .filter(|payload| payload.contains("a=p,i=1,p=2"))
1040                .count(),
1041            1
1042        );
1043    }
1044
1045    #[test]
1046    fn lifecycle_reuses_the_upload_when_viewport_scrolling_changes_the_source_crop() {
1047        let image =
1048            Image::rgba("placement", "asset", PixelSize::new(1, 4), vec![255; 4 * 4]).unwrap();
1049        let frame = |origin| {
1050            let view = View::viewport(
1051                Viewport::vertical(Projection::new(origin, ProjectionBoundary::Preserve)),
1052                ImagePresentation::new().compose(&image, CellSize::new(1, 4)),
1053            );
1054            let resolved = resolve(&view, Available::size(1, 3)).unwrap();
1055            (view, resolved)
1056        };
1057        let mut lifecycle = KittyLifecycle::new();
1058        let (first, first_resolved) = frame(0);
1059        lifecycle
1060            .present(
1061                &first,
1062                &first_resolved,
1063                Some(CellPixelSize::new(1, 1)),
1064                &mut RecordingTerminal::default(),
1065            )
1066            .unwrap();
1067        let (scrolled, scrolled_resolved) = frame(1);
1068        let mut output = RecordingTerminal::default();
1069
1070        lifecycle
1071            .present(
1072                &scrolled,
1073                &scrolled_resolved,
1074                Some(CellPixelSize::new(1, 1)),
1075                &mut output,
1076            )
1077            .unwrap();
1078
1079        let payloads = output.application_programs().collect::<Vec<_>>();
1080        assert!(!payloads.iter().any(|payload| payload.contains("a=t")));
1081        assert_eq!(payloads.len(), 1);
1082        assert!(payloads[0].contains("a=p,i=1,p=1"));
1083        assert!(payloads[0].contains("x=0,y=1,w=1,h=2,c=1,r=2"));
1084    }
1085
1086    #[test]
1087    fn lifecycle_replaces_an_asset_and_deletes_the_superseded_upload() {
1088        let first = Image::rgba(
1089            "placement",
1090            "first-asset",
1091            PixelSize::new(1, 1),
1092            [255, 0, 0, 255],
1093        )
1094        .unwrap();
1095        let second = Image::rgba(
1096            "placement",
1097            "second-asset",
1098            PixelSize::new(1, 1),
1099            [0, 255, 0, 255],
1100        )
1101        .unwrap();
1102        let mut lifecycle = KittyLifecycle::new();
1103        present_image(&mut lifecycle, &first, 0, &mut RecordingTerminal::default()).unwrap();
1104        let mut replacement = RecordingTerminal::default();
1105
1106        present_image(&mut lifecycle, &second, 0, &mut replacement).unwrap();
1107
1108        let payloads = replacement.application_programs().collect::<Vec<_>>();
1109        assert!(
1110            payloads
1111                .iter()
1112                .any(|payload| payload.contains("a=t") && payload.contains("i=2"))
1113        );
1114        assert!(
1115            payloads
1116                .iter()
1117                .any(|payload| payload.contains("a=d,d=i,i=1,p=1"))
1118        );
1119        assert!(
1120            payloads
1121                .iter()
1122                .any(|payload| payload.contains("a=p,i=2,p=1"))
1123        );
1124        assert!(
1125            payloads
1126                .iter()
1127                .any(|payload| payload.contains("a=d,d=I,i=1"))
1128        );
1129    }
1130
1131    #[test]
1132    fn lifecycle_shares_one_upload_between_independent_placements() {
1133        let first = Image::rgba(
1134            "first-placement",
1135            "shared-asset",
1136            PixelSize::new(1, 1),
1137            [255, 0, 0, 255],
1138        )
1139        .unwrap();
1140        let second = Image::rgba(
1141            "second-placement",
1142            "shared-asset",
1143            PixelSize::new(1, 1),
1144            [255, 0, 0, 255],
1145        )
1146        .unwrap();
1147        let presentation = ImagePresentation::new();
1148        let view = View::row(
1149            urushi::VerticalAlign::Top,
1150            [
1151                presentation.compose(&first, CellSize::new(1, 1)),
1152                presentation.compose(&second, CellSize::new(1, 1)),
1153            ],
1154        );
1155        let resolved = resolve(&view, Available::NONE).unwrap();
1156        let mut lifecycle = KittyLifecycle::new();
1157        let mut terminal = RecordingTerminal::default();
1158
1159        lifecycle
1160            .present(
1161                &view,
1162                &resolved,
1163                Some(CellPixelSize::new(8, 16)),
1164                &mut terminal,
1165            )
1166            .unwrap();
1167
1168        let payloads = terminal.application_programs().collect::<Vec<_>>();
1169        assert_eq!(
1170            payloads
1171                .iter()
1172                .filter(|payload| payload.contains("a=t"))
1173                .count(),
1174            1
1175        );
1176        assert_eq!(
1177            payloads
1178                .iter()
1179                .filter(|payload| payload.contains("a=p,i=1"))
1180                .count(),
1181            2
1182        );
1183    }
1184
1185    #[test]
1186    fn partial_failure_resets_possible_uploads_before_the_next_frame() {
1187        let first = Image::rgba(
1188            "placement",
1189            "first-asset",
1190            PixelSize::new(1, 1),
1191            [255, 0, 0, 255],
1192        )
1193        .unwrap();
1194        let second = Image::rgba(
1195            "placement",
1196            "second-asset",
1197            PixelSize::new(1, 1),
1198            [0, 255, 0, 255],
1199        )
1200        .unwrap();
1201        let mut lifecycle = KittyLifecycle::new();
1202        present_image(&mut lifecycle, &first, 0, &mut RecordingTerminal::default()).unwrap();
1203
1204        present_image(
1205            &mut lifecycle,
1206            &second,
1207            0,
1208            &mut RecordingTerminal::fail_command(1),
1209        )
1210        .expect_err("the injected command failure should abort the candidate frame");
1211
1212        let mut recovered = RecordingTerminal::default();
1213        present_image(&mut lifecycle, &second, 0, &mut recovered).unwrap();
1214        let payloads = recovered.application_programs().collect::<Vec<_>>();
1215        assert_eq!(&payloads[..2], ["Ga=d,d=I,i=1,q=2", "Ga=d,d=I,i=2,q=2"]);
1216        assert!(
1217            payloads
1218                .iter()
1219                .any(|payload| payload.contains("a=t") && payload.contains("i=3"))
1220        );
1221        assert!(
1222            payloads
1223                .iter()
1224                .any(|payload| payload.contains("a=p,i=3,p=2"))
1225        );
1226    }
1227
1228    #[test]
1229    fn failed_cursor_restore_is_retried_before_reconciliation() {
1230        let image =
1231            Image::rgba("placement", "asset", PixelSize::new(1, 1), [255, 0, 0, 255]).unwrap();
1232        let mut lifecycle = KittyLifecycle::new();
1233
1234        present_image(
1235            &mut lifecycle,
1236            &image,
1237            0,
1238            &mut RecordingTerminal::fail_command(4),
1239        )
1240        .expect_err("the injected cursor restore failure should abort the frame");
1241
1242        let mut recovered = RecordingTerminal::default();
1243        present_image(&mut lifecycle, &image, 0, &mut recovered).unwrap();
1244
1245        assert_eq!(
1246            recovered.commands.first().map(String::as_str),
1247            Some("restore")
1248        );
1249        assert!(
1250            recovered
1251                .commands
1252                .iter()
1253                .skip(1)
1254                .any(|command| command == "save")
1255        );
1256        assert_eq!(recovered.flushes, 2);
1257    }
1258
1259    #[test]
1260    fn failed_flush_also_forces_a_complete_resynchronization() {
1261        let image =
1262            Image::rgba("placement", "asset", PixelSize::new(1, 1), [255, 0, 0, 255]).unwrap();
1263        let mut lifecycle = KittyLifecycle::new();
1264        let mut failed = RecordingTerminal {
1265            fail_flush: true,
1266            ..RecordingTerminal::default()
1267        };
1268
1269        present_image(&mut lifecycle, &image, 0, &mut failed)
1270            .expect_err("the injected flush failure should abort the candidate frame");
1271
1272        let mut recovered = RecordingTerminal::default();
1273        present_image(&mut lifecycle, &image, 0, &mut recovered).unwrap();
1274        let payloads = recovered.application_programs().collect::<Vec<_>>();
1275        assert_eq!(payloads[0], "Ga=d,d=I,i=1,q=2");
1276        assert!(
1277            payloads
1278                .iter()
1279                .any(|payload| payload.contains("a=t") && payload.contains("i=2"))
1280        );
1281    }
1282
1283    #[test]
1284    fn clear_cleans_committed_or_possible_state_and_forces_a_new_upload() {
1285        let image =
1286            Image::rgba("placement", "asset", PixelSize::new(1, 1), [255, 0, 0, 255]).unwrap();
1287        let mut lifecycle = KittyLifecycle::new();
1288        present_image(&mut lifecycle, &image, 0, &mut RecordingTerminal::default()).unwrap();
1289        let mut cleanup = RecordingTerminal::default();
1290
1291        lifecycle.clear(&mut cleanup).unwrap();
1292
1293        assert_eq!(
1294            cleanup.application_programs().collect::<Vec<_>>(),
1295            ["Ga=d,d=I,i=1,q=2"]
1296        );
1297        let mut redrawn = RecordingTerminal::default();
1298        present_image(&mut lifecycle, &image, 0, &mut redrawn).unwrap();
1299        assert!(
1300            redrawn
1301                .application_programs()
1302                .any(|payload| { payload.contains("a=t") && payload.contains("i=2") })
1303        );
1304    }
1305
1306    #[test]
1307    fn failed_clear_remains_pending_for_a_later_cleanup() {
1308        let image =
1309            Image::rgba("placement", "asset", PixelSize::new(1, 1), [255, 0, 0, 255]).unwrap();
1310        let mut lifecycle = KittyLifecycle::new();
1311        present_image(&mut lifecycle, &image, 0, &mut RecordingTerminal::default()).unwrap();
1312
1313        lifecycle
1314            .clear(&mut RecordingTerminal::fail_command(0))
1315            .expect_err("the injected cleanup failure should remain pending");
1316        let mut retried = RecordingTerminal::default();
1317        lifecycle.clear(&mut retried).unwrap();
1318
1319        assert_eq!(
1320            retried.application_programs().collect::<Vec<_>>(),
1321            ["Ga=d,d=I,i=1,q=2"]
1322        );
1323    }
1324
1325    #[test]
1326    fn lifecycle_evicts_the_oldest_unused_upload_after_the_cache_limit() {
1327        let mut lifecycle = KittyLifecycle::new();
1328        let mut final_frame = RecordingTerminal::default();
1329        for index in 0..=MAX_UPLOADED_IMAGES {
1330            let image = Image::rgba(
1331                Key::of(&("placement", index)),
1332                Key::of(&("asset", index)),
1333                PixelSize::new(1, 1),
1334                [u8::try_from(index).unwrap(), 0, 0, 255],
1335            )
1336            .unwrap();
1337            let terminal = if index == MAX_UPLOADED_IMAGES {
1338                &mut final_frame
1339            } else {
1340                &mut RecordingTerminal::default()
1341            };
1342            present_image(&mut lifecycle, &image, 0, terminal).unwrap();
1343            present_empty(&mut lifecycle, &mut RecordingTerminal::default()).unwrap();
1344        }
1345
1346        assert!(
1347            final_frame
1348                .application_programs()
1349                .any(|payload| { payload.contains("a=d,d=I,i=1") })
1350        );
1351    }
1352
1353    #[test]
1354    fn lifecycle_deletes_a_superseded_shared_upload_after_its_last_reference() {
1355        let first = Image::rgba(
1356            "first-placement",
1357            "shared-asset",
1358            PixelSize::new(1, 1),
1359            [255, 0, 0, 255],
1360        )
1361        .unwrap();
1362        let second = Image::rgba(
1363            "second-placement",
1364            "shared-asset",
1365            PixelSize::new(1, 1),
1366            [255, 0, 0, 255],
1367        )
1368        .unwrap();
1369        let replacement = Image::rgba(
1370            "first-placement",
1371            "replacement-asset",
1372            PixelSize::new(1, 1),
1373            [0, 255, 0, 255],
1374        )
1375        .unwrap();
1376        let presentation = ImagePresentation::new();
1377        let initial = View::row(
1378            urushi::VerticalAlign::Top,
1379            [
1380                presentation.compose(&first, CellSize::new(1, 1)),
1381                presentation.compose(&second, CellSize::new(1, 1)),
1382            ],
1383        );
1384        let initial_resolved = resolve(&initial, Available::NONE).unwrap();
1385        let replaced = View::row(
1386            urushi::VerticalAlign::Top,
1387            [
1388                presentation.compose(&replacement, CellSize::new(1, 1)),
1389                presentation.compose(&second, CellSize::new(1, 1)),
1390            ],
1391        );
1392        let replaced_resolved = resolve(&replaced, Available::NONE).unwrap();
1393        let mut lifecycle = KittyLifecycle::new();
1394
1395        lifecycle
1396            .present(
1397                &initial,
1398                &initial_resolved,
1399                Some(CellPixelSize::new(8, 16)),
1400                &mut RecordingTerminal::default(),
1401            )
1402            .unwrap();
1403        let mut replacement_frame = RecordingTerminal::default();
1404        lifecycle
1405            .present(
1406                &replaced,
1407                &replaced_resolved,
1408                Some(CellPixelSize::new(8, 16)),
1409                &mut replacement_frame,
1410            )
1411            .unwrap();
1412        assert!(
1413            !replacement_frame
1414                .application_programs()
1415                .any(|payload| payload.contains("a=d,d=I,i=1"))
1416        );
1417
1418        let mut hidden = RecordingTerminal::default();
1419        present_empty(&mut lifecycle, &mut hidden).unwrap();
1420
1421        assert!(
1422            hidden
1423                .application_programs()
1424                .any(|payload| payload.contains("a=d,d=I,i=1"))
1425        );
1426    }
1427
1428    #[test]
1429    fn lifecycle_does_not_cache_an_unused_upload_beyond_the_byte_budget() {
1430        let asset_key = Key::of(&"oversized-asset");
1431        let mut state = KittyState::new();
1432        state.uploaded.insert(
1433            asset_key,
1434            Uploaded {
1435                image_id: 1,
1436                last_used: 0,
1437                byte_len: MAX_UPLOADED_BYTES + 1,
1438                retire_when_unused: false,
1439            },
1440        );
1441        let frame = DesiredFrame {
1442            assets: HashMap::new(),
1443            placements: Vec::new(),
1444        };
1445
1446        let operations = state.reconcile(&frame).unwrap();
1447
1448        assert_eq!(operations, [KittyOperation::DeleteImage { image_id: 1 }]);
1449        assert!(state.uploaded.is_empty());
1450    }
1451
1452    #[test]
1453    fn a_readopted_upload_becomes_cacheable_again() {
1454        let placement_key = Key::of(&"first-placement");
1455        let shared_placement_key = Key::of(&"second-placement");
1456        let first_asset_key = Key::of(&"first-asset");
1457        let second_asset_key = Key::of(&"second-asset");
1458        let placement = |key, asset_key, x| DesiredPlacement {
1459            key,
1460            asset_key,
1461            origin: Position::new(x, 0),
1462            size: Size::new(1, 1),
1463            source_offset: PixelPosition::new(0, 0),
1464            source_size: PixelSize::new(1, 1),
1465        };
1466        let mut state = KittyState::new();
1467        state.uploaded.insert(
1468            first_asset_key,
1469            Uploaded {
1470                image_id: 1,
1471                last_used: 0,
1472                byte_len: 4,
1473                retire_when_unused: false,
1474            },
1475        );
1476        state.uploaded.insert(
1477            second_asset_key,
1478            Uploaded {
1479                image_id: 2,
1480                last_used: 0,
1481                byte_len: 4,
1482                retire_when_unused: false,
1483            },
1484        );
1485        state.visible.insert(
1486            placement_key,
1487            VisiblePlacement {
1488                placement_id: 1,
1489                image_id: 1,
1490                placement: placement(placement_key, first_asset_key, 0),
1491            },
1492        );
1493        state.visible.insert(
1494            shared_placement_key,
1495            VisiblePlacement {
1496                placement_id: 2,
1497                image_id: 1,
1498                placement: placement(shared_placement_key, first_asset_key, 1),
1499            },
1500        );
1501
1502        state
1503            .reconcile(&DesiredFrame {
1504                assets: HashMap::new(),
1505                placements: vec![
1506                    placement(placement_key, second_asset_key, 0),
1507                    placement(shared_placement_key, first_asset_key, 1),
1508                ],
1509            })
1510            .unwrap();
1511        assert!(state.uploaded[&first_asset_key].retire_when_unused);
1512
1513        state
1514            .reconcile(&DesiredFrame {
1515                assets: HashMap::new(),
1516                placements: vec![
1517                    placement(placement_key, first_asset_key, 0),
1518                    placement(shared_placement_key, first_asset_key, 1),
1519                ],
1520            })
1521            .unwrap();
1522        assert!(!state.uploaded[&first_asset_key].retire_when_unused);
1523
1524        state
1525            .reconcile(&DesiredFrame {
1526                assets: HashMap::new(),
1527                placements: Vec::new(),
1528            })
1529            .unwrap();
1530        let shown_again = state
1531            .reconcile(&DesiredFrame {
1532                assets: HashMap::new(),
1533                placements: vec![placement(placement_key, first_asset_key, 0)],
1534            })
1535            .unwrap();
1536
1537        assert!(state.uploaded.contains_key(&first_asset_key));
1538        assert!(
1539            !shown_again
1540                .iter()
1541                .any(|operation| matches!(operation, KittyOperation::Upload { .. }))
1542        );
1543    }
1544
1545    #[test]
1546    fn swapping_two_assets_keeps_both_uploads_cacheable() {
1547        let first_placement_key = Key::of(&"first-placement");
1548        let second_placement_key = Key::of(&"second-placement");
1549        let first_asset_key = Key::of(&"first-asset");
1550        let second_asset_key = Key::of(&"second-asset");
1551        let placement = |key, asset_key, x| DesiredPlacement {
1552            key,
1553            asset_key,
1554            origin: Position::new(x, 0),
1555            size: Size::new(1, 1),
1556            source_offset: PixelPosition::new(0, 0),
1557            source_size: PixelSize::new(1, 1),
1558        };
1559        let mut state = KittyState::new();
1560        for (asset_key, image_id) in [(first_asset_key, 1), (second_asset_key, 2)] {
1561            state.uploaded.insert(
1562                asset_key,
1563                Uploaded {
1564                    image_id,
1565                    last_used: 0,
1566                    byte_len: 4,
1567                    retire_when_unused: false,
1568                },
1569            );
1570        }
1571        for (key, asset_key, image_id, placement_id, x) in [
1572            (first_placement_key, first_asset_key, 1, 1, 0),
1573            (second_placement_key, second_asset_key, 2, 2, 1),
1574        ] {
1575            state.visible.insert(
1576                key,
1577                VisiblePlacement {
1578                    placement_id,
1579                    image_id,
1580                    placement: placement(key, asset_key, x),
1581                },
1582            );
1583        }
1584
1585        state
1586            .reconcile(&DesiredFrame {
1587                assets: HashMap::new(),
1588                placements: vec![
1589                    placement(first_placement_key, second_asset_key, 0),
1590                    placement(second_placement_key, first_asset_key, 1),
1591                ],
1592            })
1593            .unwrap();
1594        state
1595            .reconcile(&DesiredFrame {
1596                assets: HashMap::new(),
1597                placements: Vec::new(),
1598            })
1599            .unwrap();
1600
1601        assert!(state.uploaded.contains_key(&first_asset_key));
1602        assert!(state.uploaded.contains_key(&second_asset_key));
1603        assert!(!state.uploaded[&first_asset_key].retire_when_unused);
1604        assert!(!state.uploaded[&second_asset_key].retire_when_unused);
1605    }
1606
1607    #[test]
1608    fn mandatory_retirement_is_counted_before_lru_eviction() {
1609        let cached_key = Key::of(&"cached");
1610        let retired_key = Key::of(&"retired");
1611        let visible_key = Key::of(&"visible");
1612        let placement_key = Key::of(&"placement");
1613        let visible_placement = DesiredPlacement {
1614            key: placement_key,
1615            asset_key: visible_key,
1616            origin: Position::new(0, 0),
1617            size: Size::new(1, 1),
1618            source_offset: PixelPosition::new(0, 0),
1619            source_size: PixelSize::new(1, 1),
1620        };
1621        let mebibytes = 1024 * 1024;
1622        let mut state = KittyState::new();
1623        for (key, image_id, byte_len, retire_when_unused) in [
1624            (cached_key, 1, 20 * mebibytes, false),
1625            (retired_key, 2, 20 * mebibytes, true),
1626            (visible_key, 3, 40 * mebibytes, false),
1627        ] {
1628            state.uploaded.insert(
1629                key,
1630                Uploaded {
1631                    image_id,
1632                    last_used: image_id.into(),
1633                    byte_len,
1634                    retire_when_unused,
1635                },
1636            );
1637        }
1638        state.visible.insert(
1639            placement_key,
1640            VisiblePlacement {
1641                placement_id: 1,
1642                image_id: 3,
1643                placement: visible_placement,
1644            },
1645        );
1646
1647        let operations = state
1648            .reconcile(&DesiredFrame {
1649                assets: HashMap::new(),
1650                placements: vec![visible_placement],
1651            })
1652            .unwrap();
1653
1654        assert_eq!(operations, [KittyOperation::DeleteImage { image_id: 2 }]);
1655        assert!(state.uploaded.contains_key(&cached_key));
1656        assert!(state.uploaded.contains_key(&visible_key));
1657        assert!(!state.uploaded.contains_key(&retired_key));
1658    }
1659}