Skip to content

Commit f822a07

Browse files
authored
icy_term: keep up with streamed sixel video (#191)
* Leave the scrollback out of the render snapshot TextScreen::render_snapshot() cloned the whole screen, scrollback included, and each scrollback line keeps a rendered pixel strip (640x16 RGBA, about 40 KB). With the default 2000 lines full, every UI frame copied about 80 MB while holding the screen lock: 39 ms per frame. The terminal thread waited for the lock long enough to drop from about 28 to about 15 sixel frames a second, so a 20 fps sixel stream fell further and further behind. The renderer never reads the scrollback (viewing it goes through snapshot_scrollback()), so the snapshot now copies everything else and starts with an empty scrollback. Time under the lock per frame drops to 0.16 ms. * Skip the sixel display pause when a newer sixel is queued The terminal thread sleeps 20 ms after each sixel so the UI can show every frame of an animation. On Windows that sleep takes about 31 ms, while decoding a 460x360 frame takes under 1 ms, so the pause caps a sixel stream at about 28 frames a second however far behind the terminal is. Over SSH nothing slows the host down, so the lag then grows without limit. On a live connection the pause is now skipped when the host has already sent a newer sixel. Every frame is still decoded and placed; the UI shows the newest one, the way SyncTERM and xterm degrade. When the terminal keeps up, and for local playback, every frame still gets its pause, so sixel animations are unchanged. * Don't pause after sixels on a live connection The 20 ms pause after each sixel lets the UI show every frame of an animation played back locally. On a connection the host paces its own frames, and the pause only costs time: NESDOOR streams NES video at up to 60 frames a second, and the terminal thread spent about 80% of each second asleep in these pauses. It ran at the very edge of the stream, fell behind on every burst, and keystrokes waited behind the pauses too. The previous change skipped the pause only when a newer sixel was already queued, which does not help when frames arrive one per read. The pause now applies only without a connection, as in SyncTERM and xterm, which draw sixels as fast as they arrive. * Process received data in the tick that reads it Each tick processed what was already pending and only then read from the connection, so new data waited a full tick (about 13 ms) before it reached the screen. Process it right after the read instead. With baud emulation the second pass releases nothing until enough time has passed, so emulated speeds are unchanged.
1 parent 846b5b4 commit f822a07

3 files changed

Lines changed: 106 additions & 20 deletions

File tree

‎crates/icy_engine/src/text_buffer/text_screen.rs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,21 @@ impl TextPane for TextScreen {
100100

101101
impl Screen for TextScreen {
102102
fn render_snapshot(&self) -> Option<Box<dyn Screen>> {
103-
Some(Box::new(self.clone()))
103+
// Rendering never reads the scrollback. It keeps a rendered pixel strip per line, so a full
104+
// scrollback is tens of megabytes, and copying it every frame would hold the screen lock
105+
// long enough to stall the terminal thread.
106+
Some(Box::new(Self {
107+
caret: self.caret.clone(),
108+
buffer: self.buffer.clone(),
109+
current_layer: self.current_layer,
110+
selection_opt: self.selection_opt,
111+
mouse_fields: self.mouse_fields.clone(),
112+
saved_caret_pos: self.saved_caret_pos,
113+
saved_caret_state: self.saved_caret_state.clone(),
114+
scan_lines: self.scan_lines,
115+
scrollback_buffer: ScrollbackBuffer::new(),
116+
unicode: self.unicode.clone(),
117+
}))
104118
}
105119

106120
fn buffer_type(&self) -> crate::BufferType {

‎crates/icy_engine/tests/text_screen.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1529,3 +1529,21 @@ fn test_kitty_keyboard_resets_with_the_terminal() {
15291529
screen.reset_terminal();
15301530
assert!(!screen.terminal_state().kitty_keyboard.is_active());
15311531
}
1532+
1533+
#[test]
1534+
fn test_render_snapshot_leaves_out_scrollback() {
1535+
let mut screen = TextScreen::new(Size::new(80, 25));
1536+
screen.terminal_state_mut().is_terminal_buffer = true;
1537+
let mut parser = AnsiParser::new();
1538+
for line in 0..40 {
1539+
parser.parse(format!("\x1b[3{}mline {line}\r\n", line % 8).as_bytes(), &mut ScreenSink::new(&mut screen));
1540+
}
1541+
assert!(!screen.scrollback_buffer.chunks.is_empty());
1542+
1543+
let mut snapshot = screen.render_snapshot().unwrap();
1544+
let region = Rectangle::from(0, 0, screen.resolution().width, screen.resolution().height);
1545+
let options = RenderOptions::default();
1546+
assert_eq!(snapshot.render_region_to_rgba(region, &options), screen.render_region_to_rgba(region, &options));
1547+
let snapshot = snapshot.as_any_mut().downcast_mut::<TextScreen>().unwrap();
1548+
assert!(snapshot.scrollback_buffer.chunks.is_empty());
1549+
}

‎crates/icy_term/src/terminal/terminal_thread.rs‎

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -697,24 +697,7 @@ impl TerminalThread {
697697
pending_data.append(&mut self.injected_data);
698698
}
699699

700-
// Process pending data with baud emulation
701-
if pending_offset < pending_data.len() {
702-
let remaining = pending_data.len() - pending_offset;
703-
let bytes_to_send = self.baud_emulator.calculate_bytes_to_send(remaining);
704-
if bytes_to_send > 0 {
705-
let end = pending_offset + bytes_to_send;
706-
let chunk = &pending_data[pending_offset..end];
707-
self.write_to_capture(chunk).await;
708-
self.process_data(chunk).await;
709-
pending_offset = end;
710-
711-
// Clear buffer when fully processed
712-
if pending_offset >= pending_data.len() {
713-
pending_data.clear();
714-
pending_offset = 0;
715-
}
716-
}
717-
}
700+
self.process_pending_data(&mut pending_data, &mut pending_offset).await;
718701

719702
// Check for pending auto-transfers
720703
if let Some((protocol_id, is_download, filename)) = self.auto_transfer.take() {
@@ -772,6 +755,8 @@ impl TerminalThread {
772755
if let Some(new_data) = self.read_connection_raw(&mut read_buffer).await {
773756
if !new_data.is_empty() {
774757
pending_data.extend_from_slice(&new_data);
758+
// Show it now rather than a tick later
759+
self.process_pending_data(&mut pending_data, &mut pending_offset).await;
775760
}
776761
}
777762
}
@@ -780,6 +765,28 @@ impl TerminalThread {
780765
}
781766
}
782767

768+
/// Processes as much of `pending_data` as the baud emulation allows.
769+
async fn process_pending_data(&mut self, pending_data: &mut Vec<u8>, pending_offset: &mut usize) {
770+
if *pending_offset >= pending_data.len() {
771+
return;
772+
}
773+
let remaining = pending_data.len() - *pending_offset;
774+
let bytes_to_send = self.baud_emulator.calculate_bytes_to_send(remaining);
775+
if bytes_to_send > 0 {
776+
let end = *pending_offset + bytes_to_send;
777+
let chunk = &pending_data[*pending_offset..end];
778+
self.write_to_capture(chunk).await;
779+
self.process_data(chunk).await;
780+
*pending_offset = end;
781+
782+
// Clear buffer when fully processed
783+
if *pending_offset >= pending_data.len() {
784+
pending_data.clear();
785+
*pending_offset = 0;
786+
}
787+
}
788+
}
789+
783790
fn perform_resize(&mut self, width: u16, height: u16) {
784791
let mut state = self.edit_screen.lock();
785792
if let Some(editable) = state.as_editable() {
@@ -1768,7 +1775,12 @@ impl TerminalThread {
17681775
}
17691776
}
17701777
}
1771-
tokio::time::sleep(Duration::from_millis(20)).await;
1778+
// Pause so the UI can show each frame of a sixel animation played back
1779+
// locally. On a live connection the host paces its frames; pausing there
1780+
// only puts the terminal behind the stream (and holds up typed keys).
1781+
if self.connection.is_none() {
1782+
tokio::time::sleep(Duration::from_millis(20)).await;
1783+
}
17721784
}
17731785
Err(err) => {
17741786
if shared {
@@ -3161,6 +3173,48 @@ mod tests {
31613173
}
31623174
}
31633175

3176+
/// Full-screen style animation: every frame replaces the previous one, and only the last frame is red.
3177+
fn sixel_frames(count: usize) -> Vec<u8> {
3178+
let mut data = Vec::new();
3179+
for frame in 0..count {
3180+
let color = if frame + 1 == count { "100;0;0" } else { "0;0;100" };
3181+
data.extend_from_slice(format!("\x1b[1;1H\x1bPq#1;2;{color}#1!16~-!16~\x1b\\").as_bytes());
3182+
}
3183+
data
3184+
}
3185+
3186+
#[tokio::test]
3187+
async fn sixel_frames_from_a_connection_skip_the_display_pause() {
3188+
let (mut terminal, _, screen) = test_terminal();
3189+
let frames = sixel_frames(20);
3190+
let start = std::time::Instant::now();
3191+
3192+
// Both all at once and one frame per read, the way a host pacing its frames delivers them.
3193+
terminal.process_data(&frames).await;
3194+
for frame in frames.split_inclusive(|byte| *byte == b'\\') {
3195+
terminal.process_data(frame).await;
3196+
}
3197+
3198+
// Pausing after each of the 40 frames would take at least 800 ms.
3199+
assert!(start.elapsed() < std::time::Duration::from_millis(200), "{:?}", start.elapsed());
3200+
let mut screen = screen.lock();
3201+
let text = screen.as_any_mut().downcast_mut::<TextScreen>().unwrap();
3202+
let sixels = &text.buffer.layers[0].sixels;
3203+
assert_eq!(sixels.len(), 1);
3204+
assert_eq!(&sixels[0].picture_data[..4], &[255, 0, 0, 255]);
3205+
}
3206+
3207+
#[tokio::test]
3208+
async fn sixel_frames_played_locally_keep_the_display_pause() {
3209+
let (mut terminal, _, _) = test_terminal();
3210+
terminal.connection = None;
3211+
let start = std::time::Instant::now();
3212+
3213+
terminal.process_data(&sixel_frames(3)).await;
3214+
3215+
assert!(start.elapsed() >= std::time::Duration::from_millis(60), "{:?}", start.elapsed());
3216+
}
3217+
31643218
#[test]
31653219
fn auto_login_enter_uses_active_terminal_mapping() {
31663220
assert_eq!(TerminalThread::auto_login_control_code(TerminalEmulation::ATAscii, b'\r'), vec![0x9B]);

0 commit comments

Comments
 (0)