feat: implement Phase 11 — inline photo viewing with ratatui-image

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 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-06 21:25:17 +03:00
parent 6845ee69bf
commit b0f1f9fdc2
29 changed files with 1505 additions and 102 deletions

View File

@@ -1,6 +1,6 @@
# Текущий контекст проекта # Текущий контекст проекта
## Статус: Фазы 1-10, 13 завершены. Следующие: Фаза 11 (изображения) или 12 (голосовые) ## Статус: Фаза 11 — Inline просмотр фото (DONE)
### Завершённые фазы (краткий итог) ### Завершённые фазы (краткий итог)
@@ -16,8 +16,19 @@
| 8 | Дополнительные фичи (markdown, edit/delete, reply/forward, блочный курсор) | DONE | | 8 | Дополнительные фичи (markdown, edit/delete, reply/forward, блочный курсор) | DONE |
| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE | | 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE |
| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) | | 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) |
| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE |
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | 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: Рефакторинг (подробности) ### Фаза 13: Рефакторинг (подробности)
Разбиты 5 монолитных файлов (4582 строк) на модульную архитектуру: Разбиты 5 монолитных файлов (4582 строк) на модульную архитектуру:
@@ -37,6 +48,7 @@ main.rs → event loop (16ms poll)
├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search) ├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search)
├── app/ → App<T: TdClientTrait> + methods/ (5 traits, 67 методов) ├── app/ → App<T: TdClientTrait> + methods/ (5 traits, 67 методов)
├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/) ├── 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) └── tdlib/ → TDLib wrapper (client, auth, chats, messages/, users, reactions, types)
``` ```
@@ -56,6 +68,7 @@ main.rs → event loop (16ms poll)
3. **FakeTdClient**: mock для тестов без TDLib (реализует TdClientTrait) 3. **FakeTdClient**: mock для тестов без TDLib (реализует TdClientTrait)
4. **Оптимизация рендеринга**: `needs_redraw` флаг, рендеринг только при изменениях 4. **Оптимизация рендеринга**: `needs_redraw` флаг, рендеринг только при изменениях
5. **Конфиг**: TOML `~/.config/tele-tui/config.toml`, credentials с приоритетом (XDG → .env) 5. **Конфиг**: TOML `~/.config/tele-tui/config.toml`, credentials с приоритетом (XDG → .env)
6. **Feature-gated images**: `images` feature flag для ratatui-image + image deps
### Зависимости (основные) ### Зависимости (основные)
@@ -65,6 +78,8 @@ crossterm = "0.28" # Терминальный backend
tdlib-rs = "1.1" # Telegram TDLib binding tdlib-rs = "1.1" # Telegram TDLib binding
tokio = "1" # Async runtime tokio = "1" # Async runtime
notify-rust = "4.11" # Desktop уведомления (feature flag) 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) Полная структура проекта: см. [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md)

649
Cargo.lock generated
View File

