@@ -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} ;
2525use 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
0 commit comments