Skip to content

Commit f63dba4

Browse files
authored
feat: load familiar images at runtime (#6)
Verified locally before merge: cargo check -p claurst-tui. Rebuilt branch to remove personal committed images and load user-owned images from runtime paths.
1 parent ecdd1ab commit f63dba4

10 files changed

Lines changed: 167 additions & 8 deletions

File tree

assets/familiars/astra.jpg

-370 KB
Binary file not shown.

assets/familiars/charm.jpg

-399 KB
Binary file not shown.

assets/familiars/cody.jpg

-414 KB
Binary file not shown.

assets/familiars/echo.jpg

-383 KB
Binary file not shown.

assets/familiars/kitty.png

-834 KB
Binary file not shown.

assets/familiars/nova.jpg

-405 KB
Binary file not shown.

assets/familiars/sage.png

-858 KB
Binary file not shown.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// familiar_image.rs — Render familiar card images in image-capable terminals.
2+
//
3+
// Uses runtime file lookup so that images remain personal to each user and are
4+
// never embedded in the binary. Falls back gracefully to block-art glyphs when
5+
// no image is found or the terminal does not support inline graphics.
6+
7+
use crate::kitty_image::{detect_image_protocol, ImageProtocol};
8+
use base64::Engine;
9+
10+
/// Look up a user's familiar image at runtime from known paths.
11+
///
12+
/// Returns the first matching file path, or `None` if no image is found.
13+
/// Never panics; always degrades gracefully.
14+
pub fn familiar_image_path(familiar_id: &str) -> Option<std::path::PathBuf> {
15+
let extensions = ["png", "jpg", "jpeg", "webp"];
16+
let search_dirs: Vec<std::path::PathBuf> = [
17+
dirs::home_dir().map(|h| h.join(".coven").join("assets").join("familiars")),
18+
dirs::home_dir().map(|h| h.join(".coven-code").join("assets").join("familiars")),
19+
]
20+
.into_iter()
21+
.flatten()
22+
.collect();
23+
24+
for dir in &search_dirs {
25+
for ext in &extensions {
26+
let p = dir.join(format!("{}.{}", familiar_id, ext));
27+
if p.is_file() {
28+
return Some(p);
29+
}
30+
}
31+
}
32+
None
33+
}
34+
35+
/// Attempt to render the familiar card image as an inline terminal image sequence.
36+
///
37+
/// Returns `Some(escape_sequence_string)` when the terminal supports image
38+
/// rendering (Kitty or Sixel protocol detected) and an image file is found at
39+
/// runtime in `~/.coven/assets/familiars/` or `~/.coven-code/assets/familiars/`.
40+
/// Returns `None` when no image protocol is available or no file is found,
41+
/// so the caller can fall back to block-art glyphs.
42+
///
43+
/// `width_cells` and `height_cells` are hints for the rendered size (currently
44+
/// passed as Kitty `c=`/`r=` column/row counts when non-zero).
45+
pub fn render_familiar_image(
46+
familiar_id: &str,
47+
_width_cells: u16,
48+
_height_cells: u16,
49+
) -> Option<String> {
50+
let protocol = detect_image_protocol();
51+
if protocol == ImageProtocol::Text {
52+
return None;
53+
}
54+
55+
let path = familiar_image_path(familiar_id)?;
56+
let bytes = std::fs::read(&path).ok()?;
57+
58+
match protocol {
59+
ImageProtocol::Kitty => {
60+
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
61+
Some(build_kitty_sequence(&b64))
62+
}
63+
ImageProtocol::Sixel => build_sixel_sequence(&bytes),
64+
ImageProtocol::Text => None,
65+
}
66+
}
67+
68+
// ---------------------------------------------------------------------------
69+
// Kitty inline sequence builder (returns String instead of writing to stdout)
70+
// ---------------------------------------------------------------------------
71+
72+
/// Build a Kitty APC escape sequence string for a base64-encoded image payload.
73+
///
74+
/// We return the full sequence as a `String` so ratatui can embed it in a Span
75+
/// without writing directly to stdout, which would race with ratatui's own
76+
/// rendering pipeline.
77+
fn build_kitty_sequence(base64_data: &str) -> String {
78+
const CHUNK: usize = 4096;
79+
80+
let clean: String = base64_data
81+
.chars()
82+
.filter(|c| !c.is_whitespace())
83+
.collect();
84+
85+
let raw_len = clean.len();
86+
let total_chunks = (raw_len + CHUNK - 1).max(1) / CHUNK;
87+
88+
let mut out = String::with_capacity(raw_len + total_chunks * 32);
89+
90+
let mut offset = 0;
91+
let mut first = true;
92+
while offset < raw_len {
93+
let end = (offset + CHUNK).min(raw_len);
94+
let chunk = &clean[offset..end];
95+
let more: u8 = if end < raw_len { 1 } else { 0 };
96+
97+
let params = if first {
98+
format!("a=T,f=100,m={},q=2,C=1", more)
99+
} else {
100+
format!("a=T,m={},q=2", more)
101+
};
102+
103+
out.push_str("\x1b_G");
104+
out.push_str(&params);
105+
out.push(';');
106+
out.push_str(chunk);
107+
out.push_str("\x1b\\");
108+
109+
first = false;
110+
offset = end;
111+
}
112+
113+
out
114+
}
115+
116+
// ---------------------------------------------------------------------------
117+
// Sixel sequence builder
118+
// ---------------------------------------------------------------------------
119+
120+
/// Convert raw image bytes to a Sixel escape sequence string.
121+
///
122+
/// Returns `None` if any step of decoding or conversion fails.
123+
fn build_sixel_sequence(image_bytes: &[u8]) -> Option<String> {
124+
use icy_sixel::encoder::EncodeOptions;
125+
use image::ImageReader;
126+
use std::io::Cursor;
127+
128+
// Decode image
129+
let reader = ImageReader::new(Cursor::new(image_bytes))
130+
.with_guessed_format()
131+
.ok()?;
132+
let img = reader.decode().ok()?;
133+
let rgba = img.to_rgba8();
134+
let (width, height) = rgba.dimensions();
135+
let pixels = rgba.into_raw();
136+
137+
// Convert to Sixel
138+
let sixel_str = icy_sixel::encoder::sixel_encode(
139+
&pixels,
140+
width as usize,
141+
height as usize,
142+
&EncodeOptions::default(),
143+
)
144+
.ok()?;
145+
146+
// Wrap with Sixel DCS delimiters
147+
let mut out = String::with_capacity(sixel_str.len() + 8);
148+
out.push_str("\x1bPq");
149+
out.push_str(&sixel_str);
150+
out.push_str("\x1b\\");
151+
Some(out)
152+
}

src-rust/crates/tui/src/lib.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ pub mod context_viz;
4545
pub mod export_dialog;
4646
/// Clipboard image paste and Ctrl+V text paste.
4747
pub mod image_paste;
48-
/// Inline image rendering via the Kitty graphics protocol (with text fallback).
49-
pub mod kitty_image;
50-
/// Application state and main event loop.
51-
pub mod app;
48+
/// Inline image rendering via the Kitty graphics protocol (with text fallback).
49+
pub mod kitty_image;
50+
/// Familiar card image lookup and terminal escape rendering.
51+
pub mod familiar_image;
52+
/// Application state and main event loop.
53+
pub mod app;
5254
/// Input helpers: slash command parsing.
5355
pub mod input;
5456
/// All ratatui rendering logic.

src-rust/crates/tui/src/render.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::cell::RefCell;
55
use crate::agents_view::render_agents_menu;
66
use crate::context_viz::render_context_viz;
77
use crate::export_dialog::render_export_dialog;
8+
use crate::familiar_image;
89
use crate::app::{App, ContextMenuKind, SystemAnnotation, SystemMessageStyle, ToolStatus};
910
use crate::rustle::rustle_lines_for;
1011
use crate::diff_viewer::render_diff_dialog;
@@ -1540,10 +1541,14 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) {
15401541
app.rustle_walk_max.set(mascot_walk_max);
15411542
let walk_x = app.rustle_walk_x.clamp(0, mascot_walk_max) as usize;
15421543
let pad = " ".repeat(walk_x);
1543-
for cl in &rustle {
1544-
let mut spans = vec![Span::raw(pad.clone())];
1545-
spans.extend(cl.spans.iter().cloned());
1546-
left_lines.push(Line::from(spans));
1544+
if let Some(seq) = familiar_image::render_familiar_image(familiar_name, 11, 5) {
1545+
left_lines.push(Line::from(vec![Span::raw(pad.clone()), Span::raw(seq)]));
1546+
} else {
1547+
for cl in &rustle {
1548+
let mut spans = vec![Span::raw(pad.clone())];
1549+
spans.extend(cl.spans.iter().cloned());
1550+
left_lines.push(Line::from(spans));
1551+
}
15471552
}
15481553
frame.render_widget(Paragraph::new(left_lines).wrap(Wrap { trim: false }), h_chunks[0]);
15491554

0 commit comments

Comments
 (0)