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    // STL
19    Stl,
20}
21
22impl ObjectFormat {
23    fn as_str(self) -> &'static str {
24        match self {
25            Self::Obj => "obj",
26            Self::Glb => "glb",
27            Self::Stl => "stl",
28        }
29    }
30
31    fn infer(path: &str) -> Self {
32        match Path::new(path)
33            .extension()
34            .and_then(|ext| ext.to_str())
35            .map(|ext| ext.to_ascii_lowercase())
36            .as_deref()
37        {
38            Some("obj") => Self::Obj,
39            Some("stl") => Self::Stl,
40            _ => Self::Glb,
41        }
42    }
43
44    fn payload_name(self) -> &'static str {
45        match self {
46            Self::Obj => "payload.obj",
47            Self::Glb => "payload.glb",
48            Self::Stl => "payload.stl",
49        }
50    }
51}
52
53/// Ratty graphic widget settings.
54#[derive(Debug, Clone)]
55pub struct RattyGraphicSettings<'a> {
56    /// Object identifier.
57    pub id: u32,
58    /// Asset path.
59    pub path: Cow<'a, str>,
60    /// Asset format.
61    pub format: ObjectFormat,
62    /// Controls registration-time normalization for OBJ assets.
63    pub normalize: bool,
64    /// Enables default animation.
65    pub animate: bool,
66    /// Scale multiplier.
67    pub scale: f32,
68    /// Extrusion depth.
69    pub depth: f32,
70    /// Optional object color.
71    pub color: Option<[u8; 3]>,
72    /// Object brightness multiplier.
73    pub brightness: f32,
74    /// Translation offset relative to the anchor.
75    pub offset: [f32; 3],
76    /// Rotation in degrees.
77    pub rotation: [f32; 3],
78    /// Non-uniform scale multiplier.
79    pub scale3: [f32; 3],
80}
81
82impl<'a> RattyGraphicSettings<'a> {
83    /// Creates widget settings for an asset path.
84    pub fn new(path: impl Into<Cow<'a, str>>) -> Self {
85        let path = path.into();
86        Self {
87            id: 1,
88            format: ObjectFormat::infer(&path),
89            path,
90            normalize: true,
91            animate: true,
92            scale: 1.0,
93            depth: 0.0,
94            color: None,
95            brightness: 1.0,
96            offset: [0.0, 0.0, 0.0],
97            rotation: [0.0, 0.0, 0.0],
98            scale3: [1.0, 1.0, 1.0],
99        }
100    }
101
102    /// Sets the object identifier.
103    pub fn id(mut self, id: u32) -> Self {
104        self.id = id;
105        self
106    }
107
108    /// Sets the asset format.
109    pub fn format(mut self, format: ObjectFormat) -> Self {
110        self.format = format;
111        self
112    }
113
114    /// Enables or disables registration-time normalization for OBJ assets.
115    ///
116    /// Normalization is enabled by default. With normalization enabled, Ratty
117    /// recenters each OBJ mesh around its bounding-box center and scales it by
118    /// the largest bounding-box axis so imported models have a predictable
119    /// origin and approximate unit size.
120    ///
121    /// Use `normalize(false)` when the OBJ coordinates are already meaningful,
122    /// for example a generated object that uses Ratty's scene coordinates or a
123    /// larger assembly made from multiple separately registered objects.
124    pub fn normalize(mut self, normalize: bool) -> Self {
125        self.normalize = normalize;
126        self
127    }
128
129    /// Enables or disables animation.
130    pub fn animate(mut self, animate: bool) -> Self {
131        self.animate = animate;
132        self
133    }
134
135    /// Sets the scale multiplier.
136    pub fn scale(mut self, scale: f32) -> Self {
137        self.scale = scale;
138        self
139    }
140
141    /// Sets the extrusion depth.
142    pub fn depth(mut self, depth: f32) -> Self {
143        self.depth = depth;
144        self
145    }
146
147    /// Sets the object color.
148    pub fn color(mut self, color: [u8; 3]) -> Self {
149        self.color = Some(color);
150        self
151    }
152
153    /// Sets the brightness multiplier.
154    pub fn brightness(mut self, brightness: f32) -> Self {
155        self.brightness = brightness;
156        self
157    }
158
159    /// Sets the translation offset relative to the anchor.
160    pub fn offset(mut self, offset: [f32; 3]) -> Self {
161        self.offset = offset;
162        self
163    }
164
165    /// Sets the rotation in degrees.
166    pub fn rotation(mut self, rotation: [f32; 3]) -> Self {
167        self.rotation = rotation;
168        self
169    }
170
171    /// Sets the non-uniform scale multiplier.
172    pub fn scale3(mut self, scale3: [f32; 3]) -> Self {
173        self.scale3 = scale3;
174        self
175    }
176}
177
178/// Ratty graphic widget.
179pub struct RattyGraphic<'a> {
180    settings: RattyGraphicSettings<'a>,
181}
182
183impl<'a> RattyGraphic<'a> {
184    /// Creates a graphic widget.
185    pub fn new(settings: RattyGraphicSettings<'a>) -> Self {
186        Self { settings }
187    }
188
189    /// Returns the widget settings.
190    pub fn settings(&self) -> &RattyGraphicSettings<'a> {
191        &self.settings
192    }
193
194    /// Returns mutable widget settings.
195    pub fn settings_mut(&mut self) -> &mut RattyGraphicSettings<'a> {
196        &mut self.settings
197    }
198
199    /// Returns the RGP register sequence.
200    pub fn register_sequence(&self) -> String {
201        format!(
202            "\x1b_ratty;g;r;id={};fmt={};path={};normalize={}\x1b\\",
203            self.settings.id,
204            self.settings.format.as_str(),
205            self.settings.path,
206            u8::from(self.settings.normalize)
207        )
208    }
209
210    /// Returns the RGP register sequences for a payload-backed asset.
211    pub fn register_payload_sequences(&self, bytes: &[u8]) -> Vec<String> {
212        self.register_payload_sequences_with_name(bytes, None)
213    }
214
215    /// Returns the RGP register sequences for a payload-backed asset with an explicit source name.
216    pub fn register_payload_sequences_with_name(
217        &self,
218        bytes: &[u8],
219        name: Option<&str>,
220    ) -> Vec<String> {
221        let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
222        let default_name = Path::new(self.settings.path.as_ref())
223            .file_name()
224            .and_then(|name| name.to_str())
225            .filter(|name| !name.is_empty())
226            .unwrap_or_else(|| self.settings.format.payload_name());
227        let name = name.unwrap_or(default_name);
228        let mut sequences = Vec::new();
229
230        for (index, chunk_start) in (0..encoded.len()).step_by(PAYLOAD_CHUNK_SIZE).enumerate() {
231            let chunk_end = (chunk_start + PAYLOAD_CHUNK_SIZE).min(encoded.len());
232            let more = u8::from(chunk_end < encoded.len());
233            let chunk = &encoded[chunk_start..chunk_end];
234            sequences.push(if index == 0 {
235                format!(
236                    "\x1b_ratty;g;r;id={};fmt={};source=payload;more={};name={};normalize={};{}\x1b\\",
237                    self.settings.id,
238                    self.settings.format.as_str(),
239                    more,
240                    name,
241                    u8::from(self.settings.normalize),
242                    chunk
243                )
244            } else {
245                format!(
246                    "\x1b_ratty;g;r;id={};fmt={};source=payload;more={};{}\x1b\\",
247                    self.settings.id,
248                    self.settings.format.as_str(),
249                    more,
250                    chunk
251                )
252            });
253        }
254
255        if sequences.is_empty() {
256            sequences.push(format!(
257                "\x1b_ratty;g;r;id={};fmt={};source=payload;more=0;name={};normalize={};\x1b\\",
258                self.settings.id,
259                self.settings.format.as_str(),
260                name,
261                u8::from(self.settings.normalize),
262            ));
263        }
264
265        sequences
266    }
267
268    /// Writes the RGP register sequence to stdout.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if stdout cannot be written or flushed.
273    pub fn register(&self) -> io::Result<()> {
274        io::stdout().write_all(self.register_sequence().as_bytes())?;
275        io::stdout().flush()
276    }
277
278    /// Writes the RGP register sequences for a payload-backed asset to stdout.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error if stdout cannot be written or flushed.
283    pub fn register_payload(&self, bytes: &[u8]) -> io::Result<()> {
284        self.register_payload_with_name(bytes, None)
285    }
286
287    /// Writes the RGP register sequences for a payload-backed asset to stdout with an explicit source name.
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if stdout cannot be written or flushed.
292    pub fn register_payload_with_name(&self, bytes: &[u8], name: Option<&str>) -> io::Result<()> {
293        let mut stdout = io::stdout();
294        for sequence in self.register_payload_sequences_with_name(bytes, name) {
295            stdout.write_all(sequence.as_bytes())?;
296        }
297        stdout.flush()
298    }
299
300    /// Returns the RGP place sequence for an area.
301    pub fn place_sequence(&self, area: Rect) -> String {
302        let center_row = area.y.saturating_add(area.height.saturating_sub(1) / 2);
303        let center_col = area.x.saturating_add(area.width.saturating_sub(1) / 2);
304        format!(
305            "\x1b_ratty;g;p;id={};row={};col={};w={};h={};animate={};scale={};depth={};color={};brightness={};px={};py={};pz={};rx={};ry={};rz={};sx={};sy={};sz={}\x1b\\",
306            self.settings.id,
307            center_row,
308            center_col,
309            area.width.max(1),
310            area.height.max(1),
311            u8::from(self.settings.animate),
312            self.settings.scale,
313            self.settings.depth,
314            self.settings
315                .color
316                .map(|[r, g, b]| format!("{r:02x}{g:02x}{b:02x}"))
317                .unwrap_or_else(|| "ffffff".to_string()),
318            self.settings.brightness,
319            self.settings.offset[0],
320            self.settings.offset[1],
321            self.settings.offset[2],
322            self.settings.rotation[0],
323            self.settings.rotation[1],
324            self.settings.rotation[2],
325            self.settings.scale3[0],
326            self.settings.scale3[1],
327            self.settings.scale3[2],
328        )
329    }
330
331    /// Returns the RGP update sequence.
332    pub fn update_sequence(&self) -> String {
333        format!(
334            "\x1b_ratty;g;u;id={};animate={};scale={};depth={};color={};brightness={};px={};py={};pz={};rx={};ry={};rz={};sx={};sy={};sz={}\x1b\\",
335            self.settings.id,
336            u8::from(self.settings.animate),
337            self.settings.scale,
338            self.settings.depth,
339            self.settings
340                .color
341                .map(|[r, g, b]| format!("{r:02x}{g:02x}{b:02x}"))
342                .unwrap_or_else(|| "ffffff".to_string()),
343            self.settings.brightness,
344            self.settings.offset[0],
345            self.settings.offset[1],
346            self.settings.offset[2],
347            self.settings.rotation[0],
348            self.settings.rotation[1],
349            self.settings.rotation[2],
350            self.settings.scale3[0],
351            self.settings.scale3[1],
352            self.settings.scale3[2],
353        )
354    }
355
356    /// Returns the RGP delete sequence.
357    pub fn delete_sequence(&self) -> String {
358        format!("\x1b_ratty;g;d;id={}\x1b\\", self.settings.id)
359    }
360
361    /// Returns the RGP sequence that deletes every Ratty graphic object.
362    ///
363    /// This emits `d` without an `id`, which is intentionally broader than
364    /// [`Self::delete_sequence`]. Use it for demo cleanup or full-scene reset
365    /// flows where removing all currently registered RGP objects is expected.
366    pub fn delete_all_sequence() -> String {
367        "\x1b_ratty;g;d\x1b\\".to_string()
368    }
369
370    /// Writes the RGP delete sequence to stdout.
371    ///
372    /// # Errors
373    ///
374    /// Returns an error if stdout cannot be written or flushed.
375    pub fn clear(&self) -> io::Result<()> {
376        io::stdout().write_all(self.delete_sequence().as_bytes())?;
377        io::stdout().flush()
378    }
379
380    /// Deletes every Ratty graphic object.
381    ///
382    /// This writes the RGP delete-all sequence to stdout. It affects all RGP
383    /// objects currently known to Ratty, not only objects created by this
384    /// process.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if stdout cannot be written or flushed.
389    pub fn clear_all() -> io::Result<()> {
390        io::stdout().write_all(Self::delete_all_sequence().as_bytes())?;
391        io::stdout().flush()
392    }
393
394    /// Writes the RGP update sequence to stdout.
395    ///
396    /// # Errors
397    ///
398    /// Returns an error if stdout cannot be written or flushed.
399    pub fn update(&self) -> io::Result<()> {
400        io::stdout().write_all(self.update_sequence().as_bytes())?;
401        io::stdout().flush()
402    }
403}
404
405/// Renders the place sequence into a Ratatui buffer.
406impl Widget for &RattyGraphic<'_> {
407    fn render(self, area: Rect, buf: &mut Buffer) {
408        if area.is_empty() {
409            return;
410        }
411
412        let place = self.place_sequence(area);
413
414        if let Some(cell) = buf.cell_mut((area.x, area.y)) {
415            let existing = cell.symbol();
416            let mut symbol = String::with_capacity(place.len() + existing.len());
417            symbol.push_str(&place);
418            symbol.push_str(existing);
419            cell.set_symbol(&symbol);
420        }
421    }
422}