diff --git a/docs/familiars.md b/docs/familiars.md index 8fd6034..feabbbe 100644 --- a/docs/familiars.md +++ b/docs/familiars.md @@ -247,23 +247,43 @@ Or check the [Coven documentation](https://opencoven.ai/docs) for installation i --- -## Familiar glyphs in the TUI +## Familiar cards in the TUI -Each familiar has a dedicated pixel-art glyph rendered in the welcome panel. The active familiar (set via `settings.json` → `"familiar"`) determines which glyph is shown. The glyph animates — it blinks, shifts pose when loading, and walks left/right across the panel. +Every familiar — built-in or user-defined — renders as a **static themed card** in three places: -Built-in glyphs: +1. The **welcome panel** (top-left of the home screen): glyph, name, access tier dot, and on wider terminals the role and an accent rule. +2. The **F2 switcher popup**: one row per familiar, each painted in that familiar's accent palette with a coloured tier dot. +3. The **`/agents` detail view**: the card appears above the persona preview when you select a familiar-sourced agent. -| ID | Concept | -|---|---| -| `kitty` | Cat head — ears, whiskers, square eyes (default) | -| `nova` | 4-point star with orbiting sparks | -| `cody` | Robot face — antenna, bracket eyes | -| `charm` | Heart with sparkle dots | -| `sage` | Wizard hat + star + open book | -| `astra` | Crescent moon + compass star + orbit | -| `echo` | Round ghost + mirror eyes + echo dots | +The glyph itself does **not** animate. The only motion is a quarter-block eye spinner that kicks in when the assistant has gone quiet for ~3 seconds, so you still get a "thinking" signal without a walking mascot pulling attention from the work area. + +Cards adapt to available room: + +- **Compact** (narrow terminals): glyph only, no border. +- **Standard** (default): glyph + name + tier dot inside a rounded border. +- **Large** (wide terminals): adds the role line and an accent rule under the glyph. + +### Built-in glyphs + +| ID | Concept | Accent | +|---|---|---| +| `kitty` | Cat head — ears, whiskers, square eyes (default) | violet | +| `nova` | Crowned sorceress with star sparkles | gold | +| `cody` | Robot face — antenna, bracket eyes, code body | cyan | +| `charm` | Heart with sparkle dots | pink | +| `sage` | Wizard hat + star + open book | emerald | +| `astra` | Crescent moon + compass star + orbit | indigo | +| `echo` | Round ghost + bracket eyes + echo dots | teal | -To change the displayed glyph, set `familiar` in your settings: +### User-defined familiars + +Any familiar declared in `~/.coven/familiars.toml` automatically gets a procedurally-generated card. The accent palette and sigil frame (crystal, hexagon, rune, or seal) are picked deterministically from the familiar's `id`, so the same familiar looks the same across sessions and machines without storing extra config. The familiar's `emoji` is rendered inside the frame. + +If you want a hand-crafted image instead of the procedural sigil, drop a PNG/JPG/WebP at `~/.coven/assets/familiars/.`. When the terminal supports Kitty or Sixel inline graphics, that image takes precedence over the card. + +### Changing the displayed glyph + +Set `familiar` in your settings: ```json { @@ -277,6 +297,8 @@ Or run: coven-code config set familiar nova ``` +You can also press **F2** at any time to open the switcher popup and pick a familiar interactively. + --- ## See also diff --git a/src-rust/crates/tui/src/agents_view.rs b/src-rust/crates/tui/src/agents_view.rs index f87bc42..ded8e6c 100644 --- a/src-rust/crates/tui/src/agents_view.rs +++ b/src-rust/crates/tui/src/agents_view.rs @@ -15,6 +15,8 @@ use std::time::{Duration, Instant}; use claurst_core::coven_shared; +use crate::familiar_card::{self, CardSize}; +use crate::familiar_theme; use crate::overlays::{ begin_modal_buf, modal_header_line_area, render_modal_title_buf, COVEN_CODE_ACCENT, COVEN_CODE_MUTED, COVEN_CODE_PANEL_BG, COVEN_CODE_TEXT, @@ -1013,6 +1015,21 @@ fn render_agent_detail(def: &AgentDefinition, area: Rect, buf: &mut Buffer) { let mut lines = Vec::new(); let is_familiar = def.source.starts_with("coven:familiar"); + // For familiar-sourced agents, render the themed card at the top of the + // detail panel so the user sees the same visual identity they pick from + // F2 or the welcome screen. We resolve from the daemon's familiar list + // so user-defined entries get a procedural card instead of a fallback. + if is_familiar { + if let Some(id) = def.source.strip_prefix("coven:familiar:") { + let daemon = coven_shared::load_familiars().unwrap_or_default(); + let theme = familiar_theme::resolve(id, &daemon); + for line in familiar_card::render_card(&theme, CardSize::Standard, None) { + lines.push(line); + } + lines.push(Line::default()); + } + } + // Source badge — colour-coded for familiar vs user. let source_style = if is_familiar { Style::default().fg(Color::Rgb(139, 92, 246)).add_modifier(Modifier::BOLD) diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 464f757..15aae41 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -772,21 +772,10 @@ pub struct App { /// Instant the session started (used for elapsed-time in the status bar). pub session_start: std::time::Instant, - /// Current Rune pose for rendering (updated each frame). + /// Current familiar pose for rendering. `Static` when idle; `Loading { + /// frame }` while streaming has stalled long enough to surface a spinner. + /// The glyph itself never walks or blinks. pub rustle_current_pose: crate::rustle::RustlePose, - /// Temporary Rune pose override (e.g. look-down on Tab). Reverts to - /// default after this instant passes. - pub rustle_pose_until: Option, - /// The temporary pose to show until `rustle_pose_until`. - pub rustle_temp_pose: Option, - /// Frame counter at which the next random eye-shift should fire. - pub rustle_next_blink: u64, - /// Horizontal walk position of the mascot in the welcome panel (0-based column offset). - pub rustle_walk_x: i32, - /// Walk direction: +1 = right, -1 = left. - pub rustle_walk_dir: i32, - /// Maximum walk offset (in columns) — set each render frame based on available width. - pub rustle_walk_max: Cell, /// Instant the current turn's streaming began (reset each time streaming starts). pub turn_start: Option, /// Elapsed time string for the last completed turn, e.g. "2m 5s". @@ -1291,16 +1280,7 @@ impl App { new_messages_while_scrolled: 0, token_warning_threshold_shown: 0, session_start: std::time::Instant::now(), - rustle_current_pose: crate::rustle::RustlePose::Default, - rustle_pose_until: None, - rustle_temp_pose: None, - rustle_next_blink: 200 + (std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .subsec_nanos() as u64 % 300), - rustle_walk_x: 0, - rustle_walk_dir: 1, - rustle_walk_max: Cell::new(0), + rustle_current_pose: crate::rustle::RustlePose::Static, turn_start: None, last_turn_elapsed: None, last_turn_verb: None, @@ -1878,77 +1858,27 @@ impl App { None } - /// and the loading spinner on stalls/errors. - /// Call once per frame before rendering. + /// Update the familiar pose for this render frame. + /// + /// The glyph itself is static — this just toggles between `Static` and + /// `Loading { frame }` so the eye-spinner kicks in when the assistant has + /// gone quiet for 3+ seconds. Call once per frame before rendering. pub fn tick_rustle_pose(&mut self) { - // Loading spinner: shown when streaming has stalled (no data for 3s+). - if self.is_streaming { - if let Some(start) = self.stall_start { - if start.elapsed() > std::time::Duration::from_secs(3) { - self.rustle_current_pose = crate::rustle::RustlePose::Loading { - frame: self.frame_count, - }; - return; - } - } - } - - // Check if a temporary pose is active. - if let Some(until) = self.rustle_pose_until { - if std::time::Instant::now() < until { - self.rustle_current_pose = self.rustle_temp_pose.clone() - .unwrap_or(crate::rustle::RustlePose::Default); - return; - } - // Expired — clear it. - self.rustle_pose_until = None; - self.rustle_temp_pose = None; - } - - // Random eye-shift: every ~200-500 frames, briefly look right. - if self.frame_count >= self.rustle_next_blink { - self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookRight); - self.rustle_pose_until = Some( - std::time::Instant::now() + std::time::Duration::from_millis(800) - ); - // Schedule next blink 200-500 frames from now (random-ish). - let jitter = (self.frame_count.wrapping_mul(7) % 300) + 200; - self.rustle_next_blink = self.frame_count + jitter; - self.rustle_current_pose = crate::rustle::RustlePose::LookRight; - return; - } - - self.rustle_current_pose = crate::rustle::RustlePose::Default; - - // Advance walk position every 8 frames (slow pace). - if self.frame_count % 8 == 0 { - self.rustle_walk_x += self.rustle_walk_dir; - let walk_max = self.rustle_walk_max.get(); - if self.rustle_walk_x >= walk_max { - self.rustle_walk_x = walk_max; - self.rustle_walk_dir = -1; - self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookLeft); - self.rustle_pose_until = Some( - std::time::Instant::now() + std::time::Duration::from_millis(300) - ); - } else if self.rustle_walk_x <= 0 { - self.rustle_walk_x = 0; - self.rustle_walk_dir = 1; - self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookRight); - self.rustle_pose_until = Some( - std::time::Instant::now() + std::time::Duration::from_millis(300) - ); - } - } + let stalled = self.is_streaming + && self + .stall_start + .map(|s| s.elapsed() > std::time::Duration::from_secs(3)) + .unwrap_or(false); + self.rustle_current_pose = if stalled { + crate::rustle::RustlePose::Loading { frame: self.frame_count } + } else { + crate::rustle::RustlePose::Static + }; } - /// Trigger Rune looking down briefly (called on Tab / mode switch). - pub fn rustle_look_down(&mut self) { - self.rustle_temp_pose = Some(crate::rustle::RustlePose::LookDown); - self.rustle_pose_until = Some( - std::time::Instant::now() + std::time::Duration::from_secs(1) - ); - } + /// No-op retained for callsites left over from the animated era (Tab / + /// mode-switch handlers). The static glyph has no look-down pose. + pub fn rustle_look_down(&mut self) {} /// Cycle to the next agent mode: build → plan → explore → build. /// Sets `agent_mode_changed` so the main loop can update the query config diff --git a/src-rust/crates/tui/src/familiar_card.rs b/src-rust/crates/tui/src/familiar_card.rs new file mode 100644 index 0000000..e90d485 --- /dev/null +++ b/src-rust/crates/tui/src/familiar_card.rs @@ -0,0 +1,386 @@ +//! Static themed familiar card composer. +//! +//! Given a [`crate::familiar_theme::FamiliarTheme`] and a [`CardSize`], produces +//! the lines that render the active familiar in the welcome panel, the F2 +//! switcher, and the `/agents` detail view. The glyph itself never animates; +//! only the eye row spins while the assistant is in the `Loading` state, so +//! the user still has a signal that work is in progress. +//! +//! Built-in archetypes dispatch to the pixel-art builders in +//! [`crate::rustle`]. Procedural archetypes ([`Archetype::SigilCrystal`] etc.) +//! draw a colored frame around the familiar's emoji so any user-defined entry +//! from `~/.coven/familiars.toml` gets first-class visual identity. + +use crate::familiar_theme::{Archetype, FamiliarPalette, FamiliarTheme}; +use crate::rustle::{archetype_lines, RustlePose}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +/// Card size selection. Welcome panel picks via [`pick_size`]; F2 and +/// `/agents` pin to a fixed size that matches their available room. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardSize { + /// Glyph only, no border, no text. Used when the host column is narrow. + Compact, + /// Bordered card with name + access tier dot in the title. + Standard, + /// Standard + role line + decorative accent rule. + Large, +} + +/// Pick a card size from the available column width. +/// +/// Thresholds tuned for the welcome panel where `left_w` is clamped to 22-32: +/// most installs land in Standard, terminals with wide welcome panels see Large, +/// only very narrow terminals fall back to Compact. +pub fn pick_size(width: u16) -> CardSize { + if width < 20 { + CardSize::Compact + } else if width < 28 { + CardSize::Standard + } else { + CardSize::Large + } +} + +/// Render the full card. `loading` is `Some(frame_count)` to spin the eyes, +/// `None` for the resting state. +pub fn render_card(theme: &FamiliarTheme, size: CardSize, loading: Option) -> Vec> { + let pose = match loading { + Some(frame) => RustlePose::Loading { frame }, + None => RustlePose::Static, + }; + let glyph = glyph_lines(theme, &pose); + + match size { + CardSize::Compact => glyph_only(glyph), + CardSize::Standard => bordered(theme, glyph, false), + CardSize::Large => bordered(theme, glyph, true), + } +} + +/// One-line preview used in the F2 switcher list. +/// +/// Format: ` [glyph-token] name · tier `, painted in the familiar's palette. +/// `width` is the popup interior column count; the row is left-trimmed to +/// fit without wrapping. +pub fn render_mini_row(theme: &FamiliarTheme, width: u16) -> Line<'static> { + let primary = Style::default().fg(theme.palette.primary).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(Color::Rgb(148, 163, 184)); + let dot = Span::styled("\u{25cf}", Style::default().fg(theme.access_color())); + let mut spans = vec![ + Span::raw(" "), + Span::styled(theme.emoji.clone(), Style::default()), + Span::raw(" "), + Span::styled(theme.display_name.clone(), primary), + ]; + if let Some(role) = &theme.role { + spans.push(Span::raw(" ")); + spans.push(Span::styled(truncate(role, (width as usize).saturating_sub(theme.display_name.len() + 10)), muted)); + } + spans.push(Span::raw(" ")); + spans.push(dot); + spans.push(Span::raw(" ")); + spans.push(Span::styled(short_tier(&theme.access).to_string(), muted)); + Line::from(spans) +} + +// ── Layout helpers ─────────────────────────────────────────────────────────── + +fn glyph_only(glyph: Vec>) -> Vec> { + glyph +} + +fn bordered(theme: &FamiliarTheme, glyph: Vec>, include_role: bool) -> Vec> { + let primary = theme.palette.primary; + let inner_w = 22u16; // 11-wide glyph + 2 pad + ~9 right margin + + let mut out = Vec::with_capacity(glyph.len() + 5); + + // Top border with the name + tier dot as a title. + out.push(title_line(theme, primary, inner_w)); + + for line in glyph { + out.push(wrap_line(line, primary, inner_w)); + } + + if include_role { + out.push(blank_line(primary, inner_w)); + if let Some(role) = &theme.role { + out.push(role_line(role, theme, primary, inner_w)); + } + out.push(rule_line(primary, inner_w)); + } + + out.push(access_line(theme, primary, inner_w)); + out.push(bottom_border(primary, inner_w)); + out +} + +fn title_line(theme: &FamiliarTheme, primary: Color, inner_w: u16) -> Line<'static> { + let dot = Span::styled("\u{25cf}", Style::default().fg(theme.access_color())); + let name = Span::styled( + theme.display_name.clone(), + Style::default().fg(primary).add_modifier(Modifier::BOLD), + ); + let title_prefix = Span::styled("\u{256d} ".to_string(), Style::default().fg(primary)); + let title_gap = Span::raw(" "); + // Approx title width: "╭ " (2) + dot (1) + " " + name + " ". Pad fill to inner_w. + let used = 2 + 1 + 1 + theme.display_name.chars().count() + 1; + let fill = (inner_w as usize).saturating_sub(used) + 1; // +1 to land on the corner + let fill_str = "\u{2500}".repeat(fill); + let suffix = Span::styled(format!("{}\u{256e}", fill_str), Style::default().fg(primary)); + Line::from(vec![title_prefix, dot, Span::raw(" "), name, title_gap, suffix]) +} + +fn bottom_border(primary: Color, inner_w: u16) -> Line<'static> { + let mid = "\u{2500}".repeat(inner_w as usize); + Line::from(Span::styled( + format!("\u{2570}{}\u{256f}", mid), + Style::default().fg(primary), + )) +} + +fn wrap_line(content: Line<'static>, primary: Color, inner_w: u16) -> Line<'static> { + let mut spans = Vec::with_capacity(content.spans.len() + 3); + spans.push(Span::styled("\u{2502}".to_string(), Style::default().fg(primary))); + let visible = visible_width(&content); + let pad_left = 1usize; + let pad_right = (inner_w as usize).saturating_sub(visible + pad_left); + spans.push(Span::raw(" ".repeat(pad_left))); + spans.extend(content.spans); + spans.push(Span::raw(" ".repeat(pad_right))); + spans.push(Span::styled("\u{2502}".to_string(), Style::default().fg(primary))); + Line::from(spans) +} + +fn blank_line(primary: Color, inner_w: u16) -> Line<'static> { + Line::from(vec![ + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + Span::raw(" ".repeat(inner_w as usize)), + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + ]) +} + +fn role_line(role: &str, theme: &FamiliarTheme, primary: Color, inner_w: u16) -> Line<'static> { + let role_trimmed = truncate(role, (inner_w as usize).saturating_sub(2)); + let text = Span::styled( + role_trimmed, + Style::default().fg(theme.palette.accent), + ); + let used = visible_str_width(&text.content); + let pad_right = (inner_w as usize).saturating_sub(used + 2); + Line::from(vec![ + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + Span::raw(" "), + text, + Span::raw(" ".repeat(pad_right)), + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + ]) +} + +fn rule_line(primary: Color, inner_w: u16) -> Line<'static> { + let inner = format!(" \u{2500}\u{2500}\u{2500}"); + let used = 5usize; + let pad_right = (inner_w as usize).saturating_sub(used); + Line::from(vec![ + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + Span::styled(inner, Style::default().fg(primary)), + Span::raw(" ".repeat(pad_right)), + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + ]) +} + +fn access_line(theme: &FamiliarTheme, primary: Color, inner_w: u16) -> Line<'static> { + let dot = Span::styled("\u{25cf}", Style::default().fg(theme.access_color())); + let tier = Span::styled( + format!(" {}", theme.access), + Style::default().fg(Color::Rgb(203, 213, 225)), + ); + let used = 2 + 1 + 1 + theme.access.chars().count(); + let pad_right = (inner_w as usize).saturating_sub(used); + Line::from(vec![ + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + Span::raw(" "), + dot, + tier, + Span::raw(" ".repeat(pad_right)), + Span::styled("\u{2502}".to_string(), Style::default().fg(primary)), + ]) +} + +// ── Glyph dispatch ─────────────────────────────────────────────────────────── + +fn glyph_lines(theme: &FamiliarTheme, pose: &RustlePose) -> Vec> { + match theme.archetype { + Archetype::SigilCrystal => sigil_crystal(&theme.palette, &theme.emoji), + Archetype::SigilHex => sigil_hex(&theme.palette, &theme.emoji), + Archetype::SigilRune => sigil_rune(&theme.palette, &theme.emoji), + Archetype::SigilSeal => sigil_seal(&theme.palette, &theme.emoji), + _ => archetype_lines(theme.archetype, &theme.palette, pose).to_vec(), + } +} + +// ── Procedural sigils for user-defined familiars ───────────────────────────── +// +// Each sigil is 11 visible cells wide × 4 rows so it slots in next to the +// hand-crafted built-ins without changing the bordered layout. The emoji +// (2 cells wide on most terminals) is rendered as its own span; the +// surrounding frame characters are colored in the resolved palette. + +fn sigil_crystal(p: &FamiliarPalette, emoji: &str) -> Vec> { + let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); + let accent = Style::default().fg(p.accent); + vec![ + Line::from(Span::styled(" \u{2581}\u{2580}\u{2581} ".to_string(), frame)), + emoji_row(p, emoji, " \u{25e2}", "\u{25e3} "), + Line::from(Span::styled(" \u{2580}\u{2581}\u{2580} ".to_string(), frame)), + Line::from(Span::styled(" \u{2022} \u{2022} ".to_string(), accent)), + ] +} + +fn sigil_hex(p: &FamiliarPalette, emoji: &str) -> Vec> { + let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); + let accent = Style::default().fg(p.accent); + vec![ + Line::from(Span::styled(" \u{256d}\u{2500}\u{2500}\u{2500}\u{256e} ".to_string(), frame)), + emoji_row(p, emoji, " \u{2502}", "\u{2502} "), + Line::from(Span::styled(" \u{2570}\u{2500}\u{2500}\u{2500}\u{256f} ".to_string(), frame)), + Line::from(Span::styled(" \u{2024}\u{2024}\u{2024} ".to_string(), accent)), + ] +} + +fn sigil_rune(p: &FamiliarPalette, emoji: &str) -> Vec> { + let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); + let accent = Style::default().fg(p.accent); + vec![ + Line::from(Span::styled(" \u{258e} \u{258e} ".to_string(), frame)), + emoji_row(p, emoji, " \u{258e}", "\u{258e} "), + Line::from(Span::styled(" \u{2594}\u{2594}\u{2594}\u{2594}\u{2594} ".to_string(), frame)), + Line::from(Span::styled(" \u{2500} \u{2500} ".to_string(), accent)), + ] +} + +fn sigil_seal(p: &FamiliarPalette, emoji: &str) -> Vec> { + let frame = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); + let accent = Style::default().fg(p.accent); + vec![ + Line::from(Span::styled(" \u{2726} \u{2726} ".to_string(), accent)), + emoji_row(p, emoji, " \u{2727}", "\u{2727} "), + Line::from(Span::styled(" \u{2726} \u{2726} ".to_string(), accent)), + Line::from(Span::styled(" \u{2500}\u{2500}\u{2500}\u{2500} ".to_string(), frame)), + ] +} + +/// Compose a row with a 2-cell emoji centered between two frame slugs and +/// pad to 11 visible cells. +fn emoji_row(p: &FamiliarPalette, emoji: &str, left: &str, right: &str) -> Line<'static> { + let frame_style = Style::default().fg(p.primary).add_modifier(Modifier::BOLD); + // Visible width = left_chars + 2 (emoji) + right_chars. Pad to 11. + let left_w = left.chars().count(); + let right_w = right.chars().count(); + let used = left_w + 2 + right_w; + let pad_right = 11usize.saturating_sub(used); + Line::from(vec![ + Span::styled(left.to_string(), frame_style), + Span::raw(emoji.to_string()), + Span::styled(right.to_string(), frame_style), + Span::raw(" ".repeat(pad_right)), + ]) +} + +// ── Width helpers ──────────────────────────────────────────────────────────── + +fn visible_width(line: &Line<'_>) -> usize { + line.spans.iter().map(|s| visible_str_width(&s.content)).sum() +} + +/// Approximate display width treating most chars as 1 cell and emoji / +/// East-Asian wide chars as 2. The glyphs in this crate use only +/// Unicode block-drawing chars (1 cell) and the optional emoji (~2 cells), +/// so this approximation is sufficient. +fn visible_str_width(s: &str) -> usize { + s.chars() + .map(|c| { + let cp = c as u32; + if cp >= 0x1F300 && cp <= 0x1FAFF { + 2 + } else if (0x2600..=0x27BF).contains(&cp) { + // Misc symbols / dingbats, usually 1 cell but emoji-style + // (✨, ★, ✦, etc.) commonly render as 1 cell in modern terminals. + 1 + } else { + 1 + } + }) + .sum() +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if i + 1 >= max { + out.push('\u{2026}'); + break; + } + out.push(c); + } + out + } +} + +fn short_tier(access: &str) -> &str { + match access { + "full" => "full", + "read-only" => "read", + "search-only" => "search", + _ => access, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::familiar_theme; + + #[test] + fn size_thresholds() { + assert!(matches!(pick_size(10), CardSize::Compact)); + assert!(matches!(pick_size(24), CardSize::Standard)); + assert!(matches!(pick_size(40), CardSize::Large)); + } + + #[test] + fn render_card_compact_is_glyph_only() { + let t = familiar_theme::resolve("kitty", &[]); + let lines = render_card(&t, CardSize::Compact, None); + // Built-in archetypes return 5 rows (4 content + 1 blank); compact passes through. + assert!(lines.len() >= 4); + } + + #[test] + fn render_card_standard_has_border() { + let t = familiar_theme::resolve("nova", &[]); + let lines = render_card(&t, CardSize::Standard, None); + // Top border + 5 glyph rows + access row + bottom border = at least 8. + assert!(lines.len() >= 7); + } + + #[test] + fn render_card_large_includes_rule_row() { + let t = familiar_theme::resolve("sage", &[]); + let lines = render_card(&t, CardSize::Large, None); + // Large has at least one more row than Standard (the rule + role region). + let standard = render_card(&t, CardSize::Standard, None).len(); + assert!(lines.len() > standard); + } + + #[test] + fn unknown_familiar_falls_back_without_panic() { + let t = familiar_theme::resolve("definitely-not-real", &[]); + let _ = render_card(&t, CardSize::Large, None); + } +} diff --git a/src-rust/crates/tui/src/familiar_theme.rs b/src-rust/crates/tui/src/familiar_theme.rs new file mode 100644 index 0000000..41b86ff --- /dev/null +++ b/src-rust/crates/tui/src/familiar_theme.rs @@ -0,0 +1,273 @@ +//! Familiar theming — resolves any familiar id (built-in or user-defined from +//! `~/.coven/familiars.toml`) to a stable [`FamiliarTheme`] used by +//! [`crate::familiar_card`] when composing the static themed card shown in the +//! welcome panel, F2 switcher, and `/agents` detail view. +//! +//! Built-in familiars (`kitty`, `nova`, `cody`, `charm`, `sage`, `astra`, +//! `echo`) get hand-tuned palettes + their existing pixel-art archetypes. +//! +//! User-defined familiars get a procedurally derived palette + sigil +//! archetype hashed from the lowercased id so the same familiar always +//! looks the same across sessions and machines without persisting extra +//! state in `familiars.toml`. +//! +//! The access tier flows through verbatim from `coven_shared` — it drives the +//! coloured tier dot drawn on the card. +//! +//! ## Public surface +//! +//! ```ignore +//! let theme = familiar_theme::resolve(id, daemon_familiars); +//! let card = familiar_card::render_card(&theme, size, loading); +//! ``` + +use claurst_core::coven_shared::CovenFamiliar; +use ratatui::style::Color; + +// ── Palette ────────────────────────────────────────────────────────────────── + +/// Four-color palette covering body fill, accent details, eye sockets, and +/// the deep background behind the eyes. Eye + bg are intentionally shared +/// across all themes so the eye rendering helpers in [`crate::rustle`] can +/// stay archetype-agnostic. +#[derive(Debug, Clone, Copy)] +pub struct FamiliarPalette { + pub primary: Color, + pub accent: Color, + pub eye_socket: Color, + pub eye_bg: Color, +} + +impl FamiliarPalette { + const fn from_rgb(primary: (u8, u8, u8), accent: (u8, u8, u8)) -> Self { + Self { + primary: Color::Rgb(primary.0, primary.1, primary.2), + accent: Color::Rgb(accent.0, accent.1, accent.2), + eye_socket: Color::Rgb(196, 181, 253), // violet-300, retained for legibility + eye_bg: Color::Rgb(15, 5, 40), + } + } +} + +// ── Archetype ──────────────────────────────────────────────────────────────── + +/// Which renderer in [`crate::rustle`] / [`crate::familiar_card`] draws the +/// glyph body. The first seven variants map to the hand-crafted built-ins; +/// `SigilCrystal`/`SigilHex`/`SigilRune`/`SigilSeal` are procedural frames +/// used for any user-defined familiar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Archetype { + Cat, + SorceressCrown, + Robot, + Heart, + WizardBook, + Moon, + Ghost, + SigilCrystal, + SigilHex, + SigilRune, + SigilSeal, +} + +// ── Theme ──────────────────────────────────────────────────────────────────── + +/// Everything the card renderer needs to draw one familiar. +/// +/// All owned so the struct can be built freely per render frame without +/// borrowing from the [`crate::app::App`] or any daemon-loaded list. +#[derive(Debug, Clone)] +pub struct FamiliarTheme { + pub id: String, + pub display_name: String, + pub emoji: String, + pub role: Option, + /// Canonical access tier string from `coven_shared::canonicalize_access_tier`. + /// One of `"full"`, `"read-only"`, `"search-only"`. + pub access: String, + pub palette: FamiliarPalette, + pub archetype: Archetype, +} + +impl FamiliarTheme { + /// Color the access-tier dot uses on the card. + pub fn access_color(&self) -> Color { + match self.access.as_str() { + "full" => Color::Rgb(34, 197, 94), // emerald-500 + "read-only" => Color::Rgb(245, 158, 11), // amber-500 + "search-only" => Color::Rgb(148, 163, 184), // slate-400 + _ => Color::Rgb(148, 163, 184), + } + } +} + +// ── Built-in palettes ──────────────────────────────────────────────────────── + +/// Palettes for the seven hand-crafted built-ins. Each one breaks the old +/// uniform violet so familiars are visually distinct at a glance. +const BUILTIN_PALETTES: &[(&str, FamiliarPalette, Archetype, &str, &str)] = &[ + ("kitty", FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250)), Archetype::Cat, "Kitty", "\u{1f431}"), + ("nova", FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138)), Archetype::SorceressCrown, "Nova", "\u{1f451}"), + ("cody", FamiliarPalette::from_rgb(( 34, 211, 238), (165, 243, 252)), Archetype::Robot, "Cody", "\u{1f4bb}"), + ("charm", FamiliarPalette::from_rgb((236, 72, 153), (251, 207, 232)), Archetype::Heart, "Charm", "\u{2728}"), + ("sage", FamiliarPalette::from_rgb(( 16, 185, 129), (167, 243, 208)), Archetype::WizardBook, "Sage", "\u{1f33f}"), + ("astra", FamiliarPalette::from_rgb(( 99, 102, 241), (199, 210, 254)), Archetype::Moon, "Astra", "\u{1f319}"), + ("echo", FamiliarPalette::from_rgb(( 20, 184, 166), (153, 246, 228)), Archetype::Ghost, "Echo", "\u{1f47b}"), +]; + +/// Eight-color palette table used to pick a deterministic accent for any +/// user-defined familiar by hashing its id. +const PROCEDURAL_PALETTES: &[FamiliarPalette] = &[ + FamiliarPalette::from_rgb((139, 92, 246), (167, 139, 250)), // violet + FamiliarPalette::from_rgb((245, 197, 24), (253, 230, 138)), // gold + FamiliarPalette::from_rgb(( 34, 211, 238), (165, 243, 252)), // cyan + FamiliarPalette::from_rgb((236, 72, 153), (251, 207, 232)), // pink + FamiliarPalette::from_rgb(( 16, 185, 129), (167, 243, 208)), // emerald + FamiliarPalette::from_rgb(( 99, 102, 241), (199, 210, 254)), // indigo + FamiliarPalette::from_rgb((251, 113, 133), (254, 205, 211)), // rose + FamiliarPalette::from_rgb((250, 204, 21), (253, 224, 71)), // amber +]; + +const PROCEDURAL_ARCHETYPES: &[Archetype] = &[ + Archetype::SigilCrystal, + Archetype::SigilHex, + Archetype::SigilRune, + Archetype::SigilSeal, +]; + +/// Resolve a familiar id to its theme. +/// +/// Built-in ids win first. Anything else is matched against the supplied +/// `daemon_familiars` (callers pass [`coven_shared::load_familiars`] output). +/// Unknown ids fall back to the `kitty` theme so the welcome panel never +/// renders blank. +pub fn resolve(id: &str, daemon_familiars: &[CovenFamiliar]) -> FamiliarTheme { + let lc = id.to_lowercase(); + if let Some(theme) = builtin(&lc) { + return theme; + } + if let Some(def) = daemon_familiars.iter().find(|f| f.id.to_lowercase() == lc) { + return procedural(def); + } + builtin("kitty").expect("kitty is always present in BUILTIN_PALETTES") +} + +fn builtin(id: &str) -> Option { + BUILTIN_PALETTES + .iter() + .find(|(slug, _, _, _, _)| *slug == id) + .map(|(slug, palette, arch, name, emoji)| FamiliarTheme { + id: (*slug).to_string(), + display_name: (*name).to_string(), + emoji: (*emoji).to_string(), + role: None, + access: builtin_access(slug).to_string(), + palette: *palette, + archetype: *arch, + }) +} + +/// Built-in tier defaults match the recommendation table in `docs/familiars.md`: +/// `cody`, `nova`, `kitty` get `full`; the research-leaning rest stay read-only. +fn builtin_access(id: &str) -> &'static str { + match id { + "kitty" | "cody" | "nova" => "full", + _ => "read-only", + } +} + +fn procedural(def: &CovenFamiliar) -> FamiliarTheme { + let h = hash_id(&def.id); + let palette = PROCEDURAL_PALETTES[(h % PROCEDURAL_PALETTES.len() as u64) as usize]; + let archetype = PROCEDURAL_ARCHETYPES[((h / 8) % PROCEDURAL_ARCHETYPES.len() as u64) as usize]; + + FamiliarTheme { + id: def.id.clone(), + display_name: def.display_name.clone().unwrap_or_else(|| def.id.clone()), + emoji: def.emoji.clone().unwrap_or_else(|| "\u{2728}".to_string()), + role: def.role.clone(), + access: def.resolved_access().to_string(), + palette, + archetype, + } +} + +/// FNV-1a 64-bit hash. Stable across machines/architectures so the same +/// familiar id always produces the same palette + archetype. +fn hash_id(id: &str) -> u64 { + let mut h: u64 = 0xcbf29ce484222325; + for b in id.to_lowercase().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_familiar(id: &str) -> CovenFamiliar { + CovenFamiliar { + id: id.to_string(), + display_name: None, + emoji: None, + role: None, + description: None, + pronouns: None, + access: None, + } + } + + #[test] + fn builtin_resolution() { + let t = resolve("kitty", &[]); + assert_eq!(t.id, "kitty"); + assert!(matches!(t.archetype, Archetype::Cat)); + } + + #[test] + fn case_insensitive_lookup() { + let t = resolve("KITTY", &[]); + assert_eq!(t.id, "kitty"); + } + + #[test] + fn unknown_falls_back_to_kitty() { + let t = resolve("does-not-exist", &[]); + assert_eq!(t.id, "kitty"); + } + + #[test] + fn user_defined_is_stable() { + let f = vec![fake_familiar("qa")]; + let a = resolve("qa", &f); + let b = resolve("qa", &f); + // Same id must hash to same palette + archetype. + assert_eq!(format!("{:?}", a.archetype), format!("{:?}", b.archetype)); + assert_eq!(format!("{:?}", a.palette.primary), format!("{:?}", b.palette.primary)); + } + + #[test] + fn different_user_familiars_differ() { + // Two distinct ids should map to either different palette or + // different archetype most of the time. This isn't a strict + // collision-resistance claim; we just want the hashing to spread. + let f = vec![fake_familiar("qa"), fake_familiar("planner-bot")]; + let a = resolve("qa", &f); + let b = resolve("planner-bot", &f); + assert!( + format!("{:?}", a.archetype) != format!("{:?}", b.archetype) + || format!("{:?}", a.palette.primary) != format!("{:?}", b.palette.primary), + "qa and planner-bot collided on both palette and archetype" + ); + } + + #[test] + fn user_emoji_passthrough() { + let mut f = fake_familiar("qa"); + f.emoji = Some("\u{1f9ea}".to_string()); // 🧪 + let t = resolve("qa", &[f.clone()]); + assert_eq!(t.emoji, "\u{1f9ea}"); + } +} diff --git a/src-rust/crates/tui/src/lib.rs b/src-rust/crates/tui/src/lib.rs index fe3ac22..ad6fff0 100644 --- a/src-rust/crates/tui/src/lib.rs +++ b/src-rust/crates/tui/src/lib.rs @@ -45,12 +45,16 @@ pub mod context_viz; pub mod export_dialog; /// Clipboard image paste and Ctrl+V text paste. pub mod image_paste; -/// Inline image rendering via the Kitty graphics protocol (with text fallback). -pub mod kitty_image; -/// Familiar card image lookup and terminal escape rendering. -pub mod familiar_image; -/// Application state and main event loop. -pub mod app; +/// Inline image rendering via the Kitty graphics protocol (with text fallback). +pub mod kitty_image; +/// Familiar card image lookup and terminal escape rendering. +pub mod familiar_image; +/// Familiar theming: palettes + archetypes per familiar id. +pub mod familiar_theme; +/// Static themed familiar card composer (welcome panel, F2 switcher, /agents). +pub mod familiar_card; +/// Application state and main event loop. +pub mod app; /// Input helpers: slash command parsing. pub mod input; /// All ratatui rendering logic. @@ -81,12 +85,12 @@ pub mod virtual_list; pub mod messages; /// Turn-aware transcript grouping and metadata helpers. pub mod transcript_turn; -/// Agent definitions list and coordinator progress view. -pub mod agents_view; -/// Coven familiar handoff command support. -pub mod handoff; -/// Stats dialog with token usage and cost charts. -pub mod stats_dialog; +/// Agent definitions list and coordinator progress view. +pub mod agents_view; +/// Coven familiar handoff command support. +pub mod handoff; +/// Stats dialog with token usage and cost charts. +pub mod stats_dialog; /// MCP server management UI. pub mod mcp_view; /// Complete prompt input with vim mode, history, typeahead, and paste handling. diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 5dfdb12..9297de7 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -5,9 +5,11 @@ use std::cell::RefCell; use crate::agents_view::render_agents_menu; use crate::context_viz::render_context_viz; use crate::export_dialog::render_export_dialog; +use crate::familiar_card; use crate::familiar_image; +use crate::familiar_theme; use crate::app::{App, ContextMenuKind, SystemAnnotation, SystemMessageStyle, ToolStatus}; -use crate::rustle::rustle_lines_for; +use crate::rustle::RustlePose; use crate::diff_viewer::render_diff_dialog; use crate::model_picker::render_model_picker; use crate::session_browser::render_session_browser; @@ -1527,33 +1529,25 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) { } else { "Welcome back!".to_string() }; - let rustle = rustle_lines_for(app.config.familiar.as_deref(), &app.rustle_current_pose); let familiar_name = app.config.familiar.as_deref().unwrap_or("kitty"); - let familiar_label = format!("familiar: {}", familiar_name); + let daemon_familiars = claurst_core::coven_shared::load_familiars().unwrap_or_default(); + let theme = familiar_theme::resolve(familiar_name, &daemon_familiars); + let card_size = familiar_card::pick_size(left_w); + let loading_frame = match app.rustle_current_pose { + RustlePose::Loading { frame } => Some(frame), + RustlePose::Static => None, + }; + let mut left_lines: Vec = Vec::new(); left_lines.push(Line::from(Span::styled( welcome_msg, Style::default().fg(Color::White).add_modifier(Modifier::BOLD), ))); - left_lines.push(Line::from(Span::styled( - familiar_label, - Style::default().fg(Color::Rgb(196, 181, 253)), // violet-300, muted - ))); left_lines.push(Line::from("")); - // Walk mascot across the left column. - // Max walkable offset = available width minus mascot width (11), minus 1 margin. - let mascot_walk_max = (left_w as i32).saturating_sub(12).max(0); - app.rustle_walk_max.set(mascot_walk_max); - let walk_x = app.rustle_walk_x.clamp(0, mascot_walk_max) as usize; - let pad = " ".repeat(walk_x); if let Some(seq) = familiar_image::render_familiar_image(familiar_name, 11, 5) { - left_lines.push(Line::from(vec![Span::raw(pad.clone()), Span::raw(seq)])); + left_lines.push(Line::from(Span::raw(seq))); } else { - for cl in &rustle { - let mut spans = vec![Span::raw(pad.clone())]; - spans.extend(cl.spans.iter().cloned()); - left_lines.push(Line::from(spans)); - } + left_lines.extend(familiar_card::render_card(&theme, card_size, loading_frame)); } frame.render_widget(Paragraph::new(left_lines).wrap(Wrap { trim: false }), h_chunks[0]); @@ -3025,7 +3019,7 @@ fn render_familiar_switcher(frame: &mut Frame, app: &App, area: Rect) { let list_len = app.familiar_switcher_list.len() as u16; let popup_h = list_len.saturating_add(2).min(area.height.saturating_sub(4)); - let popup_w = 26u16.min(area.width.saturating_sub(4)); + let popup_w = 40u16.min(area.width.saturating_sub(4)); let popup_x = area.x + area.width.saturating_sub(popup_w) / 2; let popup_y = area.y + area.height.saturating_sub(popup_h) / 2; let popup_area = Rect { @@ -3037,36 +3031,27 @@ fn render_familiar_switcher(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Clear, popup_area); - let builtin_emoji: &[(&str, &str)] = &[ - ("nova", "\u{1f451}"), - ("kitty", "\u{1f431}"), - ("cody", "\u{1f4bb}"), - ("charm", "\u{2728}"), - ("sage", "\u{1f33f}"), - ("astra", "\u{1f319}"), - ("echo", "\u{1f47b}"), - ]; + let daemon_familiars = claurst_core::coven_shared::load_familiars().unwrap_or_default(); + let interior_w = popup_w.saturating_sub(2); let items: Vec = app .familiar_switcher_list .iter() .enumerate() .map(|(i, id)| { - let emoji = builtin_emoji - .iter() - .find(|(k, _)| *k == id.as_str()) - .map(|(_, e)| *e) - .unwrap_or("\u{2b50}"); - let label = format!(" {} {} ", emoji, id); - let style = if i == app.familiar_switcher_idx { - Style::default() - .fg(Color::Black) - .bg(Color::Rgb(139, 92, 246)) - .add_modifier(Modifier::BOLD) + let theme = familiar_theme::resolve(id, &daemon_familiars); + let row = familiar_card::render_mini_row(&theme, interior_w); + let item = ListItem::new(row); + if i == app.familiar_switcher_idx { + item.style( + Style::default() + .bg(theme.palette.primary) + .fg(Color::Black) + .add_modifier(Modifier::BOLD), + ) } else { - Style::default().fg(Color::White) - }; - ListItem::new(label).style(style) + item + } }) .collect(); diff --git a/src-rust/crates/tui/src/rustle.rs b/src-rust/crates/tui/src/rustle.rs index 3bad65b..854c74e 100644 --- a/src-rust/crates/tui/src/rustle.rs +++ b/src-rust/crates/tui/src/rustle.rs @@ -2,95 +2,87 @@ //! //! Each OpenCoven familiar has its own pixel-art glyph. The active familiar //! is read from `config.familiar` (settings.json `"familiar"` key) and -//! determines which glyph renders in the welcome screen top-left. +//! determines which glyph renders in the welcome screen top-left, the F2 +//! switcher, and the `/agents` detail view. //! -//! Public API preserves upstream names (`RustlePose`, `rustle_lines`) for -//! `git merge upstream/main` friendliness. +//! The glyph itself is **static** — no walking, no idle blink, no +//! Tab-triggered look-down. The only motion is the loading spinner that +//! rotates inside the eye row when the assistant is mid-turn and stalled. +//! That motion lives in [`loading_eye_spans`]. Everything else is one +//! frame for one pose. //! -//! # Familiar roster +//! Public surface: +//! - [`RustlePose`] — `Static` for the resting glyph, `Loading { frame }` for the spinner. +//! - [`archetype_lines`] — palette-aware glyph dispatcher used by [`crate::familiar_card`]. +//! - [`rustle_lines_for`] — legacy entry point preserved for callers that still +//! pass a familiar slug; it routes through the theme/card path internally. //! -//! | ID | Glyph concept | -//! |----------|---------------------------------------| -//! | `kitty` | Cat head — ears, whiskers, square eyes (default) | -//! | `nova` | 4-point star with orbiting sparks | -//! | `cody` | Robot face — antenna, bracket eyes | -//! | `charm` | Heart with sparkle dots + speech bubble | -//! | `sage` | Wizard hat + star + open book | -//! | `astra` | Crescent moon + compass star + orbit | -//! | `echo` | Round ghost + mirror eyes + echo dots | +//! # Built-in roster //! -//! # Layout +//! | ID | Archetype | +//! |----------|--------------------------------------------------------------------------| +//! | `kitty` | Cat head — pointy ears, whisker nose, square eyes (default). | +//! | `nova` | 4-point star + crown + orbit dots — sorceress. | +//! | `cody` | Robot face with antenna, bracket eyes, code body. | +//! | `charm` | Large pixel heart with sparkle dots. | +//! | `sage` | Wizard hat with star above an open spellbook. | +//! | `astra` | Crescent moon, compass star, dotted orbit. | +//! | `echo` | Round ghost with bracket eyes and floaty echo dots. | //! //! All glyphs are 11 chars wide × 4 content rows + 1 blank spacing row. -//! Row indexing: -//! [0] — head / top -//! [1] — face / eyes (animated for Loading pose) -//! [2] — body / mid -//! [3] — feet / bottom -//! [4] — blank spacing +use crate::familiar_theme::{Archetype, FamiliarPalette}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; // ── Pose ───────────────────────────────────────────────────────────────────── /// Pose / expression of the companion mascot. -/// Names preserved from upstream for merge-friendliness. +/// +/// Static is the resting frame. Loading carries a monotonically-increasing +/// frame counter that drives the eye-spinner animation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RustlePose { - Default, - ArmsUp, - LookLeft, - LookRight, - LookDown, - /// Spinning animation — `frame` drives the eye rotation. + Static, Loading { frame: u64 }, } -// ── Colors ──────────────────────────────────────────────────────────────────── +// ── Style helpers ──────────────────────────────────────────────────────────── -/// Violet body: #8B5CF6 violet-500 — OpenCoven primary. -fn body_style() -> Style { - Style::default() - .fg(Color::Rgb(139, 92, 246)) - .add_modifier(Modifier::BOLD) +fn body_style(palette: &FamiliarPalette) -> Style { + Style::default().fg(palette.primary).add_modifier(Modifier::BOLD) } -/// Eye socket: violet-300 on near-black. -fn eye_bg_style() -> Style { - Style::default() - .fg(Color::Rgb(196, 181, 253)) - .bg(Color::Rgb(15, 5, 40)) - .add_modifier(Modifier::BOLD) +fn accent_style(palette: &FamiliarPalette) -> Style { + Style::default().fg(palette.accent).add_modifier(Modifier::BOLD) } -/// Eye highlight: white on near-black. -fn eyeball_style() -> Style { +fn eye_bg_style(palette: &FamiliarPalette) -> Style { Style::default() - .fg(Color::White) - .bg(Color::Rgb(15, 5, 40)) + .fg(palette.eye_socket) + .bg(palette.eye_bg) .add_modifier(Modifier::BOLD) } -/// Accent detail: violet-400 for secondary marks. -fn accent_style() -> Style { +fn eyeball_style(palette: &FamiliarPalette) -> Style { Style::default() - .fg(Color::Rgb(167, 139, 250)) + .fg(Color::White) + .bg(palette.eye_bg) .add_modifier(Modifier::BOLD) } // ── Eye helpers ─────────────────────────────────────────────────────────────── -fn eye_spans(s: &'static str) -> Vec> { +fn eye_spans(palette: &FamiliarPalette, s: &'static str) -> Vec> { let mut spans: Vec> = Vec::new(); let mut buf = String::new(); let mut buf_is_eyeball = false; for ch in s.chars() { - let is_eyeball = matches!(ch, '▘' | '▝' | '▀' | '▄' | '▖' | '▌' | '▐'); + let is_eyeball = matches!(ch, '\u{2598}' | '\u{259d}' | '\u{2580}' | '\u{2584}' | '\u{2596}' | '\u{258c}' | '\u{2590}'); if is_eyeball != buf_is_eyeball && !buf.is_empty() { spans.push(Span::styled( buf.clone(), - if buf_is_eyeball { eyeball_style() } else { eye_bg_style() }, + if buf_is_eyeball { eyeball_style(palette) } else { eye_bg_style(palette) }, )); buf.clear(); } @@ -100,299 +92,259 @@ fn eye_spans(s: &'static str) -> Vec> { if !buf.is_empty() { spans.push(Span::styled( buf, - if buf_is_eyeball { eyeball_style() } else { eye_bg_style() }, + if buf_is_eyeball { eyeball_style(palette) } else { eye_bg_style(palette) }, )); } spans } -fn loading_eye_spans(frame: u64) -> Vec> { - const QUARTERS: [char; 4] = ['▘', '▝', '▗', '▖']; +/// Five-cell eye row that rotates a quarter-block highlight clockwise. +/// Returned spans use the supplied palette for the dim trailing color so the +/// loader feels coherent with the rest of the glyph. +fn loading_eye_spans(palette: &FamiliarPalette, frame: u64) -> Vec> { + const QUARTERS: [char; 4] = ['\u{2598}', '\u{259d}', '\u{2597}', '\u{2596}']; const CW: [usize; 4] = [0, 1, 2, 3]; const CCW: [usize; 4] = [1, 0, 3, 2]; - const COLORS: [Color; 4] = [ - Color::White, - Color::Rgb(196, 181, 253), // violet-300 - Color::Rgb(139, 92, 246), // violet-500 - Color::Rgb(76, 29, 149), // violet-900 - ]; let step = (frame / 5) as usize % 4; let prev = (step + 3) % 4; + let trail = palette.primary; + let head = Color::White; + let bg = palette.eye_bg; + let bold = Modifier::BOLD; vec![ - Span::styled( - QUARTERS[CW[prev]].to_string(), - Style::default().fg(COLORS[2]).bg(Color::Rgb(15, 5, 40)).add_modifier(Modifier::BOLD), - ), - Span::styled( - QUARTERS[CW[step]].to_string(), - Style::default().fg(COLORS[0]).bg(Color::Rgb(15, 5, 40)).add_modifier(Modifier::BOLD), - ), - Span::styled("█".to_string(), eye_bg_style()), - Span::styled( - QUARTERS[CCW[step]].to_string(), - Style::default().fg(COLORS[0]).bg(Color::Rgb(15, 5, 40)).add_modifier(Modifier::BOLD), - ), - Span::styled( - QUARTERS[CCW[prev]].to_string(), - Style::default().fg(COLORS[2]).bg(Color::Rgb(15, 5, 40)).add_modifier(Modifier::BOLD), - ), + Span::styled(QUARTERS[CW[prev]].to_string(), Style::default().fg(trail).bg(bg).add_modifier(bold)), + Span::styled(QUARTERS[CW[step]].to_string(), Style::default().fg(head).bg(bg).add_modifier(bold)), + Span::styled("\u{2588}".to_string(), eye_bg_style(palette)), + Span::styled(QUARTERS[CCW[step]].to_string(), Style::default().fg(head).bg(bg).add_modifier(bold)), + Span::styled(QUARTERS[CCW[prev]].to_string(), Style::default().fg(trail).bg(bg).add_modifier(bold)), ] } -// ── Per-familiar glyph builders ─────────────────────────────────────────────── +// ── Per-archetype glyph builders ────────────────────────────────────────────── -/// **Kitty** — cat head: triangle ears, whiskers, square eyes. -/// **Kitty** — cat face with pointy ears, square eyes, whisker nose. -/// Reference: purple cat head, pointy ears, white square eyes, whiskers. -fn kitty_lines(pose: &RustlePose) -> [Line<'static>; 5] { - // Two pointy ear peaks: ▄▖ gap ▗▄▖ - let row1 = Line::from(vec![Span::styled(" ▄▖ ▗▄▖ ".to_string(), body_style())]); - // Eyes — square markers with pose variation - let (r2l, r2e, r2r) = match pose { - RustlePose::Default => (" ▐◈ ◈▐▌ ", "", ""), - RustlePose::ArmsUp => (" ▐◈ ◈▐▌ ", "", ""), - RustlePose::LookLeft => (" ▐◼ ◻▐▌ ", "", ""), - RustlePose::LookRight => (" ▐◻ ◼▐▌ ", "", ""), - RustlePose::LookDown => (" ▐▪ ▪▐▌ ", "", ""), - RustlePose::Loading { .. } => (" ▐", "", "▐▌ "), +/// **Kitty** — cat head: pointy ears, square eyes, whisker nose. +fn kitty_lines(palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + let row1 = Line::from(Span::styled(" \u{2584}\u{2596} \u{2597}\u{2584}\u{2596} ".to_string(), body_style(palette))); + let row2 = match pose { + RustlePose::Static => Line::from(Span::styled(" \u{2590}\u{25c8} \u{25c8}\u{2590}\u{258c} ".to_string(), body_style(palette))), + RustlePose::Loading { frame } => { + let mut spans = vec![Span::styled(" \u{2590}".to_string(), body_style(palette))]; + spans.extend(loading_eye_spans(palette, *frame)); + spans.push(Span::styled("\u{2590}\u{258c} ".to_string(), body_style(palette))); + Line::from(spans) + } }; - let mut row2_spans = vec![Span::styled(r2l.to_string(), body_style())]; - if let RustlePose::Loading { frame } = pose { - row2_spans.extend(loading_eye_spans(*frame)); - row2_spans.push(Span::styled(r2r.to_string(), body_style())); - } - // Nose and whisker-hint row - let row3 = Line::from(vec![Span::styled(" ▐▌ ᴥ ▐▌ ".to_string(), body_style())]); - // Chin - let row4 = Line::from(vec![Span::styled(" ▀▀▀▀▀▀ ".to_string(), body_style())]); - [row1, Line::from(row2_spans), row3, row4, Line::from("")] + let row3 = Line::from(Span::styled(" \u{2590}\u{258c} \u{1d25} \u{2590}\u{258c} ".to_string(), body_style(palette))); + let row4 = Line::from(Span::styled(" \u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580} ".to_string(), body_style(palette))); + [row1, row2, row3, row4, Line::from("")] } -/// **Nova** — crowned sorceress: sparkles, crown, hooded face, gem clasp. -/// Reference: crowned hooded queen figure with gem accents and sparkles. -fn nova_lines(pose: &RustlePose) -> [Line<'static>; 5] { - let row1 = Line::from(vec![ - Span::styled(" · ✦ · ".to_string(), accent_style()), - ]); - let row2 = if let RustlePose::Loading { frame } = pose { - let spin = ['·', '✦', '*', '·']; - let s = spin[(frame / 5) as usize % 4]; - Line::from(vec![ - Span::styled(format!(" ▗▄{}▄▗▖ ", s), body_style()), - ]) - } else { - let crown = match pose { - RustlePose::LookLeft => " ▗▄◄▄▗▖ ", - RustlePose::LookRight => " ▗▄►▄▗▖ ", - _ => " ▗▄♛▄▗▖ ", - }; - Line::from(vec![Span::styled(crown.to_string(), body_style())]) +/// **Nova** — crown, hooded face, gem clasp, sparkle accents. +fn nova_lines(palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + let row1 = Line::from(Span::styled(" \u{00b7} \u{2726} \u{00b7} ".to_string(), accent_style(palette))); + let row2 = match pose { + RustlePose::Loading { frame } => { + let spin = ['\u{00b7}', '\u{2726}', '*', '\u{00b7}']; + let s = spin[(*frame / 5) as usize % 4]; + Line::from(Span::styled(format!(" \u{2597}\u{2584}{}\u{2584}\u{2597}\u{2596} ", s), body_style(palette))) + } + RustlePose::Static => Line::from(Span::styled( + " \u{2597}\u{2584}\u{265b}\u{2584}\u{2597}\u{2596} ".to_string(), + body_style(palette), + )), }; - let row3 = Line::from(vec![Span::styled(" ▐▌███▐▌ ".to_string(), body_style())]); - let row4 = Line::from(vec![ - Span::styled(" ◆ · ◆ ".to_string(), accent_style()), - ]); + let row3 = Line::from(Span::styled(" \u{2590}\u{258c}\u{2588}\u{2588}\u{2588}\u{2590}\u{258c} ".to_string(), body_style(palette))); + let row4 = Line::from(Span::styled(" \u{25c6} \u{00b7} \u{25c6} ".to_string(), accent_style(palette))); [row1, row2, row3, row4, Line::from("")] } -/// **Cody** — hooded cat programmer: antenna, bracket eyes, code body, laptop. -/// Reference: hooded cat with laptop, bracket eyes, code symbols. -fn cody_lines(pose: &RustlePose) -> [Line<'static>; 5] { - let row1 = Line::from(vec![Span::styled(" ─┼─ ".to_string(), body_style())]); - let r2 = match pose { +/// **Cody** — robot programmer: antenna, bracket eyes, code body. +fn cody_lines(palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + let row1 = Line::from(Span::styled(" \u{2500}\u{253c}\u{2500} ".to_string(), body_style(palette))); + let row2 = match pose { RustlePose::Loading { frame } => { let anim = ['[', '(', '[', '<']; - let ch = anim[(frame / 5) as usize % 4]; - format!(" ▄▄[{ch} {ch}]▄ ") // 11 cols + let ch = anim[(*frame / 5) as usize % 4]; + Line::from(Span::styled(format!(" \u{2584}\u{2584}[{ch} {ch}]\u{2584} "), body_style(palette))) } - RustlePose::LookLeft => " ▄▄[◄ ◄]▄ ".to_string(), - RustlePose::LookRight => " ▄▄[► ►]▄ ".to_string(), - RustlePose::LookDown => " ▄▄[▼ ▼]▄ ".to_string(), - RustlePose::ArmsUp => " ▄▄[▲ ▲]▄ ".to_string(), - RustlePose::Default => " ▄▄[◈ ◈]▄ ".to_string(), + RustlePose::Static => Line::from(Span::styled(" \u{2584}\u{2584}[\u{25c8} \u{25c8}]\u{2584} ".to_string(), body_style(palette))), }; - let row2 = Line::from(vec![Span::styled(r2, body_style())]); - let row3 = Line::from(vec![Span::styled(" ▌ ▐ ".to_string(), body_style())]); - let row4 = Line::from(vec![Span::styled(" ▄████▄ ".to_string(), body_style())]); + let row3 = Line::from(Span::styled(" \u{258c} \u{2590} ".to_string(), body_style(palette))); + let row4 = Line::from(Span::styled(" \u{2584}\u{2588}\u{2588}\u{2588}\u{2588}\u{2584} ".to_string(), body_style(palette))); [row1, row2, row3, row4, Line::from("")] } /// **Charm** — large pixel heart with sparkle dots. -/// Reference: big purple pixel heart, sparkle dots, speech bubble hint. -fn charm_lines(pose: &RustlePose) -> [Line<'static>; 5] { - let row1 = Line::from(vec![ - Span::styled(" ▄██▄▄██▄ ".to_string(), body_style()), - ]); - let row2 = if let RustlePose::Loading { frame } = pose { - let sparkle = ['✦', '·', '*', '·']; - let s = sparkle[(frame / 5) as usize % 4]; - Line::from(vec![ - Span::styled(format!(" {s}███████{s} "), body_style()), - ]) - } else { - Line::from(vec![Span::styled(" █████████ ".to_string(), body_style())]) - }; - let row3 = Line::from(vec![Span::styled(" ███████ ".to_string(), body_style())]); - let row4 = match pose { - RustlePose::ArmsUp => Line::from(vec![ - Span::styled(" ✦▀█▀✦ ".to_string(), accent_style()), - ]), - _ => Line::from(vec![Span::styled(" ▀█▀ ".to_string(), body_style())]), +fn charm_lines(palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + let row1 = Line::from(Span::styled(" \u{2584}\u{2588}\u{2588}\u{2584}\u{2584}\u{2588}\u{2588}\u{2584} ".to_string(), body_style(palette))); + let row2 = match pose { + RustlePose::Loading { frame } => { + let sparkle = ['\u{2726}', '\u{00b7}', '*', '\u{00b7}']; + let s = sparkle[(*frame / 5) as usize % 4]; + Line::from(Span::styled(format!(" {s}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}{s} "), body_style(palette))) + } + RustlePose::Static => Line::from(Span::styled(" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ".to_string(), body_style(palette))), }; + let row3 = Line::from(Span::styled(" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ".to_string(), body_style(palette))); + let row4 = Line::from(Span::styled(" \u{2580}\u{2588}\u{2580} ".to_string(), body_style(palette))); [row1, row2, row3, row4, Line::from("")] } /// **Sage** — wizard hat with star above an open spellbook. -/// Reference: pointed hat with star, open book below, leafy accents. -fn sage_lines(pose: &RustlePose) -> [Line<'static>; 5] { - let row1 = Line::from(vec![Span::styled(" ▗▄▖ ".to_string(), body_style())]); - let row2 = Line::from(vec![Span::styled(" ▗█✦██▖ ".to_string(), body_style())]); - let row3 = Line::from(vec![Span::styled(" ▄███████▄ ".to_string(), body_style())]); - let row4 = if let RustlePose::Loading { frame } = pose { - let page = ['─', '~', '─', '~']; - let p = page[(frame / 5) as usize % 4]; - Line::from(vec![ - Span::styled(format!(" ▐{p}{p}┼{p}{p}▌ "), body_style()), - ]) - } else { - Line::from(vec![Span::styled(" ▐──┼──▌ ".to_string(), body_style())]) +fn sage_lines(palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + let row1 = Line::from(Span::styled(" \u{2597}\u{2584}\u{2596} ".to_string(), body_style(palette))); + let row2 = Line::from(Span::styled(" \u{2597}\u{2588}\u{2726}\u{2588}\u{2588}\u{2596} ".to_string(), body_style(palette))); + let row3 = Line::from(Span::styled(" \u{2584}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2584} ".to_string(), body_style(palette))); + let row4 = match pose { + RustlePose::Loading { frame } => { + let page = ['\u{2500}', '~', '\u{2500}', '~']; + let p = page[(*frame / 5) as usize % 4]; + Line::from(Span::styled(format!(" \u{2590}{p}{p}\u{253c}{p}{p}\u{258c} "), body_style(palette))) + } + RustlePose::Static => Line::from(Span::styled(" \u{2590}\u{2500}\u{2500}\u{253c}\u{2500}\u{2500}\u{258c} ".to_string(), body_style(palette))), }; [row1, row2, row3, row4, Line::from("")] } -/// **Astra** — crescent moon with compass star and orbit arc. -/// Reference: large crescent moon, 4-point cross-star, dotted orbit below. -fn astra_lines(pose: &RustlePose) -> [Line<'static>; 5] { - let row1 = Line::from(vec![Span::styled(" ✦ · ".to_string(), accent_style())]); - let row2 = Line::from(vec![Span::styled(" ▗████▖ ".to_string(), body_style())]); - let row3 = if let RustlePose::Loading { frame } = pose { - let arcs = [" █ ▘ ", " █ · ", " █ ▘ ", " █ · "]; - Line::from(vec![Span::styled(arcs[(frame / 5) as usize % 4].to_string(), body_style())]) - } else { - Line::from(vec![Span::styled(" █ ✦ ".to_string(), body_style())]) +/// **Astra** — crescent moon with compass star and dotted orbit. +fn astra_lines(palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + let row1 = Line::from(Span::styled(" \u{2726} \u{00b7} ".to_string(), accent_style(palette))); + let row2 = Line::from(Span::styled(" \u{2597}\u{2588}\u{2588}\u{2588}\u{2588}\u{2596} ".to_string(), body_style(palette))); + let row3 = match pose { + RustlePose::Loading { frame } => { + let arcs = [ + " \u{2588} \u{2598} ", + " \u{2588} \u{00b7} ", + " \u{2588} \u{2598} ", + " \u{2588} \u{00b7} ", + ]; + Line::from(Span::styled(arcs[(*frame / 5) as usize % 4].to_string(), body_style(palette))) + } + RustlePose::Static => Line::from(Span::styled(" \u{2588} \u{2726} ".to_string(), body_style(palette))), }; - let row4 = Line::from(vec![Span::styled(" ▀▄▄· · ".to_string(), accent_style())]); + let row4 = Line::from(Span::styled(" \u{2580}\u{2584}\u{2584}\u{00b7} \u{00b7} ".to_string(), accent_style(palette))); [row1, row2, row3, row4, Line::from("")] } -/// **Echo** — round ghost with bracket eyes, blush smile, wavy hem, echo dots. -/// Reference: round purple ghost, white bracket eyes, blush, bow, floaty pixels. -fn echo_lines(pose: &RustlePose) -> [Line<'static>; 5] { - let row1 = Line::from(vec![Span::styled(" ▄████▄ ".to_string(), body_style())]); - let (r2l, r2e, r2r) = match pose { - RustlePose::Loading { .. } => (" █[", "", "]█ "), - RustlePose::LookLeft => (" █[", "▘·▘", "]█ "), - RustlePose::LookRight => (" █[", "▝·▝", "]█ "), - _ => (" █[", "▀·▀", "]█ "), +/// **Echo** — round ghost with bracket eyes, blush smile, floaty dots. +fn echo_lines(palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + let row1 = Line::from(Span::styled(" \u{2584}\u{2588}\u{2588}\u{2588}\u{2588}\u{2584} ".to_string(), body_style(palette))); + let row2 = match pose { + RustlePose::Loading { frame } => { + let mut spans = vec![Span::styled(" \u{2588}[".to_string(), body_style(palette))]; + spans.extend(loading_eye_spans(palette, *frame)); + spans.push(Span::styled("]\u{2588} ".to_string(), body_style(palette))); + Line::from(spans) + } + RustlePose::Static => { + let mut spans = vec![Span::styled(" \u{2588}[".to_string(), body_style(palette))]; + spans.extend(eye_spans(palette, "\u{2580}\u{00b7}\u{2580}")); + spans.push(Span::styled("]\u{2588} ".to_string(), body_style(palette))); + Line::from(spans) + } }; - let mut row2_spans = vec![Span::styled(r2l.to_string(), body_style())]; - if let RustlePose::Loading { frame } = pose { - row2_spans.extend(loading_eye_spans(*frame)); - } else { - row2_spans.extend(eye_spans(r2e)); - } - row2_spans.push(Span::styled(r2r.to_string(), body_style())); - let row3 = Line::from(vec![Span::styled(" █ ‿ █ ".to_string(), body_style())]); - let row4 = if let RustlePose::Loading { frame } = pose { - let dots = [" ▀▄▀▄▀ ···", " ▀▄▀▄▀ ·· ", " ▀▄▀▄▀ · ", " ▀▄▀▄▀ "]; - Line::from(vec![Span::styled(dots[(frame / 8) as usize % 4].to_string(), accent_style())]) - } else { - Line::from(vec![Span::styled(" ▀▄▀▄▀ ··· ".to_string(), accent_style())]) + let row3 = Line::from(Span::styled(" \u{2588} \u{203f} \u{2588} ".to_string(), body_style(palette))); + let row4 = match pose { + RustlePose::Loading { frame } => { + let dots = [ + " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7}\u{00b7}\u{00b7}", + " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7}\u{00b7} ", + " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7} ", + " \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} ", + ]; + Line::from(Span::styled(dots[(*frame / 8) as usize % 4].to_string(), accent_style(palette))) + } + RustlePose::Static => Line::from(Span::styled(" \u{2580}\u{2584}\u{2580}\u{2584}\u{2580} \u{00b7}\u{00b7}\u{00b7}".to_string(), accent_style(palette))), }; - [row1, Line::from(row2_spans), row3, row4, Line::from("")] + [row1, row2, row3, row4, Line::from("")] } // ── Public API ──────────────────────────────────────────────────────────────── -/// Returns 5 `Line` values for the given familiar and pose. +/// Render the 5-line glyph block for a built-in archetype. /// -/// `familiar` should be the lowercase familiar ID from `config.familiar` -/// (e.g. `"kitty"`, `"nova"`, `"cody"`…). `None` or unknown values fall -/// back to `kitty`. -pub fn rustle_lines_for(familiar: Option<&str>, pose: &RustlePose) -> [Line<'static>; 5] { - match familiar.unwrap_or("kitty") { - "nova" => nova_lines(pose), - "cody" => cody_lines(pose), - "charm" => charm_lines(pose), - "sage" => sage_lines(pose), - "astra" => astra_lines(pose), - "echo" => echo_lines(pose), - _ => kitty_lines(pose), // "kitty" + default +/// Sigil archetypes ([`Archetype::SigilCrystal`] etc.) are handled by +/// [`crate::familiar_card`] directly, so they will hit `kitty_lines` here as +/// a safe fallback if they ever route through this path by accident. +pub fn archetype_lines(arch: Archetype, palette: &FamiliarPalette, pose: &RustlePose) -> [Line<'static>; 5] { + match arch { + Archetype::Cat => kitty_lines(palette, pose), + Archetype::SorceressCrown => nova_lines(palette, pose), + Archetype::Robot => cody_lines(palette, pose), + Archetype::Heart => charm_lines(palette, pose), + Archetype::WizardBook => sage_lines(palette, pose), + Archetype::Moon => astra_lines(palette, pose), + Archetype::Ghost => echo_lines(palette, pose), + Archetype::SigilCrystal + | Archetype::SigilHex + | Archetype::SigilRune + | Archetype::SigilSeal => kitty_lines(palette, pose), } } -/// Legacy entry point — defaults to kitty. Kept for call-sites that -/// don't yet thread the familiar name through. -pub fn rustle_lines(pose: &RustlePose) -> [Line<'static>; 5] { - kitty_lines(pose) +/// Legacy entry point — resolve a familiar slug to its theme and render the +/// glyph block. Keeps the old function name so any straggling caller can +/// keep working; new code should go through [`crate::familiar_card::render_card`]. +pub fn rustle_lines_for(familiar: Option<&str>, pose: &RustlePose) -> [Line<'static>; 5] { + let id = familiar.unwrap_or("kitty"); + let theme = crate::familiar_theme::resolve(id, &[]); + archetype_lines(theme.archetype, &theme.palette, pose) } #[cfg(test)] mod tests { use super::*; + use crate::familiar_theme; fn line_text(line: &Line<'_>) -> String { line.spans.iter().map(|s| s.content.as_ref()).collect::>().join("") } - // Width assertions — all familiars, all non-loading poses - fn check_width(name: &str, pose: &RustlePose) { - let lines = rustle_lines_for(Some(name), pose); - for (i, line) in lines.iter().enumerate().take(4) { - let text = line_text(line); - let width = text.chars().count(); - // Allow 10–12 chars (some glyphs use Unicode combining/width) - assert!( - (9..=12).contains(&width), - "familiar={name} pose={pose:?} row={i} width={width} text={text:?}" - ); - } - } - #[test] - fn kitty_default_eye_row() { - let lines = rustle_lines_for(Some("kitty"), &RustlePose::Default); - // Row 1 (index 1) is the eye row: square eye markers on wide face. - let text = line_text(&lines[1]); - assert!(text.contains("◈"), "default eye row should contain square-eye marker: {text:?}"); - } - - #[test] - fn kitty_arms_up_whiskers_tilt() { - let lines = rustle_lines_for(Some("kitty"), &RustlePose::ArmsUp); - // ArmsUp shares the same eye row as Default for the new design. - let text = line_text(&lines[1]); - assert!(!text.is_empty(), "arms-up eye row should not be empty: {text:?}"); - } - - #[test] - fn all_familiars_all_poses_have_reasonable_width() { + fn static_pose_renders_all_familiars() { let familiars = ["kitty", "nova", "cody", "charm", "sage", "astra", "echo"]; - let poses = [ - RustlePose::Default, - RustlePose::ArmsUp, - RustlePose::LookLeft, - RustlePose::LookRight, - RustlePose::LookDown, - ]; for fam in &familiars { - for pose in &poses { - check_width(fam, pose); - } + let lines = rustle_lines_for(Some(fam), &RustlePose::Static); + assert_eq!(lines.len(), 5, "familiar {fam} should produce 5 rows"); + let row0 = line_text(&lines[0]); + assert!(!row0.trim().is_empty(), "familiar {fam} row 0 should not be blank: {row0:?}"); } } + #[test] + fn loading_pose_drives_frame_dependent_output() { + // Different frame values should produce at least one frame where the + // visible text differs, proving the spinner is actually frame-driven. + let a = rustle_lines_for(Some("kitty"), &RustlePose::Loading { frame: 0 }); + let b = rustle_lines_for(Some("kitty"), &RustlePose::Loading { frame: 5 }); + let txt_a = line_text(&a[1]); + let txt_b = line_text(&b[1]); + assert_ne!(txt_a, txt_b, "loading row should differ between frames"); + } + #[test] fn unknown_familiar_falls_back_to_kitty() { - let a = rustle_lines_for(Some("unknown_xxx"), &RustlePose::Default); - let b = rustle_lines_for(Some("kitty"), &RustlePose::Default); + let a = rustle_lines_for(Some("unknown_xxx"), &RustlePose::Static); + let b = rustle_lines_for(Some("kitty"), &RustlePose::Static); assert_eq!(line_text(&a[0]), line_text(&b[0])); } #[test] fn none_familiar_falls_back_to_kitty() { - let a = rustle_lines_for(None, &RustlePose::Default); - let b = rustle_lines_for(Some("kitty"), &RustlePose::Default); + let a = rustle_lines_for(None, &RustlePose::Static); + let b = rustle_lines_for(Some("kitty"), &RustlePose::Static); assert_eq!(line_text(&a[0]), line_text(&b[0])); } + + #[test] + fn archetype_dispatcher_respects_palette() { + // Different palettes produce spans whose styles differ; the glyph + // shape itself stays the same. + let theme_a = familiar_theme::resolve("kitty", &[]); + let theme_b = familiar_theme::resolve("nova", &[]); + let a = archetype_lines(theme_a.archetype, &theme_a.palette, &RustlePose::Static); + let b = archetype_lines(theme_b.archetype, &theme_b.palette, &RustlePose::Static); + // Distinct archetypes → distinct row 0 content. + assert_ne!(line_text(&a[0]), line_text(&b[0])); + } }