@@ -28,6 +28,24 @@ dependencies = [
"memchr", "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]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -55,6 +73,12 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anyhow"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
version = "1.4.2" version = "1.4.2"
@@ -84,6 +108,32 @@ dependencies = [
"x11rb", "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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -227,18 +277,86 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.10.0" version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -270,6 +388,12 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "built"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.19.1"
@@ -443,6 +567,12 @@ dependencies = [
"error-code", "error-code",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "compact_str" name = "compact_str"
version = "0.8.1" version = "0.8.1"
@@ -500,6 +630,15 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -865,6 +1004,26 @@ dependencies = [
"syn", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -908,6 +1067,21 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -1103,6 +1277,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -1407,6 +1591,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "icy_sixel"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@@ -1442,12 +1632,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png", "png",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -1514,6 +1730,17 @@ dependencies = [
"syn", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -1578,6 +1805,15 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -1610,12 +1846,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.180" version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" 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]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.12" version = "0.1.12"
@@ -1659,6 +1911,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.12.5" version = "0.12.5"
@@ -1710,6 +1971,16 @@ dependencies = [
"regex-automata", "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]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.6" version = "2.7.6"
@@ -1780,6 +2051,27 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.12.0" version = "4.12.0"
@@ -1803,12 +2095,53 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1976,6 +2309,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@@ -2011,6 +2350,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pathdiff" name = "pathdiff"
version = "0.2.3" version = "0.2.3"
@@ -2132,6 +2477,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 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]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.4.0" version = "3.4.0"
@@ -2150,6 +2504,25 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.27" version = "0.1.27"
@@ -2159,6 +2532,15 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "2.0.1" version = "2.0.1"
@@ -2189,6 +2571,65 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 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]] [[package]]
name = "ratatui" name = "ratatui"
version = "0.29.0" version = "0.29.0"
@@ -2210,6 +2651,72 @@ dependencies = [
"unicode-width 0.2.0", "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]] [[package]]
name = "rayon" name = "rayon"
version = "1.11.0" version = "1.11.0"
@@ -2352,6 +2859,12 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "rgb"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2677,6 +3190,15 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 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]] [[package]]
name = "similar" name = "similar"
version = "2.7.0" version = "2.7.0"
@@ -2811,7 +3333,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [ dependencies = [
"quick-xml", "quick-xml",
"thiserror 2.0.18", "thiserror 2.0.18",
"windows", "windows 0.61.3",
"windows-version", "windows-version",
] ]
@@ -2860,10 +3382,12 @@ dependencies = [
"crossterm", "crossterm",
"dirs 5.0.1", "dirs 5.0.1",
"dotenvy", "dotenvy",
"image",
"insta", "insta",
"notify-rust", "notify-rust",
"open", "open",
"ratatui", "ratatui",
"ratatui-image",
"serde", "serde",
"serde_json", "serde_json",
"tdlib-rs", "tdlib-rs",
@@ -2948,7 +3472,7 @@ dependencies = [
"half", "half",
"quick-error", "quick-error",
"weezl", "weezl",
"zune-jpeg", "zune-jpeg 0.4.21",
] ]
[[package]] [[package]]
@@ -3355,6 +3879,17 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -3373,6 +3908,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -3513,6 +4054,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows" name = "windows"
version = "0.61.3" version = "0.61.3"
@@ -3535,14 +4086,27 @@ dependencies = [
"windows-core 0.61.2", "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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.61.2" version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings 0.4.2", "windows-strings 0.4.2",
@@ -3554,8 +4118,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement 0.60.2",
"windows-interface", "windows-interface 0.59.3",
"windows-link 0.2.1", "windows-link 0.2.1",
"windows-result 0.4.1", "windows-result 0.4.1",
"windows-strings 0.5.1", "windows-strings 0.5.1",
@@ -3572,6 +4136,17 @@ dependencies = [
"windows-threading", "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]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@@ -3583,6 +4158,17 @@ dependencies = [
"syn", "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]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
@@ -3627,6 +4213,15 @@ dependencies = [
"windows-strings 0.5.1", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -3645,6 +4240,16 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.4.2" version = "0.4.2"
@@ -3959,6 +4564,12 @@ dependencies = [
"lzma-sys", "lzma-sys",
] ]
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"
@@ -4219,13 +4830,37 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" 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]] [[package]]
name = "zune-jpeg" name = "zune-jpeg"
version = "0.4.21" version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [ 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]] [[package]]

View File

@@ -10,10 +10,11 @@ keywords = ["telegram", "tui", "terminal", "cli"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
[features] [features]
default = ["clipboard", "url-open", "notifications"] default = ["clipboard", "url-open", "notifications", "images"]
clipboard = ["dep:arboard"] clipboard = ["dep:arboard"]
url-open = ["dep:open"] url-open = ["dep:open"]
notifications = ["dep:notify-rust"] notifications = ["dep:notify-rust"]
images = ["dep:ratatui-image", "dep:image"]
[dependencies] [dependencies]
ratatui = "0.29" ratatui = "0.29"
@@ -28,6 +29,8 @@ chrono = "0.4"
open = { version = "5.0", optional = true } open = { version = "5.0", optional = true }
arboard = { version = "3.4", optional = true } arboard = { version = "3.4", optional = true }
notify-rust = { version = "4.11", 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" toml = "0.8"
dirs = "5.0" dirs = "5.0"
thiserror = "1.0" thiserror = "1.0"

View File

@@ -18,92 +18,46 @@
--- ---
## Фаза 11: Показ изображений в чате [PLANNED] ## Фаза 11: Inline просмотр фото в чате [IN PROGRESS]
**UX**: `v`/`м` на фото → загрузка → inline превью (~30x15) → Esc/навигация → свернуть обратно в текст.
Повторное `v` — мгновенно из кэша. Целевой терминал: iTerm2.
### Этап 1: Инфраструктура [TODO] ### Этап 1: Инфраструктура [TODO]
- [ ] Модуль src/media/ - [ ] Обновить ratatui 0.29 → 0.30 (требование ratatui-image)
- image_cache.rs - LRU кэш для загруженных изображений - [ ] Добавить зависимости: `ratatui-image`, `image`
- image_loader.rs - Асинхронная загрузка через TDLib - [ ] Создать `src/media/` модуль
- image_renderer.rs - Рендеринг в ratatui - `cache.rs` — LRU кэш файлов, лимит 500 MB, `~/.cache/tele-tui/images/`
- [ ] Зависимости - `loader.rs` — загрузка через TDLib downloadFile API
- ratatui-image 1.0 - поддержка изображений в TUI
- Определение протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
- [ ] ImageCache с лимитами
- LRU кэш с максимальным размером в МБ
- Автоматическая очистка старых изображений
- MAX_IMAGE_CACHE_SIZE = 100 MB (по умолчанию)
### Этап 2: Интеграция с TDLib [TODO] ### Этап 2: Расширить MessageInfo [TODO]
- [ ] Обработка MessageContentPhoto - [ ] Добавить `MediaInfo` в `MessageContent` (PhotoInfo: file_id, width, height)
- Добавить PhotoInfo в MessageInfo - [ ] Сохранять метаданные фото при конвертации TDLib → MessageInfo
- Извлечение file_id, width, height из Photo - [ ] Обновить FakeTdClient для тестов
- Выбор оптимального размера изображения (до 800px)
- [ ] Загрузка файлов
- Метод TdClient::download_photo(file_id)
- Асинхронная загрузка через downloadFile API
- Обработка состояний загрузки (pending/downloading/ready)
- [ ] Кэширование
- Сохранение путей к загруженным файлам
- Повторное использование уже загруженных изображений
### Этап 3: Рендеринг в UI [TODO] ### Этап 3: Загрузка файлов [TODO]
- [ ] Модификация render_messages() - [ ] Добавить `download_file()` в TdClientTrait
- Определение возможностей терминала при старте - [ ] Реализация через TDLib `downloadFile` API
- Рендеринг изображений через ratatui-image - [ ] Состояния загрузки: Idle → Downloading → Ready → Error
- Автоматическое масштабирование под размер области - [ ] Кэширование в `~/.cache/tele-tui/images/`
- Сохранение aspect ratio
- [ ] Превью в списке сообщений
- Миниатюры размером 20x10 символов
- Lazy loading (загрузка только видимых)
- Placeholder пока изображение грузится
- [ ] Индикатор загрузки
- Текстовая заглушка "[Загрузка фото...]"
- Progress bar для больших файлов
- Процент загрузки
### Этап 4: Полноэкранный просмотр [TODO] ### Этап 4: UI рендеринг [TODO]
- [ ] Новый режим: ViewImage - [ ] `Picker::from_query_stdio()` при старте (определение iTerm2 протокола)
- `v` / `м` в режиме выбора - открыть изображение - [ ] Команда `ViewImage` (`v`/`м`) в режиме выбора → запуск загрузки
- Показ на весь экран терминала - [ ] Inline рендеринг через `StatefulImage` (ширина ~30, высота по aspect ratio)
- `Esc` для закрытия - [ ] Esc/навигация → сворачивание обратно в текст `📷 caption`
- [ ] Информация об изображении
- Размер файла
- Разрешение (width x height)
- Формат (JPEG/PNG/GIF)
- [ ] Навигация
- `←` / `→` - предыдущее/следующее изображение в чате
- Автоматическая загрузка соседних изображений
### Этап 5: Конфигурация и UX [TODO] ### Этап 5: Полировка [TODO]
- [ ] MediaConfig в config.toml - [ ] Индикатор загрузки (`📷 ⏳ Загрузка...`)
- show_images: bool - включить/отключить показ изображений - [ ] Обработка ошибок (таймаут 30 сек, битые файлы → fallback `📷 [Фото]`)
- image_cache_mb: usize - размер кэша в МБ - [ ] `show_images: bool` в config.toml
- preview_quality: "low" | "medium" | "high" - [ ] Логирование через tracing
- render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks"
- [ ] Поддержка различных терминалов
- Auto-detection протокола при старте
- Fallback на Unicode halfblocks для любого терминала
- Опция отключения изображений если терминал не поддерживает
- [ ] Оптимизация производительности
- Асинхронная загрузка (не блокирует UI)
- Приоритизация видимых изображений
- Fast resize для превью
- Кэширование отмасштабированных версий
### Этап 6: Обработка ошибок [TODO]
- [ ] Graceful fallback
- Текстовая заглушка "[Фото]" если загрузка не удалась
- Повторная попытка по запросу пользователя
- Логирование проблем через tracing
- [ ] Ограничения
- Таймаут загрузки (30 сек)
- Максимальный размер файла для автозагрузки (10 MB)
- Предупреждение для больших файлов
### Технические детали ### Технические детали
- **Поддерживаемые протоколы:** Sixel, Kitty Graphics, iTerm2 Inline Images, Unicode Halfblocks (fallback) - **Библиотека:** ratatui-image 10.x (iTerm2 Inline Images протокол)
- **Поддерживаемые форматы:** JPEG, PNG, GIF, WebP, BMP - **Форматы:** JPEG, PNG, GIF, WebP, BMP
- **Новые хоткеи:** `v`/`м` - полноэкранный просмотр, `←`/`→` - навигация, `Esc` - закрыть - **Кэш:** LRU, 500 MB, `~/.cache/tele-tui/images/`
- **Хоткеи:** `v`/`м` — показать/скрыть inline превью
--- ---

View File

@@ -85,6 +85,11 @@ pub struct App<T: TdClientTrait = TdClient> {
// Typing indicator // Typing indicator
/// Время последней отправки typing status (для throttling) /// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>, pub last_typing_sent: Option<std::time::Instant>,
// Image support
#[cfg(feature = "images")]
pub image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
#[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>,
} }
impl<T: TdClientTrait> App<T> { impl<T: TdClientTrait> App<T> {
@@ -104,6 +109,13 @@ impl<T: TdClientTrait> App<T> {
let mut state = ListState::default(); let mut state = ListState::default();
state.select(Some(0)); 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 { App {
config, config,
screen: AppScreen::Loading, screen: AppScreen::Loading,
@@ -126,6 +138,10 @@ impl<T: TdClientTrait> App<T> {
search_query: String::new(), search_query: String::new(),
needs_redraw: true, needs_redraw: true,
last_typing_sent: None, last_typing_sent: None,
#[cfg(feature = "images")]
image_renderer,
#[cfg(feature = "images")]
image_cache,
} }
} }

View File

@@ -48,6 +48,9 @@ pub enum Command {
ReactMessage, ReactMessage,
SelectMessage, SelectMessage,
// Media
ViewImage,
// Input // Input
SubmitMessage, SubmitMessage,
Cancel, Cancel,
@@ -202,6 +205,12 @@ impl Keybindings {
]); ]);
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key() // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
// Media
bindings.insert(Command::ViewImage, vec![
KeyBinding::new(KeyCode::Char('v')),
KeyBinding::new(KeyCode::Char('м')), // RU
]);
// Input // Input
bindings.insert(Command::SubmitMessage, vec![ bindings.insert(Command::SubmitMessage, vec![
KeyBinding::new(KeyCode::Enter), KeyBinding::new(KeyCode::Enter),

View File

@@ -43,6 +43,10 @@ pub struct Config {
/// Настройки desktop notifications. /// Настройки desktop notifications.
#[serde(default)] #[serde(default)]
pub notifications: NotificationsConfig, pub notifications: NotificationsConfig,
/// Настройки отображения изображений.
#[serde(default)]
pub images: ImagesConfig,
} }
/// Общие настройки приложения. /// Общие настройки приложения.
@@ -105,6 +109,27 @@ pub struct NotificationsConfig {
pub urgency: String, 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 атрибутами) // Дефолтные значения (используются serde атрибутами)
fn default_timezone() -> String { fn default_timezone() -> String {
"+03:00".to_string() "+03:00".to_string()
@@ -146,6 +171,14 @@ fn default_notification_urgency() -> String {
"normal".to_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 { impl Default for GeneralConfig {
fn default() -> Self { fn default() -> Self {
Self { timezone: default_timezone() } Self { timezone: default_timezone() }
@@ -183,6 +216,7 @@ impl Default for Config {
colors: ColorsConfig::default(), colors: ColorsConfig::default(),
keybindings: Keybindings::default(), keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(), notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
} }
} }
} }

View File

@@ -35,3 +35,22 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
/// Лимит количества сообщений для загрузки через TDLib за раз /// Лимит количества сообщений для загрузки через TDLib за раз
pub const TDLIB_MESSAGE_LIMIT: i32 = 50; 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;

View File

@@ -68,6 +68,10 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
} }
} }
} }
#[cfg(feature = "images")]
Some(crate::config::Command::ViewImage) => {
handle_view_image(app).await;
}
Some(crate::config::Command::ReactMessage) => { Some(crate::config::Command::ReactMessage) => {
let Some(msg) = app.get_selected_message() else { let Some(msg) = app.get_selected_message() else {
return; return;
@@ -462,3 +466,167 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
_ => {} _ => {}
} }
} }
/// Обработка команды ViewImage — раскрыть/свернуть превью фото
#[cfg(feature = "images")]
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, 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<std::path::PathBuf> = 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;
}
}
}

