Skip to main content

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}