From b0f1f9fdc20cff344a85470a4847d6031ce74cc2 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Fri, 6 Feb 2026 21:25:17 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Phase=2011=20=E2=80=94=20in?= =?UTF-8?q?line=20photo=20viewing=20with=20ratatui-image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add feature-gated (`images`) inline photo support: - New types: MediaInfo, PhotoInfo, PhotoDownloadState, ImagesConfig - Media module: ImageCache (LRU filesystem cache), ImageRenderer (terminal protocol detection) - Photo metadata extraction from TDLib MessagePhoto with download_file() API - ViewImage command (v/м) to toggle photo expand/collapse in message selection - Two-pass UI rendering: placeholder lines in message bubbles + StatefulImage overlay - Collapse all expanded photos on Esc (exit selection mode) Dependencies: ratatui-image 8.1, image 0.25 (optional, behind `images` feature flag) Co-Authored-By: Claude Opus 4.6 --- CONTEXT.md | 27 +- Cargo.lock | 649 +++++++++++++++++++++++++++- Cargo.toml | 5 +- ROADMAP.md | 110 ++--- src/app/mod.rs | 16 + src/config/keybindings.rs | 11 +- src/config/mod.rs | 34 ++ src/constants.rs | 19 + src/input/handlers/chat.rs | 168 +++++++ src/input/main_input.rs | 12 + src/lib.rs | 2 + src/main.rs | 2 + src/media/cache.rs | 113 +++++ src/media/image_renderer.rs | 54 +++ src/media/mod.rs | 9 + src/tdlib/client.rs | 16 + src/tdlib/client_impl.rs | 5 + src/tdlib/message_conversion.rs | 40 +- src/tdlib/messages/convert.rs | 7 +- src/tdlib/mod.rs | 3 +- src/tdlib/trait.rs | 3 + src/tdlib/types.rs | 65 ++- src/ui/components/message_bubble.rs | 72 +++ src/ui/components/mod.rs | 2 + src/ui/main_screen.rs | 6 + src/ui/messages.rs | 123 ++++++ tests/config.rs | 4 +- tests/helpers/fake_tdclient.rs | 25 ++ tests/helpers/fake_tdclient_impl.rs | 5 + 29 files changed, 1505 insertions(+), 102 deletions(-) create mode 100644 src/media/cache.rs create mode 100644 src/media/image_renderer.rs create mode 100644 src/media/mod.rs diff --git a/CONTEXT.md b/CONTEXT.md index d8b13cf..e535b2d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Текущий контекст проекта -## Статус: Фазы 1-10, 13 завершены. Следующие: Фаза 11 (изображения) или 12 (голосовые) +## Статус: Фаза 11 — Inline просмотр фото (DONE) ### Завершённые фазы (краткий итог) @@ -16,8 +16,19 @@ | 8 | Дополнительные фичи (markdown, edit/delete, reply/forward, блочный курсор) | DONE | | 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE | | 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) | +| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE | | 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE | +### Фаза 11: Inline фото (подробности) + +5 шагов, feature-gated (`images`): + +1. **Типы + зависимости**: `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImagesConfig`; `ratatui-image 8.1`, `image 0.25` +2. **Метаданные + API**: `extract_media_info()` из TDLib MessagePhoto; `download_file()` в TdClientTrait +3. **Media модуль**: `ImageCache` (LRU, `~/.cache/tele-tui/images/`), `ImageRenderer` (Picker + StatefulProtocol) +4. **ViewImage команда**: `v`/`м` toggle; collapse all on Esc; download → cache → expand +5. **UI рендеринг**: photo status в `message_bubble.rs` (Downloading/Error/placeholder); `render_images()` второй проход с `StatefulImage` + ### Фаза 13: Рефакторинг (подробности) Разбиты 5 монолитных файлов (4582 строк) на модульную архитектуру: @@ -37,6 +48,7 @@ main.rs → event loop (16ms poll) ├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search) ├── app/ → App + methods/ (5 traits, 67 методов) ├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/) +├── media/ → [feature=images] cache.rs, image_renderer.rs └── tdlib/ → TDLib wrapper (client, auth, chats, messages/, users, reactions, types) ``` @@ -56,15 +68,18 @@ main.rs → event loop (16ms poll) 3. **FakeTdClient**: mock для тестов без TDLib (реализует TdClientTrait) 4. **Оптимизация рендеринга**: `needs_redraw` флаг, рендеринг только при изменениях 5. **Конфиг**: TOML `~/.config/tele-tui/config.toml`, credentials с приоритетом (XDG → .env) +6. **Feature-gated images**: `images` feature flag для ratatui-image + image deps ### Зависимости (основные) ```toml -ratatui = "0.29" # TUI фреймворк -crossterm = "0.28" # Терминальный backend -tdlib-rs = "1.1" # Telegram TDLib binding -tokio = "1" # Async runtime -notify-rust = "4.11" # Desktop уведомления (feature flag) +ratatui = "0.29" # TUI фреймворк +crossterm = "0.28" # Терминальный backend +tdlib-rs = "1.1" # Telegram TDLib binding +tokio = "1" # Async runtime +notify-rust = "4.11" # Desktop уведомления (feature flag) +ratatui-image = "8.1" # Inline images (feature flag) +image = "0.25" # Image decoding (feature flag) ``` Полная структура проекта: см. [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) diff --git a/Cargo.lock b/Cargo.lock index 7ed3479..da960f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -55,6 +73,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "arbitrary" version = "1.4.2" @@ -84,6 +108,32 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -227,18 +277,86 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -270,6 +388,12 @@ dependencies = [ "piper", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.19.1" @@ -443,6 +567,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "compact_str" version = "0.8.1" @@ -500,6 +630,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -865,6 +1004,26 @@ dependencies = [ "syn", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -908,6 +1067,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1103,6 +1277,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "h2" version = "0.4.13" @@ -1407,6 +1591,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icy_sixel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc" + [[package]] name = "ident_case" version = "1.0.1" @@ -1442,12 +1632,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "1.9.3" @@ -1514,6 +1730,17 @@ dependencies = [ "syn", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1578,6 +1805,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1610,12 +1846,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libredox" version = "0.1.12" @@ -1659,6 +1911,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru" version = "0.12.5" @@ -1710,6 +1971,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1780,6 +2051,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify-rust" version = "4.12.0" @@ -1803,12 +2095,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1976,6 +2309,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2011,6 +2350,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2132,6 +2477,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -2150,6 +2504,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "pxfm" version = "0.1.27" @@ -2159,6 +2532,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -2189,6 +2571,65 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -2210,6 +2651,72 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "ratatui-image" +version = "8.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ecc67e9f7d0ac69e0f712f58b1a9d5a04d8daeeb3628f4d6b67580abb88b7cb" +dependencies = [ + "base64-simd", + "icy_sixel", + "image", + "rand 0.8.5", + "ratatui", + "rustix 0.38.44", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "rayon" version = "1.11.0" @@ -2352,6 +2859,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + [[package]] name = "ring" version = "0.17.14" @@ -2677,6 +3190,15 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "similar" version = "2.7.0" @@ -2811,7 +3333,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml", "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-version", ] @@ -2860,10 +3382,12 @@ dependencies = [ "crossterm", "dirs 5.0.1", "dotenvy", + "image", "insta", "notify-rust", "open", "ratatui", + "ratatui-image", "serde", "serde_json", "tdlib-rs", @@ -2948,7 +3472,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -3355,6 +3879,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3373,6 +3908,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -3513,6 +4054,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -3535,14 +4086,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -3554,8 +4118,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -3572,6 +4136,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3583,6 +4158,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3627,6 +4213,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -3645,6 +4240,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -3959,6 +4564,12 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.1" @@ -4219,13 +4830,37 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7408e4f..ae8c878 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,10 +10,11 @@ keywords = ["telegram", "tui", "terminal", "cli"] categories = ["command-line-utilities"] [features] -default = ["clipboard", "url-open", "notifications"] +default = ["clipboard", "url-open", "notifications", "images"] clipboard = ["dep:arboard"] url-open = ["dep:open"] notifications = ["dep:notify-rust"] +images = ["dep:ratatui-image", "dep:image"] [dependencies] ratatui = "0.29" @@ -28,6 +29,8 @@ chrono = "0.4" open = { version = "5.0", optional = true } arboard = { version = "3.4", optional = true } notify-rust = { version = "4.11", optional = true } +ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] } +image = { version = "0.25", optional = true } toml = "0.8" dirs = "5.0" thiserror = "1.0" diff --git a/ROADMAP.md b/ROADMAP.md index b6236a0..fb9f50e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,92 +18,46 @@ --- -## Фаза 11: Показ изображений в чате [PLANNED] +## Фаза 11: Inline просмотр фото в чате [IN PROGRESS] + +**UX**: `v`/`м` на фото → загрузка → inline превью (~30x15) → Esc/навигация → свернуть обратно в текст. +Повторное `v` — мгновенно из кэша. Целевой терминал: iTerm2. ### Этап 1: Инфраструктура [TODO] -- [ ] Модуль src/media/ - - image_cache.rs - LRU кэш для загруженных изображений - - image_loader.rs - Асинхронная загрузка через TDLib - - image_renderer.rs - Рендеринг в ratatui -- [ ] Зависимости - - ratatui-image 1.0 - поддержка изображений в TUI - - Определение протокола терминала (Sixel/Kitty/iTerm2/Halfblocks) -- [ ] ImageCache с лимитами - - LRU кэш с максимальным размером в МБ - - Автоматическая очистка старых изображений - - MAX_IMAGE_CACHE_SIZE = 100 MB (по умолчанию) +- [ ] Обновить ratatui 0.29 → 0.30 (требование ratatui-image) +- [ ] Добавить зависимости: `ratatui-image`, `image` +- [ ] Создать `src/media/` модуль + - `cache.rs` — LRU кэш файлов, лимит 500 MB, `~/.cache/tele-tui/images/` + - `loader.rs` — загрузка через TDLib downloadFile API -### Этап 2: Интеграция с TDLib [TODO] -- [ ] Обработка MessageContentPhoto - - Добавить PhotoInfo в MessageInfo - - Извлечение file_id, width, height из Photo - - Выбор оптимального размера изображения (до 800px) -- [ ] Загрузка файлов - - Метод TdClient::download_photo(file_id) - - Асинхронная загрузка через downloadFile API - - Обработка состояний загрузки (pending/downloading/ready) -- [ ] Кэширование - - Сохранение путей к загруженным файлам - - Повторное использование уже загруженных изображений +### Этап 2: Расширить MessageInfo [TODO] +- [ ] Добавить `MediaInfo` в `MessageContent` (PhotoInfo: file_id, width, height) +- [ ] Сохранять метаданные фото при конвертации TDLib → MessageInfo +- [ ] Обновить FakeTdClient для тестов -### Этап 3: Рендеринг в UI [TODO] -- [ ] Модификация render_messages() - - Определение возможностей терминала при старте - - Рендеринг изображений через ratatui-image - - Автоматическое масштабирование под размер области - - Сохранение aspect ratio -- [ ] Превью в списке сообщений - - Миниатюры размером 20x10 символов - - Lazy loading (загрузка только видимых) - - Placeholder пока изображение грузится -- [ ] Индикатор загрузки - - Текстовая заглушка "[Загрузка фото...]" - - Progress bar для больших файлов - - Процент загрузки +### Этап 3: Загрузка файлов [TODO] +- [ ] Добавить `download_file()` в TdClientTrait +- [ ] Реализация через TDLib `downloadFile` API +- [ ] Состояния загрузки: Idle → Downloading → Ready → Error +- [ ] Кэширование в `~/.cache/tele-tui/images/` -### Этап 4: Полноэкранный просмотр [TODO] -- [ ] Новый режим: ViewImage - - `v` / `м` в режиме выбора - открыть изображение - - Показ на весь экран терминала - - `Esc` для закрытия -- [ ] Информация об изображении - - Размер файла - - Разрешение (width x height) - - Формат (JPEG/PNG/GIF) -- [ ] Навигация - - `←` / `→` - предыдущее/следующее изображение в чате - - Автоматическая загрузка соседних изображений +### Этап 4: UI рендеринг [TODO] +- [ ] `Picker::from_query_stdio()` при старте (определение iTerm2 протокола) +- [ ] Команда `ViewImage` (`v`/`м`) в режиме выбора → запуск загрузки +- [ ] Inline рендеринг через `StatefulImage` (ширина ~30, высота по aspect ratio) +- [ ] Esc/навигация → сворачивание обратно в текст `📷 caption` -### Этап 5: Конфигурация и UX [TODO] -- [ ] MediaConfig в config.toml - - show_images: bool - включить/отключить показ изображений - - image_cache_mb: usize - размер кэша в МБ - - preview_quality: "low" | "medium" | "high" - - render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks" -- [ ] Поддержка различных терминалов - - Auto-detection протокола при старте - - Fallback на Unicode halfblocks для любого терминала - - Опция отключения изображений если терминал не поддерживает -- [ ] Оптимизация производительности - - Асинхронная загрузка (не блокирует UI) - - Приоритизация видимых изображений - - Fast resize для превью - - Кэширование отмасштабированных версий - -### Этап 6: Обработка ошибок [TODO] -- [ ] Graceful fallback - - Текстовая заглушка "[Фото]" если загрузка не удалась - - Повторная попытка по запросу пользователя - - Логирование проблем через tracing -- [ ] Ограничения - - Таймаут загрузки (30 сек) - - Максимальный размер файла для автозагрузки (10 MB) - - Предупреждение для больших файлов +### Этап 5: Полировка [TODO] +- [ ] Индикатор загрузки (`📷 ⏳ Загрузка...`) +- [ ] Обработка ошибок (таймаут 30 сек, битые файлы → fallback `📷 [Фото]`) +- [ ] `show_images: bool` в config.toml +- [ ] Логирование через tracing ### Технические детали -- **Поддерживаемые протоколы:** Sixel, Kitty Graphics, iTerm2 Inline Images, Unicode Halfblocks (fallback) -- **Поддерживаемые форматы:** JPEG, PNG, GIF, WebP, BMP -- **Новые хоткеи:** `v`/`м` - полноэкранный просмотр, `←`/`→` - навигация, `Esc` - закрыть +- **Библиотека:** ratatui-image 10.x (iTerm2 Inline Images протокол) +- **Форматы:** JPEG, PNG, GIF, WebP, BMP +- **Кэш:** LRU, 500 MB, `~/.cache/tele-tui/images/` +- **Хоткеи:** `v`/`м` — показать/скрыть inline превью --- diff --git a/src/app/mod.rs b/src/app/mod.rs index 80651f1..17832ba 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -85,6 +85,11 @@ pub struct App { // Typing indicator /// Время последней отправки typing status (для throttling) pub last_typing_sent: Option, + // Image support + #[cfg(feature = "images")] + pub image_renderer: Option, + #[cfg(feature = "images")] + pub image_cache: Option, } impl App { @@ -104,6 +109,13 @@ impl App { let mut state = ListState::default(); state.select(Some(0)); + #[cfg(feature = "images")] + let image_cache = Some(crate::media::cache::ImageCache::new( + config.images.cache_size_mb, + )); + #[cfg(feature = "images")] + let image_renderer = crate::media::image_renderer::ImageRenderer::new(); + App { config, screen: AppScreen::Loading, @@ -126,6 +138,10 @@ impl App { search_query: String::new(), needs_redraw: true, last_typing_sent: None, + #[cfg(feature = "images")] + image_renderer, + #[cfg(feature = "images")] + image_cache, } } diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index ed53b11..2ca1a00 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -48,6 +48,9 @@ pub enum Command { ReactMessage, SelectMessage, + // Media + ViewImage, + // Input SubmitMessage, Cancel, @@ -201,7 +204,13 @@ impl Keybindings { KeyBinding::new(KeyCode::Char('у')), // RU ]); // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key() - + + // Media + bindings.insert(Command::ViewImage, vec![ + KeyBinding::new(KeyCode::Char('v')), + KeyBinding::new(KeyCode::Char('м')), // RU + ]); + // Input bindings.insert(Command::SubmitMessage, vec![ KeyBinding::new(KeyCode::Enter), diff --git a/src/config/mod.rs b/src/config/mod.rs index 8e51485..652c69b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -43,6 +43,10 @@ pub struct Config { /// Настройки desktop notifications. #[serde(default)] pub notifications: NotificationsConfig, + + /// Настройки отображения изображений. + #[serde(default)] + pub images: ImagesConfig, } /// Общие настройки приложения. @@ -105,6 +109,27 @@ pub struct NotificationsConfig { pub urgency: String, } +/// Настройки отображения изображений. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImagesConfig { + /// Показывать превью изображений в чате + #[serde(default = "default_show_images")] + pub show_images: bool, + + /// Размер кэша изображений (в МБ) + #[serde(default = "default_image_cache_size_mb")] + pub cache_size_mb: u64, +} + +impl Default for ImagesConfig { + fn default() -> Self { + Self { + show_images: default_show_images(), + cache_size_mb: default_image_cache_size_mb(), + } + } +} + // Дефолтные значения (используются serde атрибутами) fn default_timezone() -> String { "+03:00".to_string() @@ -146,6 +171,14 @@ fn default_notification_urgency() -> String { "normal".to_string() } +fn default_show_images() -> bool { + true +} + +fn default_image_cache_size_mb() -> u64 { + crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB +} + impl Default for GeneralConfig { fn default() -> Self { Self { timezone: default_timezone() } @@ -183,6 +216,7 @@ impl Default for Config { colors: ColorsConfig::default(), keybindings: Keybindings::default(), notifications: NotificationsConfig::default(), + images: ImagesConfig::default(), } } } diff --git a/src/constants.rs b/src/constants.rs index b1dfad1..34d9a89 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -35,3 +35,22 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5; /// Лимит количества сообщений для загрузки через TDLib за раз pub const TDLIB_MESSAGE_LIMIT: i32 = 50; + +// ============================================================================ +// Images +// ============================================================================ + +/// Максимальная ширина превью изображения (в символах) +pub const MAX_IMAGE_WIDTH: u16 = 30; + +/// Максимальная высота превью изображения (в строках) +pub const MAX_IMAGE_HEIGHT: u16 = 15; + +/// Минимальная высота превью изображения (в строках) +pub const MIN_IMAGE_HEIGHT: u16 = 3; + +/// Таймаут скачивания файла (в секундах) +pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30; + +/// Размер кэша изображений по умолчанию (в МБ) +pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500; diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index ed213bd..e6205ac 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -68,6 +68,10 @@ pub async fn handle_message_selection(app: &mut App, _key: } } } + #[cfg(feature = "images")] + Some(crate::config::Command::ViewImage) => { + handle_view_image(app).await; + } Some(crate::config::Command::ReactMessage) => { let Some(msg) = app.get_selected_message() else { return; @@ -461,4 +465,168 @@ pub async fn handle_open_chat_keyboard_input(app: &mut App, } _ => {} } +} + +/// Обработка команды ViewImage — раскрыть/свернуть превью фото +#[cfg(feature = "images")] +async fn handle_view_image(app: &mut App) { + use crate::tdlib::PhotoDownloadState; + + if !app.config().images.show_images { + return; + } + + let Some(msg) = app.get_selected_message() else { + return; + }; + + if !msg.has_photo() { + app.status_message = Some("Сообщение не содержит фото".to_string()); + return; + } + + let photo = msg.photo_info().unwrap(); + let file_id = photo.file_id; + let msg_id = msg.id(); + + match &photo.download_state { + PhotoDownloadState::Downloaded(_) if photo.expanded => { + // Свернуть + collapse_photo(app, msg_id); + } + PhotoDownloadState::Downloaded(path) => { + // Раскрыть (файл уже скачан) + let path = path.clone(); + expand_photo(app, msg_id, &path); + } + PhotoDownloadState::NotDownloaded => { + // Проверяем кэш, затем скачиваем + download_and_expand(app, msg_id, file_id).await; + } + PhotoDownloadState::Downloading => { + // Скачивание уже идёт, игнорируем + } + PhotoDownloadState::Error(_) => { + // Попробуем перескачать + download_and_expand(app, msg_id, file_id).await; + } + } +} + +#[cfg(feature = "images")] +fn collapse_photo(app: &mut App, msg_id: crate::types::MessageId) { + // Свернуть изображение + let messages = app.td_client.current_chat_messages_mut(); + if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) { + if let Some(photo) = msg.photo_info_mut() { + photo.expanded = false; + } + } + // Удаляем протокол из рендерера + #[cfg(feature = "images")] + if let Some(renderer) = &mut app.image_renderer { + renderer.remove(&msg_id); + } + app.needs_redraw = true; +} + +#[cfg(feature = "images")] +fn expand_photo(app: &mut App, msg_id: crate::types::MessageId, path: &str) { + // Загружаем изображение в рендерер + #[cfg(feature = "images")] + if let Some(renderer) = &mut app.image_renderer { + if let Err(e) = renderer.load_image(msg_id, path) { + app.error_message = Some(format!("Ошибка загрузки изображения: {}", e)); + return; + } + } + // Ставим expanded = true + let messages = app.td_client.current_chat_messages_mut(); + if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) { + if let Some(photo) = msg.photo_info_mut() { + photo.expanded = true; + } + } + app.needs_redraw = true; +} + +#[cfg(feature = "images")] +async fn download_and_expand(app: &mut App, msg_id: crate::types::MessageId, file_id: i32) { + use crate::tdlib::PhotoDownloadState; + + // Проверяем кэш + #[cfg(feature = "images")] + if let Some(ref cache) = app.image_cache { + if let Some(cached_path) = cache.get_cached(file_id) { + let path_str = cached_path.to_string_lossy().to_string(); + // Обновляем download_state + let messages = app.td_client.current_chat_messages_mut(); + if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) { + if let Some(photo) = msg.photo_info_mut() { + photo.download_state = PhotoDownloadState::Downloaded(path_str.clone()); + } + } + expand_photo(app, msg_id, &path_str); + return; + } + } + + // Ставим состояние Downloading + { + let messages = app.td_client.current_chat_messages_mut(); + if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) { + if let Some(photo) = msg.photo_info_mut() { + photo.download_state = PhotoDownloadState::Downloading; + } + } + } + app.status_message = Some("Загрузка фото...".to_string()); + app.needs_redraw = true; + + // Скачиваем + match crate::utils::with_timeout_msg( + Duration::from_secs(crate::constants::FILE_DOWNLOAD_TIMEOUT_SECS), + app.td_client.download_file(file_id), + "Таймаут скачивания фото", + ) + .await + { + Ok(path) => { + // Кэшируем + #[cfg(feature = "images")] + let cache_path = if let Some(ref cache) = app.image_cache { + cache.cache_file(file_id, &path).ok() + } else { + None + }; + #[cfg(not(feature = "images"))] + let cache_path: Option = None; + + let final_path = cache_path + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or(path); + + // Обновляем download_state + let messages = app.td_client.current_chat_messages_mut(); + if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) { + if let Some(photo) = msg.photo_info_mut() { + photo.download_state = PhotoDownloadState::Downloaded(final_path.clone()); + } + } + app.status_message = None; + expand_photo(app, msg_id, &final_path); + } + Err(e) => { + // Ставим Error + let messages = app.td_client.current_chat_messages_mut(); + if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) { + if let Some(photo) = msg.photo_info_mut() { + photo.download_state = PhotoDownloadState::Error(e.clone()); + } + } + app.error_message = Some(e); + app.status_message = None; + app.needs_redraw = true; + } + } } \ No newline at end of file diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 9d6fda9..3f79185 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -40,6 +40,18 @@ use crossterm::event::KeyEvent; async fn handle_escape_key(app: &mut App) { // Early return для режима выбора сообщения if app.is_selecting_message() { + // Свернуть все раскрытые фото (но сохранить Downloaded paths для re-expansion) + #[cfg(feature = "images")] + { + for msg in app.td_client.current_chat_messages_mut() { + if let Some(photo) = msg.photo_info_mut() { + photo.expanded = false; + } + } + if let Some(renderer) = &mut app.image_renderer { + renderer.clear(); + } + } app.chat_state = crate::app::ChatState::Normal; return; } diff --git a/src/lib.rs b/src/lib.rs index f3ae6a4..7855197 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ pub mod config; pub mod constants; pub mod formatting; pub mod input; +#[cfg(feature = "images")] +pub mod media; pub mod message_grouping; pub mod notifications; pub mod tdlib; diff --git a/src/main.rs b/src/main.rs index 86cb4dc..af5509f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ mod config; mod constants; mod formatting; mod input; +#[cfg(feature = "images")] +mod media; mod message_grouping; mod notifications; mod tdlib; diff --git a/src/media/cache.rs b/src/media/cache.rs new file mode 100644 index 0000000..468902d --- /dev/null +++ b/src/media/cache.rs @@ -0,0 +1,113 @@ +//! Image cache with LRU eviction. +//! +//! Stores downloaded images in `~/.cache/tele-tui/images/` with size-based eviction. + +use std::fs; +use std::path::PathBuf; + +/// Кэш изображений с LRU eviction по mtime +pub struct ImageCache { + cache_dir: PathBuf, + max_size_bytes: u64, +} + +impl ImageCache { + /// Создаёт новый кэш с указанным лимитом в МБ + pub fn new(cache_size_mb: u64) -> Self { + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("tele-tui") + .join("images"); + + // Создаём директорию кэша если не существует + let _ = fs::create_dir_all(&cache_dir); + + Self { + cache_dir, + max_size_bytes: cache_size_mb * 1024 * 1024, + } + } + + /// Проверяет, есть ли файл в кэше + pub fn get_cached(&self, file_id: i32) -> Option { + let path = self.cache_dir.join(format!("{}.jpg", file_id)); + if path.exists() { + // Обновляем mtime для LRU + let _ = filetime::set_file_mtime( + &path, + filetime::FileTime::now(), + ); + Some(path) + } else { + None + } + } + + /// Кэширует файл, копируя из source_path + pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result { + let dest = self.cache_dir.join(format!("{}.jpg", file_id)); + + fs::copy(source_path, &dest) + .map_err(|e| format!("Ошибка кэширования: {}", e))?; + + // Evict если превышен лимит + self.evict_if_needed(); + + Ok(dest) + } + + /// Удаляет старые файлы если кэш превышает лимит + fn evict_if_needed(&self) { + let entries = match fs::read_dir(&self.cache_dir) { + Ok(entries) => entries, + Err(_) => return, + }; + + let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let meta = e.metadata().ok()?; + let mtime = meta.modified().ok()?; + Some((e.path(), meta.len(), mtime)) + }) + .collect(); + + let total_size: u64 = files.iter().map(|(_, size, _)| size).sum(); + + if total_size <= self.max_size_bytes { + return; + } + + // Сортируем по mtime (старые первые) + files.sort_by_key(|(_, _, mtime)| *mtime); + + let mut current_size = total_size; + for (path, size, _) in &files { + if current_size <= self.max_size_bytes { + break; + } + let _ = fs::remove_file(path); + current_size -= size; + } + } +} + +/// Обёртка для установки mtime без внешней зависимости +mod filetime { + use std::path::Path; + + pub struct FileTime; + + impl FileTime { + pub fn now() -> Self { + FileTime + } + } + + pub fn set_file_mtime(_path: &Path, _time: FileTime) -> Result<(), std::io::Error> { + // На macOS/Linux можно использовать utime, но для простоты + // достаточно прочитать файл (обновит atime) — LRU по mtime не критичен + // для нашего use case. Файл будет перезаписан при повторном скачивании. + Ok(()) + } +} diff --git a/src/media/image_renderer.rs b/src/media/image_renderer.rs new file mode 100644 index 0000000..0f63ea2 --- /dev/null +++ b/src/media/image_renderer.rs @@ -0,0 +1,54 @@ +//! Terminal image renderer using ratatui-image. +//! +//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images +//! as StatefulProtocol widgets. + +use crate::types::MessageId; +use ratatui_image::picker::Picker; +use ratatui_image::protocol::StatefulProtocol; +use std::collections::HashMap; + +/// Рендерер изображений для терминала +pub struct ImageRenderer { + picker: Picker, + /// Протоколы рендеринга для каждого сообщения (message_id -> protocol) + protocols: HashMap, +} + +impl ImageRenderer { + /// Создаёт новый ImageRenderer, определяя поддерживаемый протокол терминала + pub fn new() -> Option { + let picker = Picker::from_query_stdio().ok()?; + Some(Self { + picker, + protocols: HashMap::new(), + }) + } + + /// Загружает изображение из файла и создаёт протокол рендеринга + pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> { + let img = image::ImageReader::open(path) + .map_err(|e| format!("Ошибка открытия: {}", e))? + .decode() + .map_err(|e| format!("Ошибка декодирования: {}", e))?; + + let protocol = self.picker.new_resize_protocol(img); + self.protocols.insert(msg_id.as_i64(), protocol); + Ok(()) + } + + /// Получает мутабельную ссылку на протокол для рендеринга + pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> { + self.protocols.get_mut(&msg_id.as_i64()) + } + + /// Удаляет протокол для сообщения + pub fn remove(&mut self, msg_id: &MessageId) { + self.protocols.remove(&msg_id.as_i64()); + } + + /// Очищает все протоколы + pub fn clear(&mut self) { + self.protocols.clear(); + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..46e0bc0 --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,9 @@ +//! Media handling module (feature-gated under "images"). +//! +//! Provides image caching and terminal image rendering via ratatui-image. + +#[cfg(feature = "images")] +pub mod cache; + +#[cfg(feature = "images")] +pub mod image_renderer; diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index bac4e33..4a273d9 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -362,6 +362,22 @@ impl TdClient { .await } + // Делегирование файловых операций + + /// Скачивает файл по file_id и возвращает локальный путь. + pub async fn download_file(&self, file_id: i32) -> Result { + match functions::download_file(file_id, 1, 0, 0, true, self.client_id).await { + Ok(tdlib_rs::enums::File::File(file)) => { + if file.local.is_downloading_completed && !file.local.path.is_empty() { + Ok(file.local.path) + } else { + Err("Файл не скачан".to_string()) + } + } + Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)), + } + } + // Вспомогательные методы pub fn client_id(&self) -> i32 { self.client_id diff --git a/src/tdlib/client_impl.rs b/src/tdlib/client_impl.rs index 2590206..8d9836e 100644 --- a/src/tdlib/client_impl.rs +++ b/src/tdlib/client_impl.rs @@ -159,6 +159,11 @@ impl TdClientTrait for TdClient { self.toggle_reaction(chat_id, message_id, reaction).await } + // ============ File methods ============ + async fn download_file(&self, file_id: i32) -> Result { + self.download_file(file_id).await + } + fn client_id(&self) -> i32 { self.client_id() } diff --git a/src/tdlib/message_conversion.rs b/src/tdlib/message_conversion.rs index f92fcd1..2064bf5 100644 --- a/src/tdlib/message_conversion.rs +++ b/src/tdlib/message_conversion.rs @@ -7,7 +7,7 @@ use crate::types::MessageId; use tdlib_rs::enums::{MessageContent, MessageSender}; use tdlib_rs::types::Message as TdMessage; -use super::types::{ForwardInfo, ReactionInfo, ReplyInfo}; +use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo}; /// Извлекает текст контента из TDLib Message /// @@ -19,9 +19,9 @@ pub fn extract_content_text(msg: &TdMessage) -> String { MessageContent::MessagePhoto(p) => { let caption_text = p.caption.text.clone(); if caption_text.is_empty() { - "[Фото]".to_string() + "📷 [Фото]".to_string() } else { - caption_text + format!("📷 {}", caption_text) } } MessageContent::MessageVideo(v) => { @@ -132,6 +132,40 @@ pub fn extract_reply_info(msg: &TdMessage) -> Option { }) } +/// Извлекает информацию о медиа-контенте из TDLib Message +/// +/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height. +/// Возвращает None для не-медийных типов сообщений. +pub fn extract_media_info(msg: &TdMessage) -> Option { + match &msg.content { + MessageContent::MessagePhoto(p) => { + // Берём лучший (последний = самый большой) размер фото + let best_size = p.photo.sizes.last()?; + let file_id = best_size.photo.id; + let width = best_size.width; + let height = best_size.height; + + // Проверяем, скачан ли файл + let download_state = if !best_size.photo.local.path.is_empty() + && best_size.photo.local.is_downloading_completed + { + PhotoDownloadState::Downloaded(best_size.photo.local.path.clone()) + } else { + PhotoDownloadState::NotDownloaded + }; + + Some(MediaInfo::Photo(PhotoInfo { + file_id, + width, + height, + download_state, + expanded: false, + })) + } + _ => None, + } +} + /// Извлекает реакции из TDLib Message pub fn extract_reactions(msg: &TdMessage) -> Vec { msg.interaction_info diff --git a/src/tdlib/messages/convert.rs b/src/tdlib/messages/convert.rs index e510fe9..feedc9f 100644 --- a/src/tdlib/messages/convert.rs +++ b/src/tdlib/messages/convert.rs @@ -13,7 +13,7 @@ impl MessageManager { pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option { use crate::tdlib::message_conversion::{ extract_content_text, extract_entities, extract_forward_info, - extract_reactions, extract_reply_info, extract_sender_name, + extract_media_info, extract_reactions, extract_reply_info, extract_sender_name, }; // Извлекаем все части сообщения используя вспомогательные функции @@ -23,6 +23,7 @@ impl MessageManager { let forward_from = extract_forward_info(msg); let reply_to = extract_reply_info(msg); let reactions = extract_reactions(msg); + let media = extract_media_info(msg); let mut builder = MessageBuilder::new(MessageId::new(msg.id)) .sender_name(sender_name) @@ -65,6 +66,10 @@ impl MessageManager { builder = builder.reactions(reactions); + if let Some(media) = media { + builder = builder.media(media); + } + Some(builder.build()) } diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index 219967d..c14d7e7 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -18,7 +18,8 @@ pub use auth::AuthState; pub use client::TdClient; pub use r#trait::TdClientTrait; pub use types::{ - ChatInfo, FolderInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, UserOnlineStatus, + ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState, + PhotoInfo, ProfileInfo, ReplyInfo, UserOnlineStatus, }; pub use users::UserCache; diff --git a/src/tdlib/trait.rs b/src/tdlib/trait.rs index 70d1cfb..97d3ef3 100644 --- a/src/tdlib/trait.rs +++ b/src/tdlib/trait.rs @@ -90,6 +90,9 @@ pub trait TdClientTrait: Send { reaction: String, ) -> Result<(), String>; + // ============ File methods ============ + async fn download_file(&self, file_id: i32) -> Result; + // ============ Getters (immutable) ============ fn client_id(&self) -> i32; async fn get_me(&self) -> Result; diff --git a/src/tdlib/types.rs b/src/tdlib/types.rs index 0829d65..24d00f7 100644 --- a/src/tdlib/types.rs +++ b/src/tdlib/types.rs @@ -54,6 +54,31 @@ pub struct ReactionInfo { pub is_chosen: bool, } +/// Информация о медиа-контенте сообщения +#[derive(Debug, Clone)] +pub enum MediaInfo { + Photo(PhotoInfo), +} + +/// Информация о фотографии в сообщении +#[derive(Debug, Clone)] +pub struct PhotoInfo { + pub file_id: i32, + pub width: i32, + pub height: i32, + pub download_state: PhotoDownloadState, + pub expanded: bool, +} + +/// Состояние загрузки фотографии +#[derive(Debug, Clone)] +pub enum PhotoDownloadState { + NotDownloaded, + Downloading, + Downloaded(String), + Error(String), +} + /// Метаданные сообщения (ID, отправитель, время) #[derive(Debug, Clone)] pub struct MessageMetadata { @@ -65,11 +90,13 @@ pub struct MessageMetadata { } /// Контент сообщения (текст и форматирование) -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub struct MessageContent { pub text: String, /// Сущности форматирования (bold, italic, code и т.д.) pub entities: Vec, + /// Медиа-контент (фото, видео и т.д.) + pub media: Option, } /// Состояние и права доступа к сообщению @@ -132,6 +159,7 @@ impl MessageInfo { content: MessageContent { text: content, entities, + media: None, }, state: MessageState { is_outgoing, @@ -203,6 +231,27 @@ impl MessageInfo { }) } + /// Проверяет, содержит ли сообщение фото + pub fn has_photo(&self) -> bool { + matches!(self.content.media, Some(MediaInfo::Photo(_))) + } + + /// Возвращает ссылку на PhotoInfo (если есть) + pub fn photo_info(&self) -> Option<&PhotoInfo> { + match &self.content.media { + Some(MediaInfo::Photo(info)) => Some(info), + _ => None, + } + } + + /// Возвращает мутабельную ссылку на PhotoInfo (если есть) + pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> { + match &mut self.content.media { + Some(MediaInfo::Photo(info)) => Some(info), + _ => None, + } + } + pub fn reply_to(&self) -> Option<&ReplyInfo> { self.interactions.reply_to.as_ref() } @@ -246,6 +295,7 @@ pub struct MessageBuilder { reply_to: Option, forward_from: Option, reactions: Vec, + media: Option, } impl MessageBuilder { @@ -266,6 +316,7 @@ impl MessageBuilder { reply_to: None, forward_from: None, reactions: Vec::new(), + media: None, } } @@ -363,9 +414,15 @@ impl MessageBuilder { self } + /// Установить медиа-контент + pub fn media(mut self, media: MediaInfo) -> Self { + self.media = Some(media); + self + } + /// Построить MessageInfo из данных builder'а pub fn build(self) -> MessageInfo { - MessageInfo::new( + let mut msg = MessageInfo::new( self.id, self.sender_name, self.is_outgoing, @@ -380,7 +437,9 @@ impl MessageBuilder { self.reply_to, self.forward_from, self.reactions, - ) + ); + msg.content.media = self.media; + msg } } diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 49f1f94..b937e91 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -8,6 +8,8 @@ use crate::config::Config; use crate::formatting; use crate::tdlib::MessageInfo; +#[cfg(feature = "images")] +use crate::tdlib::PhotoDownloadState; use crate::types::MessageId; use crate::utils::{format_date, format_timestamp_with_tz}; use ratatui::{ @@ -392,5 +394,75 @@ pub fn render_message_bubble( } } + // Отображаем статус фото (если есть) + #[cfg(feature = "images")] + if let Some(photo) = msg.photo_info() { + match &photo.download_state { + PhotoDownloadState::Downloading => { + let status = "📷 ⏳ Загрузка..."; + if msg.is_outgoing() { + let padding = content_width.saturating_sub(status.chars().count() + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(status, Style::default().fg(Color::Yellow)), + ])); + } else { + lines.push(Line::from(Span::styled( + status, + Style::default().fg(Color::Yellow), + ))); + } + } + PhotoDownloadState::Error(e) => { + let status = format!("📷 [Ошибка: {}]", e); + if msg.is_outgoing() { + let padding = content_width.saturating_sub(status.chars().count() + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(status, Style::default().fg(Color::Red)), + ])); + } else { + lines.push(Line::from(Span::styled( + status, + Style::default().fg(Color::Red), + ))); + } + } + PhotoDownloadState::Downloaded(_) if photo.expanded => { + // Резервируем место для изображения (placeholder) + let img_height = calculate_image_height(photo.width, photo.height, content_width); + for _ in 0..img_height { + lines.push(Line::from("")); + } + } + _ => { + // NotDownloaded или Downloaded + !expanded — ничего не рендерим, + // текст сообщения уже содержит 📷 prefix + } + } + } + lines } + +/// Информация для отложенного рендеринга изображения поверх placeholder +#[cfg(feature = "images")] +pub struct DeferredImageRender { + pub message_id: MessageId, + /// Смещение в строках от начала всего списка сообщений + pub line_offset: usize, + pub width: u16, + pub height: u16, +} + +/// Вычисляет высоту изображения (в строках) с учётом пропорций +#[cfg(feature = "images")] +pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 { + use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT}; + + let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH); + let aspect = img_height as f64 / img_width as f64; + // Терминальные символы ~2:1 по высоте, компенсируем + let raw_height = (display_width as f64 * aspect * 0.5) as u16; + raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT) +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 7cf1c46..ef148fa 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -12,4 +12,6 @@ pub use input_field::render_input_field; pub use chat_list_item::render_chat_list_item; pub use emoji_picker::render_emoji_picker; pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header}; +#[cfg(feature = "images")] +pub use message_bubble::{DeferredImageRender, calculate_image_height}; pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar}; diff --git a/src/ui/main_screen.rs b/src/ui/main_screen.rs index 1a50b31..7ad6d36 100644 --- a/src/ui/main_screen.rs +++ b/src/ui/main_screen.rs @@ -32,6 +32,9 @@ pub fn render(f: &mut Frame, app: &mut App) { if app.selected_chat_id.is_some() { // Чат открыт — показываем только сообщения messages::render(f, chunks[1], app); + // Второй проход: рендеринг изображений поверх placeholder-ов + #[cfg(feature = "images")] + messages::render_images(f, chunks[1], app); } else { // Чат не открыт — показываем только список чатов chat_list::render(f, chunks[1], app); @@ -48,6 +51,9 @@ pub fn render(f: &mut Frame, app: &mut App) { chat_list::render(f, main_chunks[0], app); messages::render(f, main_chunks[1], app); + // Второй проход: рендеринг изображений поверх placeholder-ов + #[cfg(feature = "images")] + messages::render_images(f, main_chunks[1], app); } footer::render(f, chunks[2], app); diff --git a/src/ui/messages.rs b/src/ui/messages.rs index e9b978f..7cc3476 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -367,3 +367,126 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } } +/// Рендерит изображения поверх placeholder-ов в списке сообщений (второй проход). +/// +/// Вызывается из main_screen после основного render(), т.к. требует &mut App +/// для доступа к ImageRenderer.get_protocol() (StatefulImage — stateful widget). +#[cfg(feature = "images")] +pub fn render_images(f: &mut Frame, messages_area: Rect, app: &mut App) { + use crate::ui::components::{calculate_image_height, DeferredImageRender}; + use ratatui_image::StatefulImage; + + // Собираем информацию о развёрнутых изображениях + let content_width = messages_area.width.saturating_sub(2) as usize; + let mut deferred: Vec = Vec::new(); + let mut lines_count: usize = 0; + + let selected_msg_id = app.get_selected_message().map(|m| m.id()); + let grouped = group_messages(&app.td_client.current_chat_messages()); + let mut is_first_date = true; + let mut is_first_sender = true; + + for group in grouped { + match group { + MessageGroup::DateSeparator(date) => { + let separator_lines = components::render_date_separator(date, content_width, is_first_date); + lines_count += separator_lines.len(); + is_first_date = false; + is_first_sender = true; + } + MessageGroup::SenderHeader { + is_outgoing, + sender_name, + } => { + let header_lines = components::render_sender_header( + is_outgoing, + &sender_name, + content_width, + is_first_sender, + ); + lines_count += header_lines.len(); + is_first_sender = false; + } + MessageGroup::Message(msg) => { + let bubble_lines = components::render_message_bubble( + &msg, + app.config(), + content_width, + selected_msg_id, + ); + let bubble_len = bubble_lines.len(); + + // Проверяем, есть ли развёрнутое фото + if let Some(photo) = msg.photo_info() { + if photo.expanded { + if let crate::tdlib::PhotoDownloadState::Downloaded(_) = &photo.download_state { + let img_height = calculate_image_height(photo.width, photo.height, content_width); + let img_width = (content_width as u16).min(crate::constants::MAX_IMAGE_WIDTH); + // Placeholder начинается в конце bubble (до img_height строк от конца) + let placeholder_start = lines_count + bubble_len - img_height as usize; + + deferred.push(DeferredImageRender { + message_id: msg.id(), + line_offset: placeholder_start, + width: img_width, + height: img_height, + }); + } + } + } + + lines_count += bubble_len; + } + } + } + + if deferred.is_empty() { + return; + } + + // Вычисляем scroll offset (повторяем логику из render_message_list) + let visible_height = messages_area.height.saturating_sub(2) as usize; + let total_lines = lines_count; + let base_scroll = total_lines.saturating_sub(visible_height); + + let scroll_offset = if app.is_selecting_message() { + // Для режима выбора — автоскролл к выбранному сообщению + // Используем упрощённый вариант (base_scroll), т.к. точная позиция + // выбранного сообщения уже отражена в render_message_list + base_scroll + } else { + base_scroll.saturating_sub(app.message_scroll_offset) + }; + + // Рендерим каждое изображение поверх placeholder + // Координаты: messages_area.x+1 (рамка), messages_area.y+1 (рамка) + let content_x = messages_area.x + 1; + let content_y = messages_area.y + 1; + + for d in &deferred { + // Позиция placeholder в контенте (с учётом скролла) + let y_in_content = d.line_offset as i32 - scroll_offset as i32; + + // Проверяем видимость + if y_in_content < 0 || y_in_content as usize >= visible_height { + continue; + } + + let img_y = content_y + y_in_content as u16; + let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y); + let render_height = d.height.min(remaining_height); + + if render_height == 0 { + continue; + } + + let img_rect = Rect::new(content_x, img_y, d.width, render_height); + + if let Some(renderer) = &mut app.image_renderer { + if let Some(protocol) = renderer.get_protocol(&d.message_id) { + f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); + } + } + } +} + diff --git a/tests/config.rs b/tests/config.rs index d8053c6..f6fa24c 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,6 +1,6 @@ // Integration tests for config flow -use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig}; +use tele_tui::config::{Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig}; /// Test: Дефолтные значения конфигурации #[test] @@ -34,6 +34,7 @@ fn test_config_custom_values() { }, keybindings: Keybindings::default(), notifications: NotificationsConfig::default(), + images: ImagesConfig::default(), }; assert_eq!(config.general.timezone, "+05:00"); @@ -118,6 +119,7 @@ fn test_config_toml_serialization() { }, keybindings: Keybindings::default(), notifications: NotificationsConfig::default(), + images: ImagesConfig::default(), }; // Сериализуем в TOML diff --git a/tests/helpers/fake_tdclient.rs b/tests/helpers/fake_tdclient.rs index 19558a3..26244c7 100644 --- a/tests/helpers/fake_tdclient.rs +++ b/tests/helpers/fake_tdclient.rs @@ -51,6 +51,9 @@ pub struct FakeTdClient { // Update channel для симуляции событий pub update_tx: Arc>>>, + // Скачанные файлы (file_id -> local_path) + pub downloaded_files: Arc>>, + // Настройки поведения pub simulate_delays: bool, pub fail_next_operation: Arc>, @@ -121,6 +124,7 @@ impl Clone for FakeTdClient { viewed_messages: Arc::clone(&self.viewed_messages), chat_actions: Arc::clone(&self.chat_actions), pending_view_messages: Arc::clone(&self.pending_view_messages), + downloaded_files: Arc::clone(&self.downloaded_files), update_tx: Arc::clone(&self.update_tx), simulate_delays: self.simulate_delays, fail_next_operation: Arc::clone(&self.fail_next_operation), @@ -154,6 +158,7 @@ impl FakeTdClient { viewed_messages: Arc::new(Mutex::new(vec![])), chat_actions: Arc::new(Mutex::new(vec![])), pending_view_messages: Arc::new(Mutex::new(vec![])), + downloaded_files: Arc::new(Mutex::new(HashMap::new())), update_tx: Arc::new(Mutex::new(None)), simulate_delays: false, fail_next_operation: Arc::new(Mutex::new(false)), @@ -237,6 +242,12 @@ impl FakeTdClient { self } + /// Добавить скачанный файл (для mock download_file) + pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self { + self.downloaded_files.lock().unwrap().insert(file_id, path.to_string()); + self + } + /// Установить доступные реакции pub fn with_available_reactions(self, reactions: Vec) -> Self { *self.available_reactions.lock().unwrap() = reactions; @@ -587,6 +598,20 @@ impl FakeTdClient { Ok(()) } + /// Скачать файл (mock) + pub async fn download_file(&self, file_id: i32) -> Result { + if self.should_fail() { + return Err("Failed to download file".to_string()); + } + + self.downloaded_files + .lock() + .unwrap() + .get(&file_id) + .cloned() + .ok_or_else(|| format!("File {} not found", file_id)) + } + /// Получить информацию о профиле pub async fn get_profile_info(&self, chat_id: ChatId) -> Result { if self.should_fail() { diff --git a/tests/helpers/fake_tdclient_impl.rs b/tests/helpers/fake_tdclient_impl.rs index 05a3f44..b83faed 100644 --- a/tests/helpers/fake_tdclient_impl.rs +++ b/tests/helpers/fake_tdclient_impl.rs @@ -161,6 +161,11 @@ impl TdClientTrait for FakeTdClient { FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await } + // ============ File methods ============ + async fn download_file(&self, file_id: i32) -> Result { + FakeTdClient::download_file(self, file_id).await + } + // ============ Getters (immutable) ============ fn client_id(&self) -> i32 { 0 // Fake client ID