Skip to content

Commit 15485ea

Browse files
authored
Fix native image rendering for Kitty protocol (#181)
- Delete all placements before each redraw so images that scroll out of view are cleared instead of ghosting on screen - Use source-rect crop params (y/h) instead of scaling when images are partially visible, preventing stretching - Encode images at full dimensions for caching and crop at display time, fixing inconsistent cache hits at different visible heights - Skip image emit when visible images match previous frame to prevent flicker during unrelated redraws - Wrap entire render cycle (clear + draw + image overlay) in synchronized update so the terminal renders atomically - Suppress terminal responses on graphics commands (q=2)
1 parent ce35628 commit 15485ea

File tree

4 files changed

+73
-16
lines changed

4 files changed

+73
-16
lines changed

src/app.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,16 @@ fn show_desktop_notification(sender: &str, body: &str, is_group: bool, group_nam
9191
}
9292

9393
/// An image visible on screen, for native protocol overlay rendering.
94+
#[derive(PartialEq, Eq)]
9495
pub struct VisibleImage {
9596
pub x: u16,
9697
pub y: u16,
9798
pub width: u16,
9899
pub height: u16,
100+
/// Total image height in cells (before viewport clipping).
101+
pub full_height: u16,
102+
/// Cells cropped from the top when the image is partially scrolled out.
103+
pub crop_top: u16,
99104
pub path: String,
100105
}
101106

@@ -351,10 +356,12 @@ pub struct App {
351356
pub image_protocol: ImageProtocol,
352357
/// Images visible on screen for native protocol overlay (cleared each frame)
353358
pub visible_images: Vec<VisibleImage>,
359+
/// Previous frame's visible images, for skipping redundant image redraws
360+
pub prev_visible_images: Vec<VisibleImage>,
354361
/// Experimental: use native terminal image protocols (Kitty/iTerm2) instead of halfblock
355362
pub native_images: bool,
356-
/// Cache of base64-encoded pre-resized PNGs for native protocol (path → base64)
357-
pub native_image_cache: HashMap<String, String>,
363+
/// Cache of pre-resized PNGs for native protocol (path → (base64, pixel_w, pixel_h))
364+
pub native_image_cache: HashMap<String, (String, u32, u32)>,
358365
/// Previous active conversation ID, for detecting chat switches
359366
pub prev_active_conversation: Option<String>,
360367
/// Incognito mode — in-memory DB, no local persistence
@@ -2463,6 +2470,7 @@ impl App {
24632470
link_url_map: HashMap::new(),
24642471
image_protocol: image_render::detect_protocol(),
24652472
visible_images: Vec::new(),
2473+
prev_visible_images: Vec::new(),
24662474
native_images: false,
24672475
native_image_cache: HashMap::new(),
24682476
prev_active_conversation: None,

src/image_render.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ pub fn detect_protocol() -> ImageProtocol {
3535

3636
/// Pre-resize an image and encode as PNG for native terminal protocol rendering.
3737
///
38-
/// Returns the base64-encoded PNG data, sized to look good at the given cell
39-
/// dimensions. Assumes ~8px per cell width and ~16px per cell height.
40-
pub fn encode_native_png(path: &Path, cell_width: u32, cell_height: u32) -> Option<String> {
38+
/// Returns `(base64_data, pixel_width, pixel_height)` sized to look good at the
39+
/// given cell dimensions. Assumes ~8px per cell width and ~16px per cell height.
40+
pub fn encode_native_png(path: &Path, cell_width: u32, cell_height: u32) -> Option<(String, u32, u32)> {
4141
let img = image::open(path).ok()?;
4242
let (orig_w, orig_h) = img.dimensions();
4343
if orig_w == 0 || orig_h == 0 {
@@ -65,7 +65,7 @@ pub fn encode_native_png(path: &Path, cell_width: u32, cell_height: u32) -> Opti
6565
.ok()?;
6666

6767
use base64::Engine;
68-
Some(base64::engine::general_purpose::STANDARD.encode(buf.into_inner()))
68+
Some((base64::engine::general_purpose::STANDARD.encode(buf.into_inner()), new_w, new_h))
6969
}
7070

7171
/// Render an image file as halfblock-character lines for display in a terminal.

src/main.rs

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crossterm::{
2020
event::{self, EnableBracketedPaste, DisableBracketedPaste, EnableMouseCapture, DisableMouseCapture, Event, KeyEventKind},
2121
execute, queue,
2222
style::{Print, ResetColor, SetForegroundColor},
23-
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
23+
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, BeginSynchronizedUpdate, EndSynchronizedUpdate},
2424
};
2525
use ratatui::{
2626
backend::CrosstermBackend,
@@ -457,25 +457,45 @@ fn emit_native_images(
457457
app: &mut App,
458458
) -> Result<()> {
459459
let protocol = app.image_protocol;
460-
if app.visible_images.is_empty() || protocol == image_render::ImageProtocol::Halfblock {
460+
if protocol == image_render::ImageProtocol::Halfblock {
461461
return Ok(());
462462
}
463+
464+
// Skip if visible images haven't changed since last frame
465+
if app.visible_images == app.prev_visible_images {
466+
return Ok(());
467+
}
468+
463469
use std::io::Write;
464470

471+
// Delete old Kitty placements before rendering new ones,
472+
// so images that scroll out of view are properly cleared.
473+
if protocol == image_render::ImageProtocol::Kitty {
474+
write!(backend, "\x1b_Ga=d,q=2\x1b\\")?;
475+
}
476+
477+
if app.visible_images.is_empty() {
478+
app.prev_visible_images.clear();
479+
if protocol == image_render::ImageProtocol::Kitty {
480+
backend.flush()?;
481+
}
482+
return Ok(());
483+
}
484+
465485
// Take images out to avoid borrow conflict with native_image_cache
466486
let images = std::mem::take(&mut app.visible_images);
467487

468488
queue!(backend, SavePosition)?;
469489

470490
for img in &images {
471-
// Get or compute cached base64 PNG data
472-
let b64 = if let Some(cached) = app.native_image_cache.get(&img.path) {
491+
// Get or compute cached base64 PNG data (always at full image dimensions)
492+
let (b64, _px_w, px_h) = if let Some(cached) = app.native_image_cache.get(&img.path) {
473493
cached.clone()
474494
} else {
475495
let encoded = image_render::encode_native_png(
476496
std::path::Path::new(&img.path),
477497
img.width as u32,
478-
img.height as u32,
498+
img.full_height as u32,
479499
);
480500
match encoded {
481501
Some(data) => {
@@ -492,14 +512,30 @@ fn emit_native_images(
492512
image_render::ImageProtocol::Kitty => {
493513
// f=100 = detect format, a=T = transmit+display
494514
// c/r = display size in cells, C=1 = don't move cursor
515+
// y/h = source crop in pixels for partially visible images
516+
let mut crop_params = String::new();
517+
if img.crop_top > 0 || img.height < img.full_height {
518+
let y_px = if img.full_height > 0 {
519+
img.crop_top as u32 * px_h / img.full_height as u32
520+
} else {
521+
0
522+
};
523+
let h_px = if img.full_height > 0 {
524+
(img.height as u32 * px_h / img.full_height as u32).max(1)
525+
} else {
526+
px_h
527+
};
528+
crop_params = format!(",y={y_px},h={h_px}");
529+
}
530+
495531
let chunks: Vec<&[u8]> = b64.as_bytes().chunks(4096).collect();
496532
for (i, chunk) in chunks.iter().enumerate() {
497533
let m = if i == chunks.len() - 1 { 0 } else { 1 };
498534
let chunk_str = std::str::from_utf8(chunk).unwrap_or("");
499535
if i == 0 {
500536
write!(
501537
backend,
502-
"\x1b_Gf=100,a=T,c={},r={},C=1,m={m};{chunk_str}\x1b\\",
538+
"\x1b_Gf=100,a=T,c={},r={},C=1,q=2{crop_params},m={m};{chunk_str}\x1b\\",
503539
img.width, img.height
504540
)?;
505541
} else {
@@ -520,6 +556,8 @@ fn emit_native_images(
520556

521557
queue!(backend, RestorePosition)?;
522558
backend.flush()?;
559+
560+
app.prev_visible_images = images;
523561
Ok(())
524562
}
525563

@@ -796,12 +834,15 @@ async fn run_app(
796834
loop {
797835
// Only redraw when state has changed (avoids resetting cursor blink timer every 50ms)
798836
if needs_redraw {
837+
// Wrap entire render (clear + text + image overlay) in synchronized
838+
// update so the terminal renders everything atomically.
839+
queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
840+
799841
// Force full redraw when active conversation changes (clears native image artifacts)
800842
if app.native_images && app.active_conversation != app.prev_active_conversation {
801843
app.prev_active_conversation = app.active_conversation.clone();
802844
terminal.clear()?;
803845
}
804-
805846
terminal.draw(|frame| ui::draw(frame, &mut app))?;
806847
let has_post_draw = !app.link_regions.is_empty() || app.native_images;
807848
if has_post_draw && app.mode == InputMode::Insert {
@@ -812,8 +853,9 @@ async fn run_app(
812853
emit_native_images(terminal.backend_mut(), &mut app)?;
813854
}
814855
if has_post_draw && app.mode == InputMode::Insert {
815-
execute!(terminal.backend_mut(), Show)?;
856+
queue!(terminal.backend_mut(), Show)?;
816857
}
858+
execute!(terminal.backend_mut(), EndSynchronizedUpdate)?;
817859
needs_redraw = false;
818860
}
819861

@@ -982,11 +1024,12 @@ async fn run_demo_app(
9821024

9831025
loop {
9841026
if needs_redraw {
1027+
queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?;
1028+
9851029
if app.native_images && app.active_conversation != app.prev_active_conversation {
9861030
app.prev_active_conversation = app.active_conversation.clone();
9871031
terminal.clear()?;
9881032
}
989-
9901033
terminal.draw(|frame| ui::draw(frame, &mut app))?;
9911034
let has_post_draw = !app.link_regions.is_empty() || app.native_images;
9921035
if has_post_draw && app.mode == InputMode::Insert {
@@ -997,8 +1040,9 @@ async fn run_demo_app(
9971040
emit_native_images(terminal.backend_mut(), &mut app)?;
9981041
}
9991042
if has_post_draw && app.mode == InputMode::Insert {
1000-
execute!(terminal.backend_mut(), Show)?;
1043+
queue!(terminal.backend_mut(), Show)?;
10011044
}
1045+
execute!(terminal.backend_mut(), EndSynchronizedUpdate)?;
10021046
needs_redraw = false;
10031047
}
10041048

src/ui.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,11 +1183,16 @@ fn draw_messages(frame: &mut Frame, app: &mut App, area: Rect) {
11831183
0
11841184
};
11851185

1186+
let full_height = (img_end - img_start) as u16;
1187+
let crop_top = (vis_start as i64 - screen_start) as u16;
1188+
11861189
app.visible_images.push(VisibleImage {
11871190
x: inner.x + 2, // account for 2-char indent
11881191
y: inner.y + vis_start,
11891192
width: img_width,
11901193
height: vis_end - vis_start,
1194+
full_height,
1195+
crop_top,
11911196
path: path.clone(),
11921197
});
11931198
}

0 commit comments

Comments
 (0)