Skip to main content

ratatui_ratty/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use base64::Engine as _;
4use ratatui_core::{buffer::Buffer, layout::Rect, widgets::Widget};
5use std::borrow::Cow;
6use std::io::{self, Write};
7use std::path::Path;
8
9const PAYLOAD_CHUNK_SIZE: usize = 3072;
10
11/// Object asset format.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ObjectFormat {
14    /// Wavefront OBJ.
15    Obj,
16    /// Binary glTF.
17    Glb,
18}
19
20impl ObjectFormat {
21    fn as_str(self) -> &'static str {
22        match self {
23            Self::Obj => "obj",
24            Self::Glb => "glb",
25        }
26    }
27
28    fn infer(path: &str) -> Self {
29        match Path::new(path)
30            .extension()
31            .and_then(|ext| ext.to_str())
32            .map(|ext| ext.to_ascii_lowercase())
33            .as_deref()
34        {
35            Some("obj") => Self::Obj,
36            _ => Self::Glb,
37        }
38    }
39
40    fn payload_name(self) -> &'static str {
41        match self {
42            Self::Obj => "payload.obj",
43            Self::Glb => "payload.glb",
44        }
45    }
46}
47
48/// Ratty graphic widget settings.
49#[derive(Debug, Clone)]
50pub struct RattyGraphicSettings<'a> {
51    /// Object identifier.
52    pub id: u32,
53    /// Asset path.
54    pub path: Cow<'a, str>,
55    /// Asset format.
56    pub format: ObjectFormat,
57    /// Enables default animation.
58    pub animate: bool,
59    /// Scale multiplier.
60    pub scale: f32,
61    /// Extrusion depth.
62    pub depth: f32,
63    /// Optional object color.
64    pub color: Option<[u8; 3]>,
65    /// Object brightness multiplier.
66    pub brightness: f32,
67    /// Translation offset relative to the anchor.
68    pub offset: [f32; 3],
69    /// Rotation in degrees.
70    pub rotation: [f32; 3],
71    /// Non-uniform scale multiplier.
72    pub scale3: [f32; 3],
73}
74
75impl<'a> RattyGraphicSettings<'a> {
76    /// Creates widget settings for an asset path.
77    pub fn new(path: impl Into<Cow<'a, str>>) -> Self {
78        let path = path.into();
79        Self {
80            id: 1,
81            format: ObjectFormat::infer(&path),
82            path,
83            animate: true,
84            scale: 1.0,
85            depth: 0.0,
86            color: None,
87            brightness: 1.0,
88            offset: [0.0, 0.0, 0.0],
89            rotation: [0.0, 0.0, 0.0],
90            scale3: [1.0, 1.0, 1.0],
91        }
92    }
93
94    /// Sets the object identifier.
95    pub fn id(mut self, id: u32) -> Self {
96        self.id = id;
97        self
98    }
99
100    /// Sets the asset format.
101    pub fn format(mut self, format: ObjectFormat) -> Self {
102        self.format = format;
103        self
104    }
105
106    /// Enables or disables animation.
107    pub fn animate(mut self, animate: bool) -> Self {
108        self.animate = animate;
109        self
110    }
111
112    /// Sets the scale multiplier.
113    pub fn scale(mut self, scale: f32) -> Self {
114        self.scale = scale;
115        self
116    }
117
118    /// Sets the extrusion depth.
119    pub fn depth(mut self, depth: f32) -> Self {
120        self.depth = depth;
121        self
122    }
123
124    /// Sets the object color.
125    pub fn color(mut self, color: [u8; 3]) -> Self {
126        self.color = Some(color);
127        self
128    }
129
130    /// Sets the brightness multiplier.
131    pub fn brightness(mut self, brightness: f32) -> Self {
132        self.brightness = brightness;
133        self
134    }
135
136    /// Sets the translation offset relative to the anchor.
137    pub fn offset(mut self, offset: [f32; 3]) -> Self {
138        self.offset = offset;
139        self
140    }
141
142    /// Sets the rotation in degrees.
143    pub fn rotation(mut self, rotation: [f32; 3]) -> Self {
144        self.rotation = rotation;
145        self
146    }
147
148    /// Sets the non-uniform scale multiplier.
149    pub fn scale3(mut self, scale3: [f32; 3]) -> Self {
150        self.scale3 = scale3;
151        self
152    }
153}
154
155/// Ratty graphic widget.
156pub struct RattyGraphic<'a> {
157    settings: RattyGraphicSettings<'a>,
158}
159
160impl<'a> RattyGraphic<'a> {
161    /// Creates a graphic widget.
162    pub fn new(settings: RattyGraphicSettings<'a>) -> Self {
163        Self { settings }
164    }
165
166    /// Returns the widget settings.
167    pub fn settings(&self) -> &RattyGraphicSettings<'a> {
168        &self.settings
169    }
170
171    /// Returns mutable widget settings.
172    pub fn settings_mut(&mut self) -> &mut RattyGraphicSettings<'a> {
173        &mut self.settings
174    }
175
176    /// Returns the RGP register sequence.
177    pub fn register_sequence(&self) -> String {
178        format!(
179            "\x1b_ratty;g;r;id={};fmt={};path={}\x1b\\",
180            self.settings.id,
181            self.settings.format.as_str(),
182            self.settings.path
183        )
184    }
185
186    /// Returns the RGP register sequences for a payload-backed asset.
187    pub fn register_payload_sequences(&self, bytes: &[u8]) -> Vec<String> {
188        self.register_payload_sequences_with_name(bytes, None)
189    }
190
191    /// Returns the RGP register sequences for a payload-backed asset with an explicit source name.
192    pub fn register_payload_sequences_with_name(
193        &self,
194        bytes: &[u8],
195        name: Option<&str>,
196    ) -> Vec<String> {
197        let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
198        let default_name = Path::new(self.settings.path.as_ref())
199            .file_name()
200            .and_then(|name| name.to_str())
201            .filter(|name| !name.is_empty())
202            .unwrap_or_else(|| self.settings.format.payload_name());
203        let name = name.unwrap_or(default_name);
204        let mut sequences = Vec::new();
205
206        for (index, chunk_start) in (0..encoded.len()).step_by(PAYLOAD_CHUNK_SIZE).enumerate() {
207            let chunk_end = (chunk_start + PAYLOAD_CHUNK_SIZE).min(encoded.len());
208            let more = u8::from(chunk_end < encoded.len());
209            let chunk = &encoded[chunk_start..chunk_end];
210            sequences.push(if index == 0 {
211                format!(
212                    "\x1b_ratty;g;r;id={};fmt={};source=payload;more={};name={};{}\x1b\\",
213                    self.settings.id,
214                    self.settings.format.as_str(),
215                    more,
216                    name,
217                    chunk
218                )
219            } else {
220                format!(
221                    "\x1b_ratty;g;r;id={};fmt={};source=payload;more={};{}\x1b\\",
222                    self.settings.id,
223                    self.settings.format.as_str(),
224                    more,
225                    chunk
226                )
227            });
228        }
229
230        if sequences.is_empty() {
231            sequences.push(format!(
232                "\x1b_ratty;g;r;id={};fmt={};source=payload;more=0;name={};\x1b\\",
233                self.settings.id,
234                self.settings.format.as_str(),
235                name,
236            ));
237        }
238
239        sequences
240    }
241
242    /// Writes the RGP register sequence to stdout.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if stdout cannot be written or flushed.
247    pub fn register(&self) -> io::Result<()> {
248        io::stdout().write_all(self.register_sequence().as_bytes())?;
249        io::stdout().flush()
250    }
251
252    /// Writes the RGP register sequences for a payload-backed asset to stdout.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if stdout cannot be written or flushed.
257    pub fn register_payload(&self, bytes: &[u8]) -> io::Result<()> {
258        self.register_payload_with_name(bytes, None)
259    }
260
261    /// Writes the RGP register sequences for a payload-backed asset to stdout with an explicit source name.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if stdout cannot be written or flushed.
266    pub fn register_payload_with_name(&self, bytes: &[u8], name: Option<&str>) -> io::Result<()> {
267        let mut stdout = io::stdout();
268        for sequence in self.register_payload_sequences_with_name(bytes, name) {
269            stdout.write_all(sequence.as_bytes())?;
270        }
271        stdout.flush()
272    }
273
274    /// Returns the RGP place sequence for an area.
275    pub fn place_sequence(&self, area: Rect) -> String {
276        let center_row = area.y.saturating_add(area.height.saturating_sub(1) / 2);
277        let center_col = area.x.saturating_add(area.width.saturating_sub(1) / 2);
278        format!(
279            "\x1b_ratty;g;p;id={};row={};col={};w={};h={};animate={};scale={};depth={};color={};brightness={};px={};py={};pz={};rx={};ry={};rz={};sx={};sy={};sz={}\x1b\\",
280            self.settings.id,
281            center_row,
282            center_col,
283            area.width.max(1),
284            area.height.max(1),
285            u8::from(self.settings.animate),
286            self.settings.scale,
287            self.settings.depth,
288            self.settings
289                .color
290                .map(|[r, g, b]| format!("{r:02x}{g:02x}{b:02x}"))
291                .unwrap_or_else(|| "ffffff".to_string()),
292            self.settings.brightness,
293            self.settings.offset[0],
294            self.settings.offset[1],
295            self.settings.offset[2],
296            self.settings.rotation[0],
297            self.settings.rotation[1],
298            self.settings.rotation[2],
299            self.settings.scale3[0],
300            self.settings.scale3[1],
301            self.settings.scale3[2],
302        )
303    }
304
305    /// Returns the RGP update sequence.
306    pub fn update_sequence(&self) -> String {
307        format!(
308            "\x1b_ratty;g;u;id={};animate={};scale={};depth={};color={};brightness={};px={};py={};pz={};rx={};ry={};rz={};sx={};sy={};sz={}\x1b\\",
309            self.settings.id,
310            u8::from(self.settings.animate),
311            self.settings.scale,
312            self.settings.depth,
313            self.settings
314                .color
315                .map(|[r, g, b]| format!("{r:02x}{g:02x}{b:02x}"))
316                .unwrap_or_else(|| "ffffff".to_string()),
317            self.settings.brightness,
318            self.settings.offset[0],
319            self.settings.offset[1],
320            self.settings.offset[2],
321            self.settings.rotation[0],
322            self.settings.rotation[1],
323            self.settings.rotation[2],
324            self.settings.scale3[0],
325            self.settings.scale3[1],
326            self.settings.scale3[2],
327        )
328    }
329
330    /// Returns the RGP delete sequence.
331    pub fn delete_sequence(&self) -> String {
332        format!("\x1b_ratty;g;d;id={}\x1b\\", self.settings.id)
333    }
334
335    /// Writes the RGP delete sequence to stdout.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if stdout cannot be written or flushed.
340    pub fn clear(&self) -> io::Result<()> {
341        io::stdout().write_all(self.delete_sequence().as_bytes())?;
342        io::stdout().flush()
343    }
344
345    /// Writes the RGP update sequence to stdout.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error if stdout cannot be written or flushed.
350    pub fn update(&self) -> io::Result<()> {
351        io::stdout().write_all(self.update_sequence().as_bytes())?;
352        io::stdout().flush()
353    }
354}
355
356/// Renders the place sequence into a Ratatui buffer.
357impl Widget for &RattyGraphic<'_> {
358    fn render(self, area: Rect, buf: &mut Buffer) {
359        if area.is_empty() {
360            return;
361        }
362
363        let place = self.place_sequence(area);
364
365        if let Some(cell) = buf.cell_mut((area.x, area.y)) {
366            let existing = cell.symbol();
367            let mut symbol = String::with_capacity(place.len() + existing.len());
368            symbol.push_str(&place);
369            symbol.push_str(existing);
370            cell.set_symbol(&symbol);
371        }
372    }
373}