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:
27
CONTEXT.md
27
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 |
|
| 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,15 +68,18 @@ 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
|
||||||
|
|
||||||
### Зависимости (основные)
|
### Зависимости (основные)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
ratatui = "0.29" # TUI фреймворк
|
ratatui = "0.29" # TUI фреймворк
|
||||||
crossterm = "0.28" # Терминальный backend
|
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
649
Cargo.lock
generated
@@ -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]]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
110
ROADMAP.md
110
ROADMAP.md
@@ -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 превью
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
113
src/media/cache.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/media/image_renderer.rs
Normal file
54
src/media/image_renderer.rs
Normal 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
9
src/media/mod.rs
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user