1use std::collections::{HashMap, HashSet};
4use std::io;
5use std::sync::Arc;
6
7use icy_sixel::{EncodeOptions, QuantizeMethod};
8use urushi::{Key, ResolvedView, View};
9use urushi_terminal::{
10 ClearRegion, Command, CommandWriter, ControlString, CursorMove, PixelSize as CellPixelSize,
11 Position as TerminalPosition, TerminalOutput,
12};
13
14use crate::{GraphicPlacement, Image, RgbaRaster};
15
16const MAX_COLORS: u16 = 256;
17const MAX_ENCODED_BANDS: usize = 64;
18const MAX_ENCODED_BYTES: usize = 64 * 1024 * 1024;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21struct BandKey {
22 asset_key: Key,
23 logical_width: usize,
24 logical_height: usize,
25 target_x: usize,
26 target_y: usize,
27 width: usize,
28 height: usize,
29}
30
31#[derive(Debug, Clone)]
32struct EncodedBand {
33 payload: Arc<str>,
34 last_used: u64,
35}
36
37#[derive(Debug, Clone, Copy)]
38struct DesiredBand<'a> {
39 key: BandKey,
40 raster: &'a RgbaRaster,
41 column: usize,
42 row: usize,
43}
44
45#[derive(Debug, Default)]
53pub struct SixelLifecycle {
54 encoded: HashMap<BandKey, EncodedBand>,
55 generation: u64,
56 cursor_restore_pending: bool,
57}
58
59impl SixelLifecycle {
60 #[must_use]
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 pub fn present(
72 &mut self,
73 view: &View,
74 resolved: &ResolvedView,
75 cell_pixels: CellPixelSize,
76 terminal: &mut (impl CommandWriter + ?Sized),
77 ) -> io::Result<()> {
78 self.present_images(resolved, crate::image::collect(view), cell_pixels, terminal)
79 }
80
81 pub fn clear(&mut self, terminal: &mut (impl CommandWriter + ?Sized)) -> io::Result<()> {
86 self.restore_cursor(terminal)?;
87 terminal.write_command(Command::Clear(ClearRegion::Screen))?;
88 terminal.write_command(Command::MoveCursor(CursorMove::To(TerminalPosition::new(
89 0, 0,
90 ))))?;
91 TerminalOutput::flush(terminal)
92 }
93
94 fn present_images<'a>(
95 &mut self,
96 resolved: &ResolvedView,
97 images: impl IntoIterator<Item = &'a Image>,
98 cell_pixels: CellPixelSize,
99 terminal: &mut (impl CommandWriter + ?Sized),
100 ) -> io::Result<()> {
101 validate_cell_pixels(cell_pixels)?;
102 self.restore_cursor(terminal)?;
103 self.generation = self.generation.saturating_add(1);
104 let bands = desired_bands(resolved, images, cell_pixels)?;
105 let live = bands.iter().map(|band| band.key).collect::<HashSet<_>>();
106
107 let result = (|| {
108 for band in bands {
109 let payload = self.encoded_band(band)?;
110 write_band(
111 band.column,
112 band.row,
113 &payload,
114 terminal,
115 &mut self.cursor_restore_pending,
116 )?;
117 }
118 TerminalOutput::flush(terminal)
119 })();
120 if result.is_ok() {
121 self.cursor_restore_pending = false;
122 }
123 self.enforce_cache_limits(&live, MAX_ENCODED_BANDS, MAX_ENCODED_BYTES);
124 result
125 }
126
127 fn encoded_band(&mut self, band: DesiredBand<'_>) -> io::Result<Arc<str>> {
128 if let Some(encoded) = self.encoded.get_mut(&band.key) {
129 encoded.last_used = self.generation;
130 return Ok(Arc::clone(&encoded.payload));
131 }
132
133 let rgba = scale_band(band)?;
134 let options = EncodeOptions {
135 max_colors: MAX_COLORS,
136 diffusion: 0.0,
137 quantize_method: QuantizeMethod::Wu,
138 };
139 #[allow(deprecated)]
140 let encoded = icy_sixel::sixel_encode(&rgba, band.key.width, band.key.height, &options)
141 .map_err(|error| io::Error::other(format!("could not encode Sixel: {error}")))?;
142 let payload = encoded
143 .strip_prefix("\x1bP")
144 .and_then(|encoded| encoded.strip_suffix("\x1b\\"))
145 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid Sixel framing"))?;
146 ControlString::try_from(payload)
147 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
148 let payload = Arc::<str>::from(payload);
149 self.encoded.insert(
150 band.key,
151 EncodedBand {
152 payload: Arc::clone(&payload),
153 last_used: self.generation,
154 },
155 );
156 Ok(payload)
157 }
158
159 fn enforce_cache_limits(
160 &mut self,
161 live: &HashSet<BandKey>,
162 max_entries: usize,
163 max_bytes: usize,
164 ) {
165 loop {
166 let bytes = self.encoded.values().fold(0usize, |total, band| {
167 total.saturating_add(band.payload.len())
168 });
169 if self.encoded.len() <= max_entries && bytes <= max_bytes {
170 break;
171 }
172 let Some(key) = self
173 .encoded
174 .iter()
175 .min_by_key(|(key, band)| {
176 (
177 live.contains(key),
178 band.last_used,
179 key.target_y,
180 key.target_x,
181 )
182 })
183 .map(|(key, _)| *key)
184 else {
185 break;
186 };
187 self.encoded.remove(&key);
188 }
189 }
190
191 fn restore_cursor(&mut self, terminal: &mut (impl CommandWriter + ?Sized)) -> io::Result<()> {
192 if !self.cursor_restore_pending {
193 return Ok(());
194 }
195 terminal.write_command(Command::RestoreCursorPosition)?;
196 TerminalOutput::flush(terminal)?;
197 self.cursor_restore_pending = false;
198 Ok(())
199 }
200}
201
202pub fn render_sixel<'a>(
204 view: &ResolvedView,
205 images: impl IntoIterator<Item = &'a Image>,
206 cell_pixels: CellPixelSize,
207 terminal: &mut (impl CommandWriter + ?Sized),
208) -> io::Result<()> {
209 SixelLifecycle::new().present_images(view, images, cell_pixels, terminal)
210}
211
212fn validate_cell_pixels(cell_pixels: CellPixelSize) -> io::Result<()> {
213 if cell_pixels.width() == 0 || cell_pixels.height() == 0 {
214 return Err(io::Error::new(
215 io::ErrorKind::InvalidInput,
216 "Sixel rendering requires non-zero character-cell pixel dimensions",
217 ));
218 }
219 Ok(())
220}
221
222fn desired_bands<'a>(
223 view: &ResolvedView,
224 images: impl IntoIterator<Item = &'a Image>,
225 cell_pixels: CellPixelSize,
226) -> io::Result<Vec<DesiredBand<'a>>> {
227 let mut bands = Vec::new();
228 for placement in images
229 .into_iter()
230 .filter_map(|image| image.placement(view, Some(cell_pixels)))
231 {
232 append_bands(placement, cell_pixels, &mut bands)?;
233 }
234 Ok(bands)
235}
236
237fn append_bands<'a>(
238 placement: GraphicPlacement<'a>,
239 cell_pixels: CellPixelSize,
240 bands: &mut Vec<DesiredBand<'a>>,
241) -> io::Result<()> {
242 let Some(origin) = placement.visible_origin() else {
243 return Ok(());
244 };
245 let Some(size) = placement.visible_size() else {
246 return Ok(());
247 };
248 if size.is_empty() {
249 return Ok(());
250 }
251 let column = usize::try_from(origin.x).map_err(|_| {
252 io::Error::new(
253 io::ErrorKind::InvalidInput,
254 "a Sixel image column cannot be negative",
255 )
256 })?;
257 let row = usize::try_from(origin.y).map_err(|_| {
258 io::Error::new(
259 io::ErrorKind::InvalidInput,
260 "a Sixel image row cannot be negative",
261 )
262 })?;
263 let logical = placement.logical_size();
264 let logical_width = logical
265 .width()
266 .checked_mul(cell_pixels.width())
267 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel width overflow"))?;
268 let logical_height = logical
269 .height()
270 .checked_mul(cell_pixels.height())
271 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel height overflow"))?;
272 let offset_x = usize::try_from(
273 origin
274 .x
275 .checked_sub(placement.logical_origin().x)
276 .ok_or_else(|| {
277 io::Error::new(
278 io::ErrorKind::InvalidData,
279 "Sixel clip precedes its logical image",
280 )
281 })?,
282 )
283 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Sixel clip offset overflow"))?;
284 let offset_y = usize::try_from(
285 origin
286 .y
287 .checked_sub(placement.logical_origin().y)
288 .ok_or_else(|| {
289 io::Error::new(
290 io::ErrorKind::InvalidData,
291 "Sixel clip precedes its logical image",
292 )
293 })?,
294 )
295 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Sixel clip offset overflow"))?;
296 let target_x = offset_x
297 .checked_mul(cell_pixels.width())
298 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel offset overflow"))?;
299 let width = size
300 .width()
301 .checked_mul(cell_pixels.width())
302 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel width overflow"))?;
303
304 for band in 0..size.height() {
305 let target_y = offset_y
306 .checked_add(band)
307 .and_then(|row| row.checked_mul(cell_pixels.height()))
308 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Sixel offset overflow"))?;
309 bands.push(DesiredBand {
310 key: BandKey {
311 asset_key: placement.raster().key(),
312 logical_width,
313 logical_height,
314 target_x,
315 target_y,
316 width,
317 height: cell_pixels.height(),
318 },
319 raster: placement.raster(),
320 column,
321 row: row + band,
322 });
323 }
324 Ok(())
325}
326
327fn scale_band(band: DesiredBand<'_>) -> io::Result<Vec<u8>> {
328 let source_width = usize::try_from(band.raster.size().width())
329 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source width overflow"))?;
330 let source_height = usize::try_from(band.raster.size().height())
331 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source height overflow"))?;
332 let length = band
333 .key
334 .width
335 .checked_mul(band.key.height)
336 .and_then(|pixels| pixels.checked_mul(4))
337 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "scaled raster overflow"))?;
338 let mut scaled = vec![0; length];
339 for target_y in 0..band.key.height {
340 let full_y =
341 band.key.target_y.checked_add(target_y).ok_or_else(|| {
342 io::Error::new(io::ErrorKind::InvalidInput, "scaled row overflow")
343 })?;
344 let raster_y = full_y as u128 * source_height as u128 / band.key.logical_height as u128;
345 let raster_y = usize::try_from(raster_y).map_err(|_| {
346 io::Error::new(io::ErrorKind::InvalidInput, "scaled source row overflow")
347 })?;
348 for target_x in 0..band.key.width {
349 let full_x = band.key.target_x.checked_add(target_x).ok_or_else(|| {
350 io::Error::new(io::ErrorKind::InvalidInput, "scaled column overflow")
351 })?;
352 let raster_x = full_x as u128 * source_width as u128 / band.key.logical_width as u128;
353 let raster_x = usize::try_from(raster_x).map_err(|_| {
354 io::Error::new(io::ErrorKind::InvalidInput, "scaled source column overflow")
355 })?;
356 let source = raster_y
357 .checked_mul(source_width)
358 .and_then(|offset| offset.checked_add(raster_x))
359 .and_then(|offset| offset.checked_mul(4))
360 .ok_or_else(|| {
361 io::Error::new(io::ErrorKind::InvalidInput, "source raster offset overflow")
362 })?;
363 let target = (target_y * band.key.width + target_x) * 4;
364 let pixel = band.raster.bytes().get(source..source + 4).ok_or_else(|| {
365 io::Error::new(io::ErrorKind::InvalidData, "source raster pixel is missing")
366 })?;
367 scaled[target..target + 4].copy_from_slice(pixel);
368 }
369 }
370 Ok(scaled)
371}
372
373fn write_band(
374 column: usize,
375 row: usize,
376 encoded: &str,
377 terminal: &mut (impl CommandWriter + ?Sized),
378 cursor_restore_pending: &mut bool,
379) -> io::Result<()> {
380 let payload = ControlString::try_from(encoded)
381 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
382 terminal.write_command(Command::SaveCursorPosition)?;
383 *cursor_restore_pending = true;
384 let result = terminal
385 .write_command(Command::MoveCursor(CursorMove::To(TerminalPosition::new(
386 column, row,
387 ))))
388 .and_then(|()| terminal.write_command(Command::DeviceControl(payload)))
389 .and_then(|()| terminal.write_command(Command::RestoreCursorPosition));
390 if result.is_ok() {
391 *cursor_restore_pending = false;
392 }
393 result
394}
395
396#[cfg(test)]
397mod tests {
398 use urushi::{Available, Canvas, CanvasContext, CanvasItem, Position, Size, View, resolve};
399 use urushi_terminal::backend::ansi::AnsiWriter;
400
401 use crate::{CellSize, ImagePresentation, PixelSize};
402
403 use super::*;
404
405 #[derive(Default)]
406 struct RecordingTerminal {
407 commands: Vec<String>,
408 command_count: usize,
409 fail_command: Option<usize>,
410 flushes: usize,
411 }
412
413 impl CommandWriter for RecordingTerminal {
414 fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
415 let index = self.command_count;
416 self.command_count += 1;
417 if self.fail_command == Some(index) {
418 return Err(io::Error::other("injected command failure"));
419 }
420 self.commands.push(match command {
421 Command::SaveCursorPosition => "save".to_owned(),
422 Command::RestoreCursorPosition => "restore".to_owned(),
423 Command::MoveCursor(CursorMove::To(position)) => {
424 format!("move {},{}", position.column(), position.row())
425 }
426 Command::DeviceControl(payload) => format!("DCS {}", payload.as_str()),
427 Command::Clear(ClearRegion::Screen) => "clear".to_owned(),
428 other => format!("{other:?}"),
429 });
430 Ok(())
431 }
432 }
433
434 impl TerminalOutput for RecordingTerminal {
435 fn flush(&mut self) -> io::Result<()> {
436 self.flushes += 1;
437 Ok(())
438 }
439 }
440
441 fn image() -> Image {
442 Image::rgba(
443 "placement",
444 "asset",
445 PixelSize::new(2, 12),
446 vec![255; 2 * 12 * 4],
447 )
448 .unwrap()
449 }
450
451 #[derive(Debug, Clone, PartialEq)]
452 struct PlacedImage {
453 view: View,
454 y: i64,
455 }
456
457 impl CanvasItem for PlacedImage {
458 fn draw(&self, context: &mut CanvasContext) {
459 context.view(
460 Position::new(0, self.y),
461 self.view.clone(),
462 Some(1),
463 Some(4),
464 );
465 }
466 }
467
468 fn viewport(image: &Image, origin: i64) -> (View, ResolvedView) {
469 let image = ImagePresentation::new().compose(image, CellSize::new(1, 4));
470 let view = View::canvas(Canvas::new().extent(Size::new(1, 3)).item(PlacedImage {
471 view: image,
472 y: -origin,
473 }));
474 let resolved = resolve(&view, Available::NONE).unwrap();
475 (view, resolved)
476 }
477
478 #[test]
479 fn scales_and_writes_one_image_as_cell_row_bands() {
480 let image = image();
481 let view = ImagePresentation::new().compose(&image, CellSize::new(1, 4));
482 let resolved = resolve(&view, Available::NONE).unwrap();
483 let mut output = AnsiWriter::new(Vec::new());
484
485 render_sixel(&resolved, [&image], CellPixelSize::new(2, 3), &mut output).unwrap();
486
487 let output = String::from_utf8(output.into_inner()).unwrap();
488 assert_eq!(output.matches("\x1bP").count(), 4);
489 assert!(output.starts_with("\x1b7\x1b[1;1H\x1bP"));
490 assert!(output.contains("\"1;1;2;3"));
491 assert!(output.ends_with("\x1b\\\x1b8"));
492 }
493
494 #[test]
495 fn scrolling_reuses_overlapping_encoded_bands() {
496 let image = image();
497 let mut lifecycle = SixelLifecycle::new();
498 let (_, first_resolved) = viewport(&image, 0);
499 lifecycle
500 .present_images(
501 &first_resolved,
502 [&image],
503 CellPixelSize::new(2, 3),
504 &mut AnsiWriter::new(Vec::new()),
505 )
506 .unwrap();
507 assert_eq!(lifecycle.encoded.len(), 3);
508
509 let (_, second_resolved) = viewport(&image, 1);
510 lifecycle
511 .present_images(
512 &second_resolved,
513 [&image],
514 CellPixelSize::new(2, 3),
515 &mut AnsiWriter::new(Vec::new()),
516 )
517 .unwrap();
518
519 assert_eq!(lifecycle.encoded.len(), 4);
520 assert_eq!(
521 lifecycle
522 .encoded
523 .keys()
524 .filter(|key| matches!(key.target_y, 3 | 6))
525 .count(),
526 2
527 );
528 }
529
530 #[test]
531 fn horizontal_clip_selects_the_matching_source_pixels() {
532 #[derive(Debug, Clone, PartialEq)]
533 struct ShiftedImage(View);
534
535 impl CanvasItem for ShiftedImage {
536 fn draw(&self, context: &mut CanvasContext) {
537 context.view(Position::new(-1, 0), self.0.clone(), Some(4), Some(1));
538 }
539 }
540
541 let image = Image::rgba(
542 "placement",
543 "asset",
544 PixelSize::new(4, 1),
545 [
546 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
547 ],
548 )
549 .unwrap();
550 let image_view = ImagePresentation::new().compose(&image, CellSize::new(4, 1));
551 let view = View::canvas(
552 Canvas::new()
553 .extent(Size::new(2, 1))
554 .item(ShiftedImage(image_view)),
555 );
556 let resolved = resolve(&view, Available::NONE).unwrap();
557
558 let bands = desired_bands(&resolved, [&image], CellPixelSize::new(1, 1)).unwrap();
559 assert_eq!(bands.len(), 1);
560 assert_eq!(bands[0].key.target_x, 1);
561 assert_eq!(bands[0].key.width, 2);
562 assert_eq!(
563 scale_band(bands[0]).unwrap(),
564 [0, 255, 0, 255, 0, 0, 255, 255]
565 );
566 }
567
568 #[test]
569 fn clear_preserves_encoded_cache_for_the_full_redraw() {
570 let image = image();
571 let (_, resolved) = viewport(&image, 0);
572 let mut lifecycle = SixelLifecycle::new();
573 lifecycle
574 .present_images(
575 &resolved,
576 [&image],
577 CellPixelSize::new(2, 3),
578 &mut AnsiWriter::new(Vec::new()),
579 )
580 .unwrap();
581 let cached = lifecycle.encoded.len();
582 let mut output = AnsiWriter::new(Vec::new());
583
584 lifecycle.clear(&mut output).unwrap();
585
586 assert_eq!(output.into_inner(), b"\x1b[2J\x1b[1;1H");
587 assert_eq!(lifecycle.encoded.len(), cached);
588 }
589
590 #[test]
591 fn failed_output_restores_the_cursor_then_repaints_the_complete_scene() {
592 let image = image();
593 let (_, resolved) = viewport(&image, 0);
594 let mut lifecycle = SixelLifecycle::new();
595 let mut failed = RecordingTerminal {
596 fail_command: Some(2),
597 ..RecordingTerminal::default()
598 };
599
600 lifecycle
601 .present_images(&resolved, [&image], CellPixelSize::new(2, 3), &mut failed)
602 .expect_err("the injected DCS failure should abort the frame");
603 let mut recovered = RecordingTerminal::default();
604 lifecycle
605 .present_images(
606 &resolved,
607 [&image],
608 CellPixelSize::new(2, 3),
609 &mut recovered,
610 )
611 .unwrap();
612
613 assert_eq!(
614 recovered.commands.first().map(String::as_str),
615 Some("restore")
616 );
617 assert_eq!(
618 recovered
619 .commands
620 .iter()
621 .filter(|command| command.starts_with("DCS "))
622 .count(),
623 3
624 );
625 assert_eq!(recovered.flushes, 2);
626 }
627
628 #[test]
629 fn encoded_cache_evicts_the_oldest_unused_bands() {
630 let image = Image::rgba(
631 "placement",
632 "asset",
633 PixelSize::new(1, 80),
634 vec![255; 80 * 4],
635 )
636 .unwrap();
637 let mut lifecycle = SixelLifecycle::new();
638
639 for target_y in 0..70 {
640 lifecycle.generation = target_y + 1;
641 let key = BandKey {
642 asset_key: image.raster().key(),
643 logical_width: 1,
644 logical_height: 80,
645 target_x: 0,
646 target_y: usize::try_from(target_y).unwrap(),
647 width: 1,
648 height: 1,
649 };
650 lifecycle
651 .encoded_band(DesiredBand {
652 key,
653 raster: image.raster(),
654 column: 0,
655 row: 0,
656 })
657 .unwrap();
658 lifecycle.enforce_cache_limits(
659 &HashSet::from([key]),
660 MAX_ENCODED_BANDS,
661 MAX_ENCODED_BYTES,
662 );
663 }
664
665 assert_eq!(lifecycle.encoded.len(), MAX_ENCODED_BANDS);
666 assert!(lifecycle.encoded.keys().all(|key| key.target_y >= 6));
667 }
668
669 #[test]
670 fn encoded_cache_limits_apply_when_every_entry_is_in_the_visible_scene() {
671 let mut lifecycle = SixelLifecycle::new();
672 let mut live = HashSet::new();
673 for target_y in 0..3 {
674 let key = BandKey {
675 asset_key: Key::from("asset"),
676 logical_width: 1,
677 logical_height: 3,
678 target_x: 0,
679 target_y,
680 width: 1,
681 height: 1,
682 };
683 live.insert(key);
684 lifecycle.encoded.insert(
685 key,
686 EncodedBand {
687 payload: Arc::from("1234"),
688 last_used: u64::try_from(target_y).unwrap(),
689 },
690 );
691 }
692
693 lifecycle.enforce_cache_limits(&live, 2, 6);
694
695 assert_eq!(lifecycle.encoded.len(), 1);
696 assert_eq!(
697 lifecycle
698 .encoded
699 .values()
700 .map(|band| band.payload.len())
701 .sum::<usize>(),
702 4
703 );
704 assert!(lifecycle.encoded.keys().all(|key| key.target_y == 2));
705 }
706
707 #[test]
708 fn rejects_missing_cell_pixel_geometry() {
709 let image = image();
710 let view = ImagePresentation::new().compose(&image, CellSize::new(1, 1));
711 let resolved = resolve(&view, Available::NONE).unwrap();
712 let mut output = AnsiWriter::new(Vec::new());
713
714 let error = render_sixel(&resolved, [&image], CellPixelSize::default(), &mut output)
715 .expect_err("zero geometry is rejected");
716
717 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
718 assert!(output.into_inner().is_empty());
719 }
720}