Skip to main content

lean_ctx/core/buddy/
format.rs

1use super::rpg::format_compact;
2use super::sprite::sprite_lines_for_tick;
3use super::types::{BuddyState, Rarity, Species};
4
5/// Inner height (sprite rows) of the framed portrait. The sprite is vertically
6/// centered within this many rows so every evolution stage lines up.
7const PORTRAIT_H: usize = 8;
8/// Visual width of the info column to the right of the portrait.
9const RIGHT_W: usize = 44;
10/// Max width of full-width caption lines below the card body.
11const CAPTION_W: usize = 66;
12
13/// Element foreground colour, gated by `NO_COLOR` so plain output stays clean.
14fn elem_ansi(state: &BuddyState) -> &'static str {
15    if super::super::theme::no_color() {
16        ""
17    } else {
18        state.species.element_color()
19    }
20}
21
22/// Rarity foreground colour, gated by `NO_COLOR`.
23fn rarity_ansi(state: &BuddyState) -> &'static str {
24    if super::super::theme::no_color() {
25        ""
26    } else {
27        state.rarity.color_code()
28    }
29}
30
31/// The colour the creature itself is drawn in: its element normally, but a
32/// shifting cosmic hue once it begins ascending (each prestige tier recolours
33/// it). Gated by `NO_COLOR`.
34fn creature_color(state: &BuddyState) -> &'static str {
35    if super::super::theme::no_color() {
36        ""
37    } else if state.prestige > 0 {
38        super::ascension::color(state.prestige)
39    } else {
40        state.species.element_color()
41    }
42}
43
44/// Ascension rank colour for prestige badges/bars, gated by `NO_COLOR`.
45fn ascension_ansi(tier: u32) -> &'static str {
46    if super::super::theme::no_color() {
47        ""
48    } else {
49        super::ascension::color(tier)
50    }
51}
52
53pub fn format_buddy_block(state: &BuddyState, theme: &super::super::theme::Theme) -> String {
54    format_buddy_block_at(state, theme, None)
55}
56
57/// Collector-card layout for the gain --deep dashboard: a framed pixel-art
58/// portrait on the left, nameplate + element type + rarity pips + RPG stat bars
59/// on the right, with mood, speech and achievement badges underneath.
60pub fn format_buddy_block_at(
61    state: &BuddyState,
62    theme: &super::super::theme::Theme,
63    tick: Option<u64>,
64) -> String {
65    let r = super::super::theme::rst();
66    let dim = super::super::theme::dim();
67
68    let sprite = sprite_lines_for_tick(state, tick);
69    let portrait = portrait_box(sprite, creature_color(state));
70    let right = build_right_column(state, theme);
71
72    let rows = portrait.len().max(right.len());
73    let mut out = Vec::with_capacity(rows + 6);
74    out.push(String::new());
75
76    for i in 0..rows {
77        let pl = portrait.get(i).map_or("", String::as_str);
78        let pl = super::super::theme::pad_right(pl, super::mascot_art::width() + 2);
79        let rl = right.get(i).map_or("", String::as_str);
80        out.push(format!("  {pl}  {rl}"));
81    }
82
83    out.push(String::new());
84    let mc = mood_color(theme, &state.mood);
85    let mood_line = if state.bugs_prevented > 0 {
86        format!(
87            "{mc}{} {}{r}{dim}  ยท  {} bugs caught{r}",
88            state.mood.icon(),
89            state.mood.label(),
90            state.bugs_prevented,
91        )
92    } else {
93        format!("{mc}{} {}{r}", state.mood.icon(), state.mood.label())
94    };
95    out.push(format!(
96        "  {}",
97        super::super::theme::truncate_visual(&mood_line, CAPTION_W)
98    ));
99    // Speech tied to the mood face with a little tail, like the creature is talking.
100    let speech = format!(
101        "{dim}\u{2570}\u{2500}{r} {dim}\u{201c}{}\u{201d}{r}",
102        state.speech
103    );
104    out.push(format!(
105        "  {}",
106        super::super::theme::truncate_visual(&speech, CAPTION_W)
107    ));
108
109    append_badges(&mut out, state, theme);
110
111    out.push(String::new());
112    out.join("\n")
113}
114
115/// Build the framed pixel portrait. Each returned line has visual width
116/// `mascot width + 2` (frame on both sides); the block is `PORTRAIT_H + 2` tall.
117fn portrait_box(sprite: &[String], element_color: &str) -> Vec<String> {
118    let r = super::super::theme::rst();
119    let w = super::mascot_art::width();
120    let top = format!("{element_color}\u{256d}{}\u{256e}{r}", "\u{2500}".repeat(w));
121    let bottom = format!("{element_color}\u{2570}{}\u{256f}{r}", "\u{2500}".repeat(w));
122
123    let h = sprite.len().min(PORTRAIT_H);
124    let top_pad = (PORTRAIT_H - h) / 2;
125
126    let mut lines = Vec::with_capacity(PORTRAIT_H + 2);
127    lines.push(top);
128    for i in 0..PORTRAIT_H {
129        let idx = i as isize - top_pad as isize;
130        let body = if idx >= 0 && (idx as usize) < sprite.len() {
131            sprite[idx as usize].as_str()
132        } else {
133            ""
134        };
135        let padded = super::super::theme::pad_right(body, w);
136        lines.push(format!(
137            "{element_color}\u{2502}{element_color}{padded}{element_color}\u{2502}{r}"
138        ));
139    }
140    lines.push(bottom);
141    lines
142}
143
144/// The right info column: exactly `PORTRAIT_H + 2` lines, each padded to
145/// [`RIGHT_W`] so the portrait and info stay column-aligned.
146fn build_right_column(state: &BuddyState, theme: &super::super::theme::Theme) -> Vec<String> {
147    let r = super::super::theme::rst();
148    let bold = super::super::theme::bold();
149
150    let rarity_color = rarity_ansi(state);
151
152    let name = format!(
153        "{}{bold}{}{r}",
154        creature_color(state),
155        super::super::theme::truncate_visual(&state.name, 26)
156    );
157    let stars = rarity_pips(&state.rarity);
158
159    let type_line = nameplate_form_line(state, theme);
160    let rarity_label = format!("{rarity_color}{}{r}", state.rarity.label());
161
162    let mut lines = Vec::with_capacity(PORTRAIT_H + 2);
163    lines.push(right_align(&name, &stars, RIGHT_W));
164    lines.push(right_align(&type_line, &rarity_label, RIGHT_W));
165    lines.push(String::new());
166    lines.push(super::super::theme::pad_right(
167        &progression_bar(state, theme, 12),
168        RIGHT_W,
169    ));
170    lines.push(String::new());
171    lines.push(super::super::theme::pad_right(
172        &metric_value(
173            "saved",
174            &format!("{} tokens", format_compact(state.tokens_saved)),
175        ),
176        RIGHT_W,
177    ));
178    lines.push(super::super::theme::pad_right(
179        &metric_pct(theme, "compression", state.compression_pct),
180        RIGHT_W,
181    ));
182    lines.push(super::super::theme::pad_right(
183        &metric_pct(theme, "cache", state.cache_hit_rate),
184        RIGHT_W,
185    ));
186    lines.push(super::super::theme::pad_right(
187        &metric_value("streak", &format!("{} days", state.streak_days)),
188        RIGHT_W,
189    ));
190    lines
191}
192
193/// Second nameplate line: the buddy's element flavour (omitted for the neutral
194/// "Null" element so it never reads as a confusing word) plus its current form
195/// on the *endless* ladder โ€” the cosmic ascension rank with its tier star once
196/// ascending, otherwise the evolution stage. The form is never a dead-end nor a
197/// low-stage word at a high level.
198fn nameplate_form_line(state: &BuddyState, theme: &super::super::theme::Theme) -> String {
199    let r = super::super::theme::rst();
200    let bold = super::super::theme::bold();
201    let dim = super::super::theme::dim();
202    let m = theme.muted.fg();
203
204    let form = if state.prestige > 0 {
205        format!(
206            "{}\u{2605}{} {bold}{}{r}",
207            ascension_ansi(state.prestige),
208            state.prestige,
209            state.form,
210        )
211    } else {
212        format!("{}{bold}{}{r}", theme.accent.fg(), state.form)
213    };
214
215    if matches!(state.species, Species::Egg) {
216        form
217    } else {
218        format!(
219            "{}{}{r} {m}{}{r}  {dim}\u{00b7}{r}  {form}",
220            elem_ansi(state),
221            state.species.element_glyph(),
222            state.species.element_name(),
223        )
224    }
225}
226
227/// A labelled key/value metric line for the companion stat sheet. The label sits
228/// in a fixed dim column so values line up; the value is emphasised.
229fn metric_value(label: &str, value: &str) -> String {
230    let r = super::super::theme::rst();
231    let dim = super::super::theme::dim();
232    let bold = super::super::theme::bold();
233    let label_col = super::super::theme::pad_right(&format!("{dim}{label}{r}"), 13);
234    format!("{label_col}{bold}{value}{r}")
235}
236
237/// A labelled percentage metric with a compact fixed-width bar (lean-ctx efficiency).
238fn metric_pct(theme: &super::super::theme::Theme, label: &str, pct: u8) -> String {
239    let r = super::super::theme::rst();
240    let dim = super::super::theme::dim();
241    let label_col = super::super::theme::pad_right(&format!("{dim}{label}{r}"), 13);
242    let filled = (pct as usize * 8) / 100;
243    let empty = 8 - filled;
244    let pc = theme.pct_color(f64::from(pct));
245    format!(
246        "{label_col}{pc}{}{dim}{}{r} {pc}{pct:>3}%{r}",
247        "\u{2588}".repeat(filled),
248        "\u{2591}".repeat(empty),
249    )
250}
251
252/// Render the achievements footer: a progress header plus badges laid out in
253/// tidy fixed-width columns. Variation selectors are stripped so emoji keep a
254/// stable terminal width and the columns stay aligned.
255fn append_badges(out: &mut Vec<String>, state: &BuddyState, theme: &super::super::theme::Theme) {
256    if state.achievement_badges.is_empty() {
257        return;
258    }
259    let r = super::super::theme::rst();
260    let bold = super::super::theme::bold();
261    let dim = super::super::theme::dim();
262    let m = theme.muted.fg();
263    let a = theme.accent.fg();
264
265    let total = super::achievements::catalog().len();
266    let got = state.achievement_badges.len();
267
268    out.push(String::new());
269    const BAR_W: usize = 12;
270    let filled = (got * BAR_W) / total.max(1);
271    out.push(format!(
272        "  {dim}achievements{r}  {a}{}{dim}{}{r}  {bold}{got}{r}{dim}/{total}{r}",
273        "\u{2588}".repeat(filled),
274        "\u{2591}".repeat(BAR_W.saturating_sub(filled)),
275    ));
276
277    const COLS: usize = 3;
278    const CELL: usize = 21;
279    let mut col = 0usize;
280    let mut line = String::from("  ");
281    for badge in &state.achievement_badges {
282        if col == COLS {
283            out.push(std::mem::replace(&mut line, String::from("  ")));
284            col = 0;
285        }
286        line.push_str(&badge_cell(badge, CELL, &m, r));
287        col += 1;
288    }
289    if col > 0 {
290        out.push(line);
291    }
292}
293
294/// Format one achievement as a fixed-width grid cell. Each badge has exactly one
295/// emoji icon assumed to render double-width; because that assumption is applied
296/// uniformly the columns line up regardless of the terminal's emoji metrics.
297fn badge_cell(badge: &str, width: usize, color: &str, r: &str) -> String {
298    let stripped = strip_vs16(badge);
299    let name = stripped.split_once(' ').map_or("", |(_, n)| n);
300    let content_w = 2 + 1 + name.chars().count();
301    let pad = width.saturating_sub(content_w);
302    format!("{color}{stripped}{r}{}", " ".repeat(pad))
303}
304
305/// Element/theme colour expressing the buddy's current mood.
306fn mood_color(theme: &super::super::theme::Theme, mood: &super::types::Mood) -> String {
307    use super::types::Mood;
308    match mood {
309        Mood::Ecstatic => theme.success.fg(),
310        Mood::Happy => theme.secondary.fg(),
311        Mood::Content => theme.accent.fg(),
312        Mood::Worried => theme.warning.fg(),
313        Mood::Sleeping => theme.muted.fg(),
314    }
315}
316
317/// Strip Unicode variation selectors (U+FE0F) so emoji render at a stable
318/// monospace width across terminals, keeping column alignment intact.
319fn strip_vs16(s: &str) -> String {
320    s.chars().filter(|&c| c != '\u{fe0f}').collect()
321}
322
323/// Left text + right text padded so the whole thing is exactly `w` visual cols.
324fn right_align(left: &str, right: &str, w: usize) -> String {
325    let rv = super::super::theme::visual_len(right);
326    let left = if super::super::theme::visual_len(left) + rv + 1 > w {
327        super::super::theme::truncate_visual(left, w.saturating_sub(rv + 1))
328    } else {
329        left.to_string()
330    };
331    let lv = super::super::theme::visual_len(&left);
332    let gap = w.saturating_sub(lv + rv);
333    format!("{left}{}{right}", " ".repeat(gap))
334}
335
336fn rarity_pips(rarity: &Rarity) -> String {
337    let r = super::super::theme::rst();
338    let dim = super::super::theme::dim();
339    let color = if super::super::theme::no_color() {
340        ""
341    } else {
342        rarity.color_code()
343    };
344    let filled = match rarity {
345        Rarity::Egg => 0,
346        Rarity::Common => 1,
347        Rarity::Uncommon => 2,
348        Rarity::Rare => 3,
349        Rarity::Epic => 4,
350        Rarity::Legendary => 5,
351    };
352    format!(
353        "{color}{}{r}{dim}{}{r}",
354        "\u{25c6}".repeat(filled),
355        "\u{25c7}".repeat(5 - filled),
356    )
357}
358
359/// Progression bar. Before Mythic it tracks evolution toward the next stage;
360/// at Mythic it switches to the *endless* ascension ladder โ€” there is always a
361/// next cosmic rank, so it never shows a dead-end "MAX".
362fn progression_bar(state: &BuddyState, theme: &super::super::theme::Theme, width: usize) -> String {
363    let r = super::super::theme::rst();
364    let dim = super::super::theme::dim();
365    let a = theme.accent.fg();
366
367    if let Some(next) = state.evolution.next() {
368        let progress = state.evolution.progress(state.level);
369        let bar = theme.gradient_bar(progress, width);
370        return format!(
371            "{dim}evo{r} {bar} {a}{:.0}%{r} {dim}\u{2192} {}{r}",
372            progress * 100.0,
373            next.label()
374        );
375    }
376
377    // Mythic โ†’ infinite ascension. Always a next tier.
378    let next_tier = state.prestige + 1;
379    let progress = super::ascension::progress(state.xp);
380    let bar = theme.gradient_bar(progress, width);
381    format!(
382        "{dim}ascend{r} {bar} {}\u{2605}{}{r} {dim}{:.0}% \u{2192} {}{r}",
383        ascension_ansi(next_tier),
384        next_tier,
385        progress * 100.0,
386        super::ascension::title(next_tier),
387    )
388}
389
390pub fn format_buddy_full(state: &BuddyState, theme: &super::super::theme::Theme) -> String {
391    let rst = super::super::theme::rst();
392    let accent = theme.accent.fg();
393    let muted = theme.muted.fg();
394    let bold = super::super::theme::bold();
395    let dim = super::super::theme::dim();
396    let rarity_color = rarity_ansi(state);
397    let body_color = creature_color(state);
398
399    let mut out = Vec::new();
400
401    let rank_label = if state.prestige > 0 {
402        format!(
403            "{}\u{2605}{} {}{rst}",
404            ascension_ansi(state.prestige),
405            state.prestige,
406            state.form,
407        )
408    } else {
409        format!("{muted}{} {}{rst}", state.evolution.icon(), state.form)
410    };
411
412    out.push(String::new());
413    out.push(format!("  {bold}{accent}Pixel Sprite{rst}  {rank_label}"));
414    out.push(String::new());
415
416    for line in &state.ascii_art {
417        out.push(format!("    {body_color}{line}{rst}"));
418    }
419    out.push(String::new());
420
421    // Element flavour is omitted for the neutral "Null" element so the line never
422    // reads as a confusing "the Null-type".
423    let element_phrase = if matches!(state.species, Species::Egg) {
424        String::new()
425    } else {
426        format!("{muted}the {}-type{rst}  ", state.species.element_name())
427    };
428    out.push(format!(
429        "  {bold}{body_color}{}{rst}  {element_phrase}{rarity_color}{}{rst}",
430        state.name,
431        state.rarity.label(),
432    ));
433    out.push(format!(
434        "  {muted}Mood: {}  |  Streak: {}d  |  Bugs prevented: {}{rst}",
435        state.mood.label(),
436        state.streak_days,
437        state.bugs_prevented,
438    ));
439
440    let evo_bar = progression_bar(state, theme, 16);
441    out.push(format!("  {evo_bar}"));
442    out.push(String::new());
443
444    out.push(format!("  {bold}Efficiency{rst}"));
445    out.push(format!(
446        "  {}",
447        metric_value(
448            "saved",
449            &format!("{} tokens", format_compact(state.tokens_saved))
450        )
451    ));
452    out.push(format!(
453        "  {}",
454        metric_pct(theme, "compression", state.compression_pct)
455    ));
456    out.push(format!(
457        "  {}",
458        metric_pct(theme, "cache", state.cache_hit_rate)
459    ));
460    out.push(String::new());
461
462    if !state.achievement_badges.is_empty() {
463        let total = super::achievements::catalog().len();
464        let got = state.achievement_badges.len();
465        out.push(format!(
466            "  {bold}Achievements{rst}  {muted}{got}/{total}{rst}"
467        ));
468        let mut col = 0usize;
469        let mut line = String::from("  ");
470        for badge in &state.achievement_badges {
471            if col == 3 {
472                out.push(std::mem::replace(&mut line, String::from("  ")));
473                col = 0;
474            }
475            line.push_str(&badge_cell(badge, 21, &muted, rst));
476            col += 1;
477        }
478        if col > 0 {
479            out.push(line);
480        }
481        out.push(String::new());
482    }
483
484    out.push(format!("  {dim}\"{}\"{rst}", state.speech));
485    out.push(String::new());
486
487    out.join("\n")
488}
489
490pub(super) fn detect_project_root_for_buddy() -> String {
491    if let Some(session) = super::super::session::SessionState::load_latest() {
492        if let Some(root) = session.project_root.as_deref()
493            && !root.trim().is_empty()
494        {
495            return root.to_string();
496        }
497        if let Some(cwd) = session.shell_cwd.as_deref()
498            && !cwd.trim().is_empty()
499        {
500            return super::super::protocol::detect_project_root_or_cwd(cwd);
501        }
502        if let Some(last) = session.files_touched.last()
503            && !last.path.trim().is_empty()
504            && let Some(parent) = std::path::Path::new(&last.path).parent()
505        {
506            let p = parent.to_string_lossy().to_string();
507            return super::super::protocol::detect_project_root_or_cwd(&p);
508        }
509    }
510    std::env::current_dir()
511        .map(|p| super::super::protocol::detect_project_root_or_cwd(&p.to_string_lossy()))
512        .unwrap_or_default()
513}