Skip to main content

oino_ui/extras/
image.rs

1//! `Image` node (§4.5.2).
2
3use serde::{Deserialize, Serialize};
4
5use crate::node::{Common, IntoNode, Node};
6
7/// Where the image bytes come from (§4.5.2).
8#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9#[serde(untagged)]
10pub enum ImageSource {
11    /// Inline base64 bytes (≤ 1 MiB decoded; > 4 MiB decoded rejected, §4.8).
12    #[serde(rename_all = "camelCase")]
13    Data {
14        /// Base64-encoded bytes.
15        data: String,
16        /// MIME type.
17        mime_type: String,
18    },
19    /// Host file path (fetched with `fs_read`, host.md §9.1).
20    #[serde(rename_all = "camelCase")]
21    Path {
22        /// Path in the session workspace.
23        path: String,
24    },
25    /// Host-side blob id (fetched with `blob_get`, host.md §9.2).
26    #[serde(rename_all = "camelCase")]
27    Blob {
28        /// Blob id.
29        blob: String,
30    },
31}
32
33impl ImageSource {
34    /// Inline base64 source.
35    #[must_use]
36    pub fn data(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
37        Self::Data {
38            data: data.into(),
39            mime_type: mime_type.into(),
40        }
41    }
42
43    /// Host file-path source.
44    #[must_use]
45    pub fn path(path: impl Into<String>) -> Self {
46        Self::Path { path: path.into() }
47    }
48
49    /// Host blob-id source.
50    #[must_use]
51    pub fn blob(blob: impl Into<String>) -> Self {
52        Self::Blob { blob: blob.into() }
53    }
54
55    /// Base64 payload length estimate in decoded bytes (only meaningful for
56    /// [`ImageSource::Data`]).
57    #[must_use]
58    pub fn decoded_len_estimate(&self) -> usize {
59        match self {
60            Self::Data { data, .. } => data.len() * 3 / 4,
61            _ => 0,
62        }
63    }
64}
65
66/// Object fit.
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
68pub enum Fit {
69    /// Keep aspect ratio, fit inside (default).
70    #[default]
71    Contain,
72    /// Keep aspect ratio, cover the area.
73    Cover,
74}
75
76/// An image (§4.5.2). TUI: kitty / iTerm2 / sixel when supported, else a dim
77/// `[image: alt]` box.
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct Image {
81    /// Common node props (`key`, `style`, `block`, `fallback`).
82    #[serde(flatten)]
83    pub common: Common,
84    /// Image source.
85    pub src: ImageSource,
86    /// Alt text (the TUI fallback box).
87    pub alt: String,
88    /// Object fit.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub fit: Option<Fit>,
91    /// Maximum width in columns.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub max_width: Option<u16>,
94    /// Maximum height in rows.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub max_height: Option<u16>,
97}
98
99impl Image {
100    /// An image with source and alt text.
101    #[must_use]
102    pub fn new(src: ImageSource, alt: impl Into<String>) -> Self {
103        Self {
104            common: Common::new(),
105            src,
106            alt: alt.into(),
107            fit: None,
108            max_width: None,
109            max_height: None,
110        }
111    }
112
113    /// Sets the object fit.
114    #[must_use]
115    pub fn fit(mut self, fit: Fit) -> Self {
116        self.fit = Some(fit);
117        self
118    }
119
120    /// Sets the maximum width.
121    #[must_use]
122    pub fn max_width(mut self, width: u16) -> Self {
123        self.max_width = Some(width);
124        self
125    }
126
127    /// Sets the maximum height.
128    #[must_use]
129    pub fn max_height(mut self, height: u16) -> Self {
130        self.max_height = Some(height);
131        self
132    }
133}
134
135impl IntoNode for Image {}
136impl From<Image> for Node {
137    fn from(i: Image) -> Self {
138        Node::Image(i)
139    }
140}