View File

@@ -40,6 +40,18 @@ use crossterm::event::KeyEvent;
async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) { async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
// Early return для режима выбора сообщения // Early return для режима выбора сообщения
if app.is_selecting_message() { 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; app.chat_state = crate::app::ChatState::Normal;
return; return;
} }

View File

@@ -7,6 +7,8 @@ pub mod config;
pub mod constants; pub mod constants;
pub mod formatting; pub mod formatting;
pub mod input; pub mod input;
#[cfg(feature = "images")]
pub mod media;
pub mod message_grouping; pub mod message_grouping;
pub mod notifications; pub mod notifications;
pub mod tdlib; pub mod tdlib;

View File

@@ -3,6 +3,8 @@ mod config;
mod constants; mod constants;
mod formatting; mod formatting;
mod input; mod input;
#[cfg(feature = "images")]
mod media;
mod message_grouping; mod message_grouping;
mod notifications; mod notifications;
mod tdlib; mod tdlib;

113
src/media/cache.rs Normal file
View File

@@ -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<PathBuf> {
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<PathBuf, String> {
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(())
}
}

View File

@@ -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<i64, StatefulProtocol>,
}
impl ImageRenderer {
/// Создаёт новый ImageRenderer, определяя поддерживаемый протокол терминала
pub fn new() -> Option<Self> {
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();
}
}

