1use 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#[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 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 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
467pub 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}