a2ui_tui/components/image.rs
1//! Image component — renders an image in the TUI.
2//!
3//! Renders the image from a LOCAL file path via `ratatui-image`. The terminal
4//! is probed **once** for the best native graphics protocol it supports and the
5//! renderer degrades automatically: **kitty → iTerm2 → Sixel → Halfblocks**
6//! (Halfblocks works in every terminal via colored half-block glyphs). If the
7//! URL is not a loadable local path, or decoding fails, it falls back to the
8//! text placeholder below. Use [`detected_protocol()`] to read back which
9//! protocol actually won.
10
11use ratatui::{
12 Frame,
13 layout::Rect,
14 style::{Color, Style},
15 text::{Line, Span},
16 widgets::Paragraph,
17};
18
19use a2ui_base::model::component_context::ComponentContext;
20use a2ui_base::protocol::common_types::DynamicString;
21use crate::component_impl::TuiComponent;
22
23/// Render the standard text placeholder into `inner`.
24fn render_placeholder(
25 variant_str: &str,
26 content: &str,
27 inner: Rect,
28 frame: &mut Frame,
29) {
30 let placeholder = format!("[\u{1F5BC}{} {}]", variant_str, content);
31 let paragraph = Paragraph::new(Line::from(Span::styled(
32 placeholder,
33 Style::default().fg(Color::DarkGray),
34 )));
35 frame.render_widget(paragraph, inner);
36}
37
38/// Image component implementation.
39///
40/// Renders the real image via `ratatui-image` when the URL resolves to a
41/// loadable local file path; otherwise shows the placeholder
42/// `[🖼 description]`. Applies a default 1-cell margin.
43pub struct ImageComponent;
44
45impl TuiComponent for ImageComponent {
46 fn name(&self) -> &'static str {
47 "Image"
48 }
49
50 fn render(
51 &self,
52 ctx: &ComponentContext,
53 area: Rect,
54 frame: &mut Frame,
55 _render_child: &mut dyn FnMut(&str, Rect, &mut Frame, &str),
56 _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
57 ) {
58 let comp_model = match ctx.components.get(&ctx.component_id) {
59 Some(m) => m,
60 None => return,
61 };
62
63 // Apply default 1-cell margin on all sides (never collapses to zero).
64 let inner = crate::layout_engine::padded_content(area);
65
66 if inner.width == 0 || inner.height == 0 {
67 return;
68 }
69
70 // Resolve description and URL.
71 let description = match comp_model.get_property::<DynamicString>("description") {
72 Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
73 None => String::new(),
74 };
75 let url = match comp_model.get_property::<DynamicString>("url") {
76 Some(ds) => ctx.data_context.resolve_dynamic_string(&ds),
77 None => String::new(),
78 };
79
80 // Resolve fit and variant properties.
81 let _fit: Option<String> = comp_model.get_property("fit");
82 let variant: Option<String> = comp_model.get_property("variant");
83 let variant_str = variant.as_deref().map(|v| format!(" ({})", v)).unwrap_or_default();
84
85 // Use description if available, otherwise fall back to URL.
86 // (Borrow `url` rather than moving it so it remains available for the
87 // real-render attempt below when the `image` feature is enabled.)
88 let content = if !description.is_empty() {
89 description
90 } else if !url.is_empty() {
91 url.clone()
92 } else {
93 "image".to_string()
94 };
95
96 // Attempt real rendering. On any failure (non-local URL, missing file,
97 // decode error), fall back to the text placeholder so the render loop
98 // never panics.
99 if let Ok(()) = real::render(&url, inner, frame) {
100 return;
101 }
102
103 render_placeholder(&variant_str, &content, inner, frame);
104 }
105
106 fn natural_height(
107 &self,
108 _ctx: &ComponentContext,
109 _available_width: u16,
110 _measure_child: &mut dyn FnMut(&str, &str, u16) -> Option<u16>,
111 ) -> Option<u16> {
112 // Placeholder is one line + 2 margin; real images scale to fit, so
113 // authors grow them with `weight`.
114 Some(3)
115 }
116}
117
118// ---------------------------------------------------------------------------
119// Real rendering via `ratatui-image`
120// ---------------------------------------------------------------------------
121
122mod real {
123 use std::sync::OnceLock;
124
125 use ratatui::{Frame, layout::{Rect, Size}};
126 use ratatui_image::{
127 Image, Resize,
128 picker::{Picker, ProtocolType},
129 protocol::Protocol,
130 };
131
132 /// The terminal-capability picker, cached for the process lifetime.
133 ///
134 /// `from_query_stdio` writes & reads stdio **once** to probe for the best
135 /// native graphics protocol the terminal supports. On a "no capabilities"
136 /// / "no response" result it already returns a Halfblocks picker; we
137 /// additionally fall back to `halfblocks()` on any hard error. The result:
138 /// the highest fidelity the terminal supports (kitty > iTerm2 > Sixel >
139 /// Halfblocks), degrading automatically — no per-terminal config needed.
140 ///
141 /// The probe is blocking and re-queries the terminal, so it MUST be cached.
142 /// Re-querying every render frame would stall the loop and flicker the
143 /// screen. The first call lands during the first `draw()`, which is after
144 /// the alternate screen is entered — exactly when ratatui-image requires.
145 fn picker() -> &'static Picker {
146 static PICKER: OnceLock<Picker> = OnceLock::new();
147 PICKER.get_or_init(|| match Picker::from_query_stdio() {
148 Ok(p) => p,
149 Err(_) => Picker::halfblocks(),
150 })
151 }
152
153 /// Human-readable name of the protocol the cached picker settled on.
154 pub fn protocol_name() -> &'static str {
155 match picker().protocol_type() {
156 ProtocolType::Halfblocks => "Halfblocks",
157 ProtocolType::Sixel => "Sixel",
158 ProtocolType::Kitty => "Kitty",
159 ProtocolType::Iterm2 => "iTerm2",
160 }
161 }
162
163 /// Load the file at `path` as an image, build a protocol, and render it.
164 ///
165 /// Returns `Ok(())` only when the image was actually rendered; any failure
166 /// (non-file URL, missing file, decode error, picker error) returns `Err`
167 /// so the caller falls back to the placeholder.
168 pub fn render(path: &str, inner: Rect, frame: &mut Frame) -> Result<(), ()> {
169 // Only local file paths are supported — no HTTP fetch (would require a
170 // heavy async/HTTP dep). Reject obviously non-path URLs early.
171 if path.is_empty() || path.starts_with("http://") || path.starts_with("https://") {
172 return Err(());
173 }
174 let path = std::path::Path::new(path);
175 if !path.is_file() {
176 return Err(());
177 }
178
179 let dyn_image = image::ImageReader::open(path)
180 .map_err(|_| ())?
181 .with_guessed_format()
182 .map_err(|_| ())?
183 .decode()
184 .map_err(|_| ())?;
185
186 // Use the cached terminal-capability picker: it picks the best native
187 // protocol the terminal supports (kitty / iTerm2 / Sixel), degrading
188 // to Halfblocks (works in any terminal) when none is available.
189 let picker = picker();
190
191 // v11 takes a `Size` (cell grid dimensions), not a `Rect`.
192 let size = Size {
193 width: inner.width,
194 height: inner.height,
195 };
196 let protocol: Protocol = picker
197 .new_protocol(dyn_image, size, Resize::Fit(None))
198 .map_err(|_| ())?;
199
200 // v11's `Image::new` borrows the protocol immutably.
201 frame.render_widget(Image::new(&protocol), inner);
202 Ok(())
203 }
204}
205
206/// Name of the terminal graphics protocol the renderer settled on after probing
207/// the terminal (e.g. `"Kitty"`, `"iTerm2"`, `"Sixel"`, or `"Halfblocks"`).
208///
209/// The probe runs at most once and is cached, so this is cheap to call per
210/// frame — useful for showing the active protocol in a status bar.
211pub fn detected_protocol() -> &'static str {
212 real::protocol_name()
213}