9
src/media/mod.rs Normal file
View File

@@ -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;

View File

@@ -362,6 +362,22 @@ impl TdClient {
.await .await
} }
// Делегирование файловых операций
/// Скачивает файл по file_id и возвращает локальный путь.
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
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 { pub fn client_id(&self) -> i32 {
self.client_id self.client_id

View File

@@ -159,6 +159,11 @@ impl TdClientTrait for TdClient {
self.toggle_reaction(chat_id, message_id, reaction).await self.toggle_reaction(chat_id, message_id, reaction).await
} }
// ============ File methods ============
async fn download_file(&self, file_id: i32) -> Result<String, String> {
self.download_file(file_id).await
}
fn client_id(&self) -> i32 { fn client_id(&self) -> i32 {
self.client_id() self.client_id()
} }

View File

@@ -7,7 +7,7 @@ use crate::types::MessageId;
use tdlib_rs::enums::{MessageContent, MessageSender}; use tdlib_rs::enums::{MessageContent, MessageSender};
use tdlib_rs::types::Message as TdMessage; use tdlib_rs::types::Message as TdMessage;
use super::types::{ForwardInfo, ReactionInfo, ReplyInfo}; use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo};
/// Извлекает текст контента из TDLib Message /// Извлекает текст контента из TDLib Message
/// ///
@@ -19,9 +19,9 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
MessageContent::MessagePhoto(p) => { MessageContent::MessagePhoto(p) => {
let caption_text = p.caption.text.clone(); let caption_text = p.caption.text.clone();
if caption_text.is_empty() { if caption_text.is_empty() {
"[Фото]".to_string() "📷 [Фото]".to_string()
} else { } else {
caption_text format!("📷 {}", caption_text)
} }
} }
MessageContent::MessageVideo(v) => { MessageContent::MessageVideo(v) => {
@@ -132,6 +132,40 @@ pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
}) })
} }
/// Извлекает информацию о медиа-контенте из TDLib Message
///
/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height.
/// Возвращает None для не-медийных типов сообщений.
pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
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 /// Извлекает реакции из TDLib Message
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> { pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
msg.interaction_info msg.interaction_info

View File

@@ -13,7 +13,7 @@ impl MessageManager {
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> { pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{ use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info, 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 forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg); let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg); let reactions = extract_reactions(msg);
let media = extract_media_info(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id)) let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name) .sender_name(sender_name)
@@ -65,6 +66,10 @@ impl MessageManager {
builder = builder.reactions(reactions); builder = builder.reactions(reactions);
if let Some(media) = media {
builder = builder.media(media);
}
Some(builder.build()) Some(builder.build())
} }

