1use std::path::{Path, PathBuf};
4use std::process::{Child, Command, Stdio};
5
6use crate::pet::{resize_rgba, PetFrame};
7use crate::preview::{PreviewLine, PreviewStyle};
8
9pub fn is_image_ext(ext: &str) -> bool {
12 matches!(
13 ext.to_ascii_lowercase().as_str(),
14 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico"
15 )
16}
17
18pub fn is_csv_ext(ext: &str) -> bool {
19 matches!(ext.to_ascii_lowercase().as_str(), "csv" | "tsv")
20}
21
22pub fn is_npy_ext(ext: &str) -> bool {
23 ext.eq_ignore_ascii_case("npy")
24}
25
26pub fn is_audio_ext(ext: &str) -> bool {
27 matches!(
28 ext.to_ascii_lowercase().as_str(),
29 "mp3" | "wav" | "flac" | "ogg" | "m4a" | "aac" | "aiff" | "wma" | "opus"
30 )
31}
32
33pub fn is_media_path(path: &Path) -> bool {
34 path.extension()
35 .and_then(|e| e.to_str())
36 .is_some_and(|e| {
37 is_image_ext(e) || is_csv_ext(e) || is_npy_ext(e) || is_audio_ext(e)
38 })
39}
40
41#[derive(Clone)]
44pub struct ImageAsset {
45 pub path: PathBuf,
46 pub src_w: u32,
47 pub src_h: u32,
48 pub rgba: Vec<u8>,
49 pub width_cells: u16,
51 pub cached_w: u32,
52 pub cached_h: u32,
53 pub cached_rgba: Vec<u8>,
54 pub cached_b64: String,
55 pub kitty_id: u32,
56}
57
58impl ImageAsset {
59 pub fn load(path: &Path, cell_px: u32) -> Result<Self, String> {
60 let data = std::fs::read(path).map_err(|e| e.to_string())?;
61 let img = image::load_from_memory(&data).map_err(|e| e.to_string())?;
62 let rgba = img.to_rgba8();
63 let (src_w, src_h) = rgba.dimensions();
64 let mut asset = Self {
65 path: path.to_path_buf(),
66 src_w,
67 src_h,
68 rgba: rgba.into_raw(),
69 width_cells: 48,
70 cached_w: 0,
71 cached_h: 0,
72 cached_rgba: Vec::new(),
73 cached_b64: String::new(),
74 kitty_id: 88,
75 };
76 asset.rebuild_cache(cell_px);
77 Ok(asset)
78 }
79
80 pub fn adjust_width(&mut self, delta: i16, cell_px: u32) {
81 let w = self.width_cells as i16 + delta;
82 self.width_cells = w.clamp(8, 120) as u16;
83 self.rebuild_cache(cell_px);
84 }
85
86 pub fn rebuild_cache(&mut self, cell_px: u32) {
87 let cell_px = cell_px.max(8);
88 let tw = (self.width_cells as u32).saturating_mul(cell_px).max(8);
89 let th = if self.src_w == 0 {
90 tw
91 } else {
92 (tw as u64 * self.src_h as u64 / self.src_w as u64).max(1) as u32
93 };
94 let frame = PetFrame {
95 width: self.src_w,
96 height: self.src_h,
97 rgba: self.rgba.clone(),
98 delay: std::time::Duration::from_secs(1),
99 };
100 let out = resize_rgba(&frame, tw, th);
101 self.cached_b64 = crate::pet::encode_b64_public(&out);
102 self.cached_rgba = out;
103 self.cached_w = tw;
104 self.cached_h = th;
105 }
106}
107
108pub fn render_csv(text: &str, tsv: bool) -> Vec<PreviewLine> {
114 let sep = if tsv { '\t' } else { ',' };
115 let mut out = Vec::new();
116 out.push(pl(
117 vec![(
118 format!(" CSV/TSV table · sep={sep:?}"),
119 PreviewStyle::Dim,
120 )],
121 ));
122 out.push(pl(vec![("".into(), PreviewStyle::Normal)]));
123
124 const MAX_COLS: usize = 12;
126 const MAX_CELL_W: usize = 24;
127 let mut header: Vec<String> = Vec::new();
128 let mut rows: Vec<Vec<String>> = Vec::new();
129 for (i, line) in text.lines().take(200).enumerate() {
130 let mut cols = split_csv_line(line, sep);
131 cols.truncate(MAX_COLS);
132 if i == 0 {
133 header = cols;
134 } else {
135 rows.push(cols);
136 }
137 }
138 let ncols = header
139 .len()
140 .max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
141 let cell_w = |s: &str| -> usize {
142 s.chars()
143 .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(1))
144 .sum()
145 };
146 let mut widths = vec![0usize; ncols];
147 for (c, w) in widths.iter_mut().enumerate() {
148 *w = std::iter::once(&header)
149 .chain(rows.iter())
150 .filter_map(|r| r.get(c))
151 .map(|s| cell_w(s).min(MAX_CELL_W))
152 .max()
153 .unwrap_or(1)
154 .max(1);
155 }
156 let fmt_row = |row: &[String]| -> String {
157 let mut s = String::from(" ");
158 for (c, w) in widths.iter().enumerate() {
159 let cell = row.get(c).map(|s| s.as_str()).unwrap_or("");
160 let mut taken = String::new();
162 let mut used = 0usize;
163 for ch in cell.chars() {
164 let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
165 if used + cw > *w {
166 if used < *w {
167 taken.push('…');
168 used += 1;
169 }
170 break;
171 }
172 taken.push(ch);
173 used += cw;
174 }
175 s.push_str(&taken);
176 s.push_str(&" ".repeat(w.saturating_sub(used)));
177 if c + 1 < widths.len() {
178 s.push_str(" │ ");
179 }
180 }
181 s
182 };
183 if !header.is_empty() {
184 out.push(pl(vec![(fmt_row(&header), PreviewStyle::H3)]));
185 let rule: usize = widths.iter().sum::<usize>() + widths.len().saturating_sub(1) * 3;
186 out.push(pl(vec![(
187 format!(" {}", "─".repeat(rule.clamp(8, 200))),
188 PreviewStyle::Hr,
189 )]));
190 }
191 for (ri, row) in rows.iter().enumerate() {
192 let style = if ri % 2 == 0 {
193 PreviewStyle::Normal
194 } else {
195 PreviewStyle::Dim
196 };
197 out.push(pl(vec![(fmt_row(row), style)]));
198 }
199 if text.lines().count() > 200 {
200 out.push(pl(vec![(
201 " … truncated (200 rows)".into(),
202 PreviewStyle::Dim,
203 )]));
204 }
205 if out.len() <= 2 {
206 out.push(pl(vec![("(empty)".into(), PreviewStyle::Dim)]));
207 }
208 out
209}
210
211fn split_csv_line(line: &str, sep: char) -> Vec<String> {
212 let mut out = Vec::new();
214 let mut cur = String::new();
215 let mut in_q = false;
216 let mut chars = line.chars().peekable();
217 while let Some(c) = chars.next() {
218 if c == '"' {
219 if in_q && chars.peek() == Some(&'"') {
220 cur.push('"');
221 chars.next();
222 } else {
223 in_q = !in_q;
224 }
225 } else if c == sep && !in_q {
226 out.push(std::mem::take(&mut cur));
227 } else {
228 cur.push(c);
229 }
230 }
231 out.push(cur);
232 out
233}
234
235pub fn render_npy(path: &Path) -> Result<Vec<PreviewLine>, String> {
238 let data = std::fs::read(path).map_err(|e| e.to_string())?;
239 if data.len() < 10 || &data[0..6] != b"\x93NUMPY" {
240 return Err("not a .npy file".into());
241 }
242 let major = data[6];
243 let _minor = data[7];
244 let (hdr_len, hdr_start) = if major == 1 {
245 if data.len() < 10 {
246 return Err("truncated npy".into());
247 }
248 let len = u16::from_le_bytes([data[8], data[9]]) as usize;
249 (len, 10usize)
250 } else {
251 if data.len() < 12 {
252 return Err("truncated npy".into());
253 }
254 let len = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
255 (len, 12usize)
256 };
257 let hdr_end = hdr_start + hdr_len;
258 if data.len() < hdr_end {
259 return Err("truncated npy header".into());
260 }
261 let header = String::from_utf8_lossy(&data[hdr_start..hdr_end]).to_string();
262 let descr = npy_field(&header, "descr").unwrap_or_else(|| "?".into());
263 let fortran = npy_field(&header, "fortran_order").unwrap_or_else(|| "False".into());
264 let shape_s = npy_field(&header, "shape").unwrap_or_else(|| "()".into());
265 let payload = &data[hdr_end..];
266
267 let mut out = Vec::new();
268 out.push(pl(vec![(" NumPy .npy".into(), PreviewStyle::H2)]));
269 out.push(pl(vec![(format!(" dtype {descr}"), PreviewStyle::Code)]));
270 out.push(pl(vec![(format!(" shape {shape_s}"), PreviewStyle::Code)]));
271 out.push(pl(vec![(
272 format!(" fortran {fortran} · payload {} bytes", payload.len()),
273 PreviewStyle::Dim,
274 )]));
275 out.push(pl(vec![("".into(), PreviewStyle::Normal)]));
276
277 let sample = sample_npy_values(payload, &descr, 48);
279 if sample.is_empty() {
280 out.push(pl(vec![(
281 " (binary payload — no numeric sample)".into(),
282 PreviewStyle::Dim,
283 )]));
284 } else {
285 out.push(pl(vec![(" values (sample)".into(), PreviewStyle::H4)]));
286 for chunk in sample.chunks(6) {
287 let line = chunk.join(" ");
288 out.push(pl(vec![(format!(" {line}"), PreviewStyle::JsonNumber)]));
289 }
290 }
291 Ok(out)
292}
293
294fn npy_field(header: &str, key: &str) -> Option<String> {
295 let pat = format!("'{key}':");
297 let i = header.find(&pat)?;
298 let rest = header[i + pat.len()..].trim_start();
299 if rest.starts_with('\'') {
300 let rest = &rest[1..];
301 let end = rest.find('\'')?;
302 return Some(rest[..end].to_string());
303 }
304 if rest.starts_with('"') {
305 let rest = &rest[1..];
306 let end = rest.find('"')?;
307 return Some(rest[..end].to_string());
308 }
309 if rest.starts_with('(') {
312 let end = rest.find(')')?;
313 return Some(rest[..=end].to_string());
314 }
315 let end = rest
317 .find(',')
318 .or_else(|| rest.find('}'))
319 .unwrap_or(rest.len());
320 Some(rest[..end].trim().to_string())
321}
322
323fn sample_npy_values(payload: &[u8], descr: &str, n: usize) -> Vec<String> {
324 let d = descr.trim();
325 let is_le = d.starts_with('<') || d.starts_with('|') || !d.starts_with('>');
327 let type_ch = d.chars().find(|c| c.is_ascii_alphabetic()).unwrap_or('f');
328 let size: usize = d
329 .chars()
330 .filter(|c| c.is_ascii_digit())
331 .collect::<String>()
332 .parse()
333 .unwrap_or(4);
334
335 let mut out = Vec::new();
336 let mut off = 0;
337 while out.len() < n && off + size <= payload.len() {
338 let chunk = &payload[off..off + size];
339 let s = match (type_ch, size) {
340 ('f', 4) => {
341 let mut b = [0u8; 4];
342 b.copy_from_slice(chunk);
343 let v = if is_le {
344 f32::from_le_bytes(b)
345 } else {
346 f32::from_be_bytes(b)
347 };
348 format!("{v:.4}")
349 }
350 ('f', 8) => {
351 let mut b = [0u8; 8];
352 b.copy_from_slice(chunk);
353 let v = if is_le {
354 f64::from_le_bytes(b)
355 } else {
356 f64::from_be_bytes(b)
357 };
358 format!("{v:.4}")
359 }
360 ('i', 1) => format!("{}", chunk[0] as i8),
361 ('i', 2) => {
362 let mut b = [0u8; 2];
363 b.copy_from_slice(chunk);
364 let v = if is_le {
365 i16::from_le_bytes(b)
366 } else {
367 i16::from_be_bytes(b)
368 };
369 format!("{v}")
370 }
371 ('i', 4) => {
372 let mut b = [0u8; 4];
373 b.copy_from_slice(chunk);
374 let v = if is_le {
375 i32::from_le_bytes(b)
376 } else {
377 i32::from_be_bytes(b)
378 };
379 format!("{v}")
380 }
381 ('i', 8) => {
382 let mut b = [0u8; 8];
383 b.copy_from_slice(chunk);
384 let v = if is_le {
385 i64::from_le_bytes(b)
386 } else {
387 i64::from_be_bytes(b)
388 };
389 format!("{v}")
390 }
391 ('u', 1) => format!("{}", chunk[0]),
392 _ => format!("{:02x?}", &chunk[..size.min(4)]),
393 };
394 out.push(s);
395 off += size;
396 }
397 out
398}
399
400pub struct AudioPlayer {
403 pub path: PathBuf,
404 child: Option<Child>,
405}
406
407impl AudioPlayer {
408 pub fn new(path: PathBuf) -> Self {
409 Self { path, child: None }
410 }
411
412 pub fn playing(&mut self) -> bool {
413 if let Some(child) = self.child.as_mut() {
414 match child.try_wait() {
415 Ok(Some(_)) => {
416 self.child = None;
417 false
418 }
419 Ok(None) => true,
420 Err(_) => {
421 self.child = None;
422 false
423 }
424 }
425 } else {
426 false
427 }
428 }
429
430 pub fn toggle(&mut self) -> Result<String, String> {
431 if self.playing() {
432 self.stop();
433 return Ok("Audio stopped".into());
434 }
435 self.play()
436 }
437
438 pub fn play(&mut self) -> Result<String, String> {
439 self.stop();
440 let path = self.path.display().to_string();
441 let child = if cfg!(target_os = "macos") {
443 Command::new("afplay")
444 .arg(&path)
445 .stdout(Stdio::null())
446 .stderr(Stdio::null())
447 .spawn()
448 } else if cfg!(target_os = "windows") {
449 Command::new("ffplay")
451 .args(["-nodisp", "-autoexit", "-loglevel", "quiet", &path])
452 .stdout(Stdio::null())
453 .stderr(Stdio::null())
454 .spawn()
455 .or_else(|_| {
456 Command::new("mpv")
457 .args(["--no-video", "--really-quiet", &path])
458 .stdout(Stdio::null())
459 .stderr(Stdio::null())
460 .spawn()
461 })
462 } else {
463 Command::new("ffplay")
464 .args(["-nodisp", "-autoexit", "-loglevel", "quiet", &path])
465 .stdout(Stdio::null())
466 .stderr(Stdio::null())
467 .spawn()
468 .or_else(|_| {
469 Command::new("mpv")
470 .args(["--no-video", "--really-quiet", &path])
471 .stdout(Stdio::null())
472 .stderr(Stdio::null())
473 .spawn()
474 })
475 .or_else(|_| {
476 Command::new("aplay")
477 .arg(&path)
478 .stdout(Stdio::null())
479 .stderr(Stdio::null())
480 .spawn()
481 })
482 }
483 .map_err(|e| {
484 format!("cannot play audio ({e}) — install afplay/ffplay/mpv")
485 })?;
486 self.child = Some(child);
487 Ok(format!("Playing {}", self.path.display()))
488 }
489
490 pub fn stop(&mut self) {
491 if let Some(mut c) = self.child.take() {
492 let _ = c.kill();
493 let _ = c.wait();
494 }
495 }
496}
497
498impl Drop for AudioPlayer {
499 fn drop(&mut self) {
500 self.stop();
501 }
502}
503
504pub fn audio_info_lines(path: &Path, playing: bool) -> Vec<PreviewLine> {
505 let name = path
506 .file_name()
507 .and_then(|n| n.to_str())
508 .unwrap_or("audio");
509 let status = if playing { "▶ playing" } else { "■ stopped" };
510 vec![
511 pl(vec![(" Audio".into(), PreviewStyle::H2)]),
512 pl(vec![(format!(" {name}"), PreviewStyle::Normal)]),
513 pl(vec![(format!(" {status}"), PreviewStyle::Code)]),
514 pl(vec![("".into(), PreviewStyle::Normal)]),
515 pl(vec![(
516 " Space play / stop".into(),
517 PreviewStyle::Dim,
518 )]),
519 pl(vec![(
520 " Esc close preview".into(),
521 PreviewStyle::Dim,
522 )]),
523 pl(vec![(
524 " (uses afplay / ffplay / mpv)".into(),
525 PreviewStyle::Dim,
526 )]),
527 ]
528}
529
530fn pl(spans: Vec<(String, PreviewStyle)>) -> PreviewLine {
531 PreviewLine { spans, image: None }
532}