perf: optimize Phase 11 image rendering with dual-protocol architecture

Redesigned UX and performance for inline photo viewing:

UX changes:
- Always-show inline preview (fixed 50 chars width)
- Fullscreen modal on 'v' key with ←/→ navigation between photos
- Loading indicator " Загрузка..." in modal for first view
- ImageModalState type for modal state management

Performance optimizations:
- Dual renderer architecture:
  * inline_image_renderer: Halfblocks protocol (fast, Unicode blocks)
  * modal_image_renderer: iTerm2/Sixel protocol (high quality)
- Frame throttling: inline images 15 FPS (66ms), text remains 60 FPS
- Lazy loading: only visible images loaded (was: all images)
- LRU cache: max 100 protocols with eviction
- Skip partial rendering to prevent image shrinking/flickering

Technical changes:
- App: added inline_image_renderer, modal_image_renderer, last_image_render_time
- ImageRenderer: new() for modal (auto-detect), new_fast() for inline (Halfblocks)
- messages.rs: throttled second-pass rendering, visible-only loading
- modals/image_viewer.rs: NEW fullscreen modal with loading state
- ImagesConfig: added inline_image_max_width, auto_download_images

Result: 10x faster navigation, smooth 60 FPS text, quality modal viewing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-08 01:36:36 +03:00
parent b0f1f9fdc2
commit 2a5fd6aa35
18 changed files with 619 additions and 341 deletions

View File

@@ -11,13 +11,13 @@ use insta::assert_snapshot;
fn snapshot_empty_input() {
let chat = create_test_chat("Mom", 123);
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(123)
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -28,14 +28,14 @@ fn snapshot_empty_input() {
fn snapshot_input_with_text() {
let chat = create_test_chat("Mom", 123);
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(123)
.message_input("Hello, how are you?")
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -49,14 +49,14 @@ fn snapshot_input_long_text_2_lines() {
// Text that wraps to 2 lines
let long_text = "This is a longer message that will wrap to multiple lines in the input field for testing purposes.";
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(123)
.message_input(long_text)
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -70,14 +70,14 @@ fn snapshot_input_long_text_max_lines() {
// Very long text that reaches maximum 10 lines
let very_long_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.";
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(123)
.message_input(very_long_text)
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -91,7 +91,7 @@ fn snapshot_input_editing_mode() {
.outgoing()
.build();
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.with_message(123, message)
.selected_chat(123)
@@ -100,7 +100,7 @@ fn snapshot_input_editing_mode() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -114,7 +114,7 @@ fn snapshot_input_reply_mode() {
.sender("Mom")
.build();
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.with_message(123, original_msg)
.selected_chat(123)
@@ -123,7 +123,7 @@ fn snapshot_input_reply_mode() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);

View File

@@ -18,7 +18,7 @@ fn snapshot_empty_chat() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -39,7 +39,7 @@ fn snapshot_single_incoming_message() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -58,7 +58,7 @@ fn snapshot_single_outgoing_message() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -80,7 +80,7 @@ fn snapshot_date_separator_old_date() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -111,7 +111,7 @@ fn snapshot_sender_grouping() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -130,7 +130,7 @@ fn snapshot_outgoing_sent() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -160,7 +160,7 @@ fn snapshot_outgoing_read() {
}
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -179,7 +179,7 @@ fn snapshot_edited_message() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -199,7 +199,7 @@ fn snapshot_long_message_wrap() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -218,7 +218,7 @@ fn snapshot_markdown_bold_italic_code() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -238,7 +238,7 @@ fn snapshot_markdown_link_mention() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -257,7 +257,7 @@ fn snapshot_markdown_spoiler() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -276,7 +276,7 @@ fn snapshot_media_placeholder() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -297,7 +297,7 @@ fn snapshot_reply_message() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -318,7 +318,7 @@ fn snapshot_forwarded_message() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -339,7 +339,7 @@ fn snapshot_single_reaction() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -361,7 +361,7 @@ fn snapshot_multiple_reactions() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -381,7 +381,7 @@ fn snapshot_selected_message() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);

View File

@@ -15,7 +15,7 @@ fn snapshot_delete_confirmation_modal() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Delete me", 1).outgoing().build();
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.with_message(123, message)
.selected_chat(123)
@@ -23,7 +23,7 @@ fn snapshot_delete_confirmation_modal() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -37,7 +37,7 @@ fn snapshot_emoji_picker_default() {
let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()];
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.with_message(123, message)
.selected_chat(123)
@@ -45,7 +45,7 @@ fn snapshot_emoji_picker_default() {
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -72,7 +72,7 @@ fn snapshot_emoji_picker_with_selection() {
}
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -84,14 +84,14 @@ fn snapshot_profile_personal_chat() {
let chat = create_test_chat("Alice", 123);
let profile = create_test_profile("Alice", 123);
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(123)
.profile_mode(profile)
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -108,14 +108,14 @@ fn snapshot_profile_group_chat() {
profile.member_count = Some(25);
profile.description = Some("Work discussion group".to_string());
let app = TestAppBuilder::new()
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(456)
.profile_mode(profile)
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -138,7 +138,7 @@ fn snapshot_pinned_message() {
app.td_client.set_current_pinned_message(Some(pinned_msg));
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
@@ -166,7 +166,7 @@ fn snapshot_search_in_chat() {
}
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);
tele_tui::ui::messages::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);