View File

@@ -18,7 +18,8 @@ pub use auth::AuthState;
pub use client::TdClient; pub use client::TdClient;
pub use r#trait::TdClientTrait; pub use r#trait::TdClientTrait;
pub use types::{ 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; pub use users::UserCache;

View File

@@ -90,6 +90,9 @@ pub trait TdClientTrait: Send {
reaction: String, reaction: String,
) -> Result<(), String>; ) -> Result<(), String>;
// ============ File methods ============
async fn download_file(&self, file_id: i32) -> Result<String, String>;
// ============ Getters (immutable) ============ // ============ Getters (immutable) ============
fn client_id(&self) -> i32; fn client_id(&self) -> i32;
async fn get_me(&self) -> Result<i64, String>; async fn get_me(&self) -> Result<i64, String>;

View File

@@ -54,6 +54,31 @@ pub struct ReactionInfo {
pub is_chosen: bool, 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, отправитель, время) /// Метаданные сообщения (ID, отправитель, время)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MessageMetadata { pub struct MessageMetadata {
@@ -65,11 +90,13 @@ pub struct MessageMetadata {
} }
/// Контент сообщения (текст и форматирование) /// Контент сообщения (текст и форматирование)
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone)]
pub struct MessageContent { pub struct MessageContent {
pub text: String, pub text: String,
/// Сущности форматирования (bold, italic, code и т.д.) /// Сущности форматирования (bold, italic, code и т.д.)
pub entities: Vec<TextEntity>, pub entities: Vec<TextEntity>,
/// Медиа-контент (фото, видео и т.д.)
pub media: Option<MediaInfo>,
} }
/// Состояние и права доступа к сообщению /// Состояние и права доступа к сообщению
@@ -132,6 +159,7 @@ impl MessageInfo {
content: MessageContent { content: MessageContent {
text: content, text: content,
entities, entities,
media: None,
}, },
state: MessageState { state: MessageState {
is_outgoing, 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> { pub fn reply_to(&self) -> Option<&ReplyInfo> {
self.interactions.reply_to.as_ref() self.interactions.reply_to.as_ref()
} }
@@ -246,6 +295,7 @@ pub struct MessageBuilder {
reply_to: Option<ReplyInfo>, reply_to: Option<ReplyInfo>,
forward_from: Option<ForwardInfo>, forward_from: Option<ForwardInfo>,
reactions: Vec<ReactionInfo>, reactions: Vec<ReactionInfo>,
media: Option<MediaInfo>,
} }
impl MessageBuilder { impl MessageBuilder {
@@ -266,6 +316,7 @@ impl MessageBuilder {
reply_to: None, reply_to: None,
forward_from: None, forward_from: None,
reactions: Vec::new(), reactions: Vec::new(),
media: None,
} }
} }
@@ -363,9 +414,15 @@ impl MessageBuilder {
self self
} }
/// Установить медиа-контент
pub fn media(mut self, media: MediaInfo) -> Self {
self.media = Some(media);
self
}
/// Построить MessageInfo из данных builder'а /// Построить MessageInfo из данных builder'а
pub fn build(self) -> MessageInfo { pub fn build(self) -> MessageInfo {
MessageInfo::new( let mut msg = MessageInfo::new(
self.id, self.id,
self.sender_name, self.sender_name,
self.is_outgoing, self.is_outgoing,
@@ -380,7 +437,9 @@ impl MessageBuilder {
self.reply_to, self.reply_to,
self.forward_from, self.forward_from,
self.reactions, self.reactions,
) );
msg.content.media = self.media;
msg
} }
} }

