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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ObjectFormat {
14 Obj,
16 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#[derive(Debug, Clone)]
50pub struct RattyGraphicSettings<'a> {
51 pub id: u32,
53 pub path: Cow<'a, str>,
55 pub format: ObjectFormat,
57 pub animate: bool,
59 pub scale: f32,
61 pub depth: f32,
63 pub color: Option<[u8; 3]>,
65 pub brightness: f32,
67 pub offset: [f32; 3],
69 pub rotation: [f32; 3],
71 pub scale3: [f32; 3],
73}
74
75impl<'a> RattyGraphicSettings<'a> {
76 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 pub fn id(mut self, id: u32) -> Self {
96 self.id = id;
97 self
98 }
99
100 pub fn format(mut self, format: ObjectFormat) -> Self {
102 self.format = format;
103 self
104 }
105
106 pub fn animate(mut self, animate: bool) -> Self {
108 self.animate = animate;
109 self
110 }
111
112 pub fn scale(mut self, scale: f32) -> Self {
114 self.scale = scale;
115 self
116 }
117
118 pub fn depth(mut self, depth: f32) -> Self {
120 self.depth = depth;
121 self
122 }
123
124 pub fn color(mut self, color: [u8; 3]) -> Self {
126 self.color = Some(color);
127 self
128 }
129
130 pub fn brightness(mut self, brightness: f32) -> Self {
132 self.brightness = brightness;
133 self
134 }
135
136 pub fn offset(mut self, offset: [f32; 3]) -> Self {
138 self.offset = offset;
139 self
140 }
141
142 pub fn rotation(mut self, rotation: [f32; 3]) -> Self {
144 self.rotation = rotation;
145 self
146 }
147
148 pub fn scale3(mut self, scale3: [f32; 3]) -> Self {
150 self.scale3 = scale3;
151 self
152 }
153}
154
155pub struct RattyGraphic<'a> {
157 settings: RattyGraphicSettings<'a>,
158}
159
160impl<'a> RattyGraphic<'a> {
161 pub fn new(settings: RattyGraphicSettings<'a>) -> Self {
163 Self { settings }
164 }
165
166 pub fn settings(&self) -> &RattyGraphicSettings<'a> {
168 &self.settings
169 }
170
171 pub fn settings_mut(&mut self) -> &mut RattyGraphicSettings<'a> {
173 &mut self.settings
174 }
175
176 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 pub fn register_payload_sequences(&self, bytes: &[u8]) -> Vec<String> {
188 self.register_payload_sequences_with_name(bytes, None)
189 }
190
191 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 pub fn register(&self) -> io::Result<()> {
248 io::stdout().write_all(self.register_sequence().as_bytes())?;
249 io::stdout().flush()
250 }
251
252 pub fn register_payload(&self, bytes: &[u8]) -> io::Result<()> {
258 self.register_payload_with_name(bytes, None)
259 }
260
261 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 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 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 pub fn delete_sequence(&self) -> String {
332 format!("\x1b_ratty;g;d;id={}\x1b\\", self.settings.id)
333 }
334
335 pub fn clear(&self) -> io::Result<()> {
341 io::stdout().write_all(self.delete_sequence().as_bytes())?;
342 io::stdout().flush()
343 }
344
345 pub fn update(&self) -> io::Result<()> {
351 io::stdout().write_all(self.update_sequence().as_bytes())?;
352 io::stdout().flush()
353 }
354}
355
356impl 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}