Skip to main content

xei_core/
media.rs

1//! Media helpers for explorer/preview: images, CSV/NPY tables, audio playback.
2
3use std::path::{Path, PathBuf};
4use std::process::{Child, Command, Stdio};
5
6use crate::pet::{resize_rgba, PetFrame};
7use crate::preview::{PreviewLine, PreviewStyle};
8
9// ── Classification ──────────────────────────────────────────────────────
10
11pub 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// ── Image ───────────────────────────────────────────────────────────────
42
43#[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    /// Display width in terminal cells (arrow keys adjust).
50    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
108// Expose base64 from pet for media (or duplicate) — add pub fn on pet
109// We'll add encode_b64_public to pet.rs
110
111// ── CSV ─────────────────────────────────────────────────────────────────
112
113pub 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    // Parse first, then size columns so the table actually lines up.
125    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            // Clip to the column budget, then pad to it (width-aware).
161            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    // Minimal CSV: honor quotes for commas
213    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
235// ── NPY (NumPy) ─────────────────────────────────────────────────────────
236
237pub 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    // Pretty sample of values
278    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    // header is a python dict-like string: {'descr': '<f8', 'fortran_order': False, 'shape': (2, 3), }
296    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    // tuple — take through the closing paren (a bare `,` split would cut
310    // `(2, 3)` down to `(2`)
311    if rest.starts_with('(') {
312        let end = rest.find(')')?;
313        return Some(rest[..=end].to_string());
314    }
315    // bare True/False
316    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    // e.g. <f8, >f4, <i4, |u1
326    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
400// ── Audio ───────────────────────────────────────────────────────────────
401
402pub 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        // Prefer platform players; no extra crates.
442        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            // powershell SoundPlayer is async-awkward; try ffplay/mpv
450            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}