View File

@@ -8,6 +8,8 @@
use crate::config::Config; use crate::config::Config;
use crate::formatting; use crate::formatting;
use crate::tdlib::MessageInfo; use crate::tdlib::MessageInfo;
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::types::MessageId; use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp_with_tz}; use crate::utils::{format_date, format_timestamp_with_tz};
use ratatui::{ 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 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)
}

View File

@@ -12,4 +12,6 @@ pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item; pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker; pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header}; 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}; pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};

View File

@@ -32,6 +32,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
if app.selected_chat_id.is_some() { if app.selected_chat_id.is_some() {
// Чат открыт — показываем только сообщения // Чат открыт — показываем только сообщения
messages::render(f, chunks[1], app); messages::render(f, chunks[1], app);
// Второй проход: рендеринг изображений поверх placeholder-ов
#[cfg(feature = "images")]
messages::render_images(f, chunks[1], app);
} else { } else {
// Чат не открыт — показываем только список чатов // Чат не открыт — показываем только список чатов
chat_list::render(f, chunks[1], app); chat_list::render(f, chunks[1], app);
@@ -48,6 +51,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
chat_list::render(f, main_chunks[0], app); chat_list::render(f, main_chunks[0], app);
messages::render(f, main_chunks[1], 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); footer::render(f, chunks[2], app);

View File

@@ -367,3 +367,126 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} }
} }
/// Рендерит изображения поверх placeholder-ов в списке сообщений (второй проход).
///
/// Вызывается из main_screen после основного render(), т.к. требует &mut App
/// для доступа к ImageRenderer.get_protocol() (StatefulImage — stateful widget).
#[cfg(feature = "images")]
pub fn render_images<T: TdClientTrait>(f: &mut Frame, messages_area: Rect, app: &mut App<T>) {
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<DeferredImageRender> = 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);
}
}
}
}

View File

@@ -1,6 +1,6 @@
// Integration tests for config flow // 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: Дефолтные значения конфигурации
#[test] #[test]
@@ -34,6 +34,7 @@ fn test_config_custom_values() {
}, },
keybindings: Keybindings::default(), keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(), notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
}; };
assert_eq!(config.general.timezone, "+05:00"); assert_eq!(config.general.timezone, "+05:00");
@@ -118,6 +119,7 @@ fn test_config_toml_serialization() {
}, },
keybindings: Keybindings::default(), keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(), notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
}; };
// Сериализуем в TOML // Сериализуем в TOML

View File

@@ -51,6 +51,9 @@ pub struct FakeTdClient {
// Update channel для симуляции событий // Update channel для симуляции событий
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>, pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
// Скачанные файлы (file_id -> local_path)
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
// Настройки поведения // Настройки поведения
pub simulate_delays: bool, pub simulate_delays: bool,
pub fail_next_operation: Arc<Mutex<bool>>, pub fail_next_operation: Arc<Mutex<bool>>,
@@ -121,6 +124,7 @@ impl Clone for FakeTdClient {
viewed_messages: Arc::clone(&self.viewed_messages), viewed_messages: Arc::clone(&self.viewed_messages),
chat_actions: Arc::clone(&self.chat_actions), chat_actions: Arc::clone(&self.chat_actions),
pending_view_messages: Arc::clone(&self.pending_view_messages), pending_view_messages: Arc::clone(&self.pending_view_messages),
downloaded_files: Arc::clone(&self.downloaded_files),
update_tx: Arc::clone(&self.update_tx), update_tx: Arc::clone(&self.update_tx),
simulate_delays: self.simulate_delays, simulate_delays: self.simulate_delays,
fail_next_operation: Arc::clone(&self.fail_next_operation), fail_next_operation: Arc::clone(&self.fail_next_operation),
@@ -154,6 +158,7 @@ impl FakeTdClient {
viewed_messages: Arc::new(Mutex::new(vec![])), viewed_messages: Arc::new(Mutex::new(vec![])),
chat_actions: Arc::new(Mutex::new(vec![])), chat_actions: Arc::new(Mutex::new(vec![])),
pending_view_messages: 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)), update_tx: Arc::new(Mutex::new(None)),
simulate_delays: false, simulate_delays: false,
fail_next_operation: Arc::new(Mutex::new(false)), fail_next_operation: Arc::new(Mutex::new(false)),
@@ -237,6 +242,12 @@ impl FakeTdClient {
self 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<String>) -> Self { pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
*self.available_reactions.lock().unwrap() = reactions; *self.available_reactions.lock().unwrap() = reactions;
@@ -587,6 +598,20 @@ impl FakeTdClient {
Ok(()) Ok(())
} }
/// Скачать файл (mock)
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
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<ProfileInfo, String> { pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
if self.should_fail() { if self.should_fail() {

View File

@@ -161,6 +161,11 @@ impl TdClientTrait for FakeTdClient {
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
} }
// ============ File methods ============
async fn download_file(&self, file_id: i32) -> Result<String, String> {
FakeTdClient::download_file(self, file_id).await
}
// ============ Getters (immutable) ============ // ============ Getters (immutable) ============
fn client_id(&self) -> i32 { fn client_id(&self) -> i32 {
0 // Fake client ID 0 // Fake client ID