fix(tui): faceless silhouette default; center avatar on Welcome
Three corrections after first walkthrough:
1) The hand-crafted "Annie face" palette grid read as a creepy llama
(Casey's words). Replaced with a faceless silhouette — hair, neck,
collar, no eyes/brow/mouth. ASCII portraits at this resolution
should be a signal that no image is loaded, not a default depiction.
Per-pixel row-swap overlays for blink/yawn/strain/processing are
gone too; state now expresses through:
- border color (posture_border)
- cyan-leaning luminance breath pulse (modulate)
- across-the-image desaturation on strain, dim on yawn,
warm tint on affection.
When a per-agent PortraitSource is loaded (PNG/JPEG from agent
memfs assets/), pixels come from the source and the silhouette is
not drawn. The source pixels get the same posture modulation pass.
2) The "press ENTER to wake <Agent>" line on the welcome avatar was
misleading — the welcome menu owns the keyboard, not the avatar.
Welcome's `draw_welcome` in presence.rs is removed entirely; the
avatar is now rendered inline by `App::draw_welcome` so it can
participate in the welcome vertical layout.
3) Avatar moved from upper-right corner to a centered, framed card
directly under the title. The avatar IS the "your agent is loaded"
indicator; the menu beneath reads as actions on that agent.
Subconscious-active state still expresses through the ◈ glyph and
border color (cyan when active, dim when idle).
`Screen::Presence` (hotkey `p`) keeps its meaning as the fullscreen
"sit with her" view — natural future home for TTS/STT triggers.
Also saves two memory entries:
- feedback_portrait_aesthetics: ASCII portraits are creepy at this
resolution; default to PNG; state via color+breath, not row swaps.
- reference_nannou: Casey wants to lean on nannou (Rust creative-coding,
wgpu) more frequently for graphics outside the TUI.
Build clean.
This commit is contained in:
parent
543ae50103
commit
fb520ebcaf
3 changed files with 159 additions and 272 deletions
|
|
@ -28,7 +28,7 @@ use tracing::info;
|
|||
use crate::core::config::ConsciousnessConfig;
|
||||
use crate::ui::chat::{ChatState, draw as draw_chat};
|
||||
use crate::ui::cockpit_panel::CockpitPane;
|
||||
use crate::ui::presence::{Presence, draw_overlay as draw_presence_overlay, draw_welcome as draw_presence_welcome};
|
||||
use crate::ui::presence::{Presence, draw_overlay as draw_presence_overlay};
|
||||
use crate::ui::color_support::rgb;
|
||||
use crate::ui::component::{Component, Scene, SceneLayout, TuiEvent};
|
||||
use crate::backend::BackendEvent;
|
||||
|
|
@ -981,21 +981,29 @@ impl App {
|
|||
}
|
||||
|
||||
fn draw_welcome(&self, frame: &mut Frame) {
|
||||
use crate::ui::portrait;
|
||||
|
||||
let area = frame.size();
|
||||
|
||||
// Background
|
||||
let bg = Block::default().style(Style::default().bg(Color::Black));
|
||||
frame.render_widget(bg, area);
|
||||
|
||||
// Avatar card occupies its own row in the welcome stack — centered,
|
||||
// framed, modest. It IS the "your agent is loaded" indicator. The
|
||||
// menu beneath then reads as actions on that agent.
|
||||
let avatar_card_w: u16 = portrait::RENDER_W + 2;
|
||||
let avatar_card_h: u16 = portrait::RENDER_H + 3;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(2)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(12),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(2), // top breathing room
|
||||
Constraint::Length(4), // title + subtitle
|
||||
Constraint::Length(avatar_card_h), // centered avatar
|
||||
Constraint::Min(8), // menu
|
||||
Constraint::Length(3), // footer
|
||||
])
|
||||
.split(area);
|
||||
|
||||
|
|
@ -1018,6 +1026,57 @@ impl App {
|
|||
.alignment(Alignment::Center);
|
||||
frame.render_widget(title, chunks[1]);
|
||||
|
||||
// Centered avatar card under the title.
|
||||
if chunks[2].width >= avatar_card_w {
|
||||
let card_x = chunks[2].x + (chunks[2].width - avatar_card_w) / 2;
|
||||
let card_y = chunks[2].y;
|
||||
let card_area = Rect {
|
||||
x: card_x,
|
||||
y: card_y,
|
||||
width: avatar_card_w,
|
||||
height: avatar_card_h.min(chunks[2].height),
|
||||
};
|
||||
let border_col = if self.presence.subconscious_active {
|
||||
Color::Rgb(120, 200, 220)
|
||||
} else {
|
||||
Color::Rgb(120, 130, 150)
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_col).add_modifier(Modifier::DIM));
|
||||
frame.render_widget(block, card_area);
|
||||
|
||||
let portrait_area = Rect {
|
||||
x: card_area.x + 1,
|
||||
y: card_area.y + 1,
|
||||
width: portrait::RENDER_W,
|
||||
height: portrait::RENDER_H,
|
||||
};
|
||||
portrait::render(frame.buffer_mut(), portrait_area, &self.presence);
|
||||
|
||||
let name_area = Rect {
|
||||
x: card_area.x + 1,
|
||||
y: card_area.y + 1 + portrait::RENDER_H,
|
||||
width: card_area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
let glyph = if self.presence.subconscious_active { "◈" } else { "·" };
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {} ", glyph), Style::default().fg(border_col)),
|
||||
Span::styled(
|
||||
self.presence.name.clone(),
|
||||
Style::default()
|
||||
.fg(border_col)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]))
|
||||
.alignment(Alignment::Center),
|
||||
name_area,
|
||||
);
|
||||
}
|
||||
|
||||
let menu_items = vec![
|
||||
("📊 Dashboard", "See how your agent is doing"),
|
||||
("💬 Chat", "Talk with your agent"),
|
||||
|
|
@ -1076,9 +1135,6 @@ impl App {
|
|||
.style(Style::default().fg(Color::DarkGray))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(footer, chunks[4]);
|
||||
|
||||
// Draw the welcome-screen presence card (no portrait yet — see C2).
|
||||
draw_presence_welcome(frame, &self.presence, area, Some(&self.agent_pref));
|
||||
}
|
||||
|
||||
fn draw_dashboard(&self, frame: &mut Frame) {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ use ratatui::{
|
|||
style::Color,
|
||||
};
|
||||
|
||||
use crate::ui::presence::{Eye, Posture, Presence};
|
||||
use crate::ui::presence::{Posture, Presence};
|
||||
|
||||
// ── Loaded per-agent portrait (Tier 2 source) ───────────────────
|
||||
|
||||
|
|
@ -83,11 +83,6 @@ impl PortraitSource {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rows reserved for state-aware overlays. Pixels in these rows always come
|
||||
/// from the hand-crafted palette grid (and color_for) so that blinking, gaze
|
||||
/// shifts, mouth changes, and brow furrows keep working regardless of which
|
||||
/// portrait source the user loaded.
|
||||
const OVERLAY_ROWS: &[usize] = &[6, 7, 10, 11];
|
||||
|
||||
pub const PORTRAIT_W: u16 = 18;
|
||||
pub const PORTRAIT_H: u16 = 18;
|
||||
|
|
@ -97,99 +92,48 @@ pub const PORTRAIT_H: u16 = 18;
|
|||
pub const RENDER_W: u16 = PORTRAIT_W;
|
||||
pub const RENDER_H: u16 = PORTRAIT_H / 2;
|
||||
|
||||
// ── Base portrait grid ──────────────────────────────────────────
|
||||
// ── Base portrait grid — clean silhouette, no face ──────────────
|
||||
// This is the EMERGENCY default. The hand-crafted "Annie face" version
|
||||
// read as a creepy llama (Casey's words), so this is now a faceless
|
||||
// silhouette: hair, neck, collar. State animation lives in border color,
|
||||
// breath luminance pulse, and posture-driven color modulation — never in
|
||||
// per-pixel row swaps at this resolution. True facial animation belongs
|
||||
// in a future TTS/STT-integrated system, not in half-blocks.
|
||||
//
|
||||
// When a `PortraitSource` is loaded (per-agent PNG/JPEG from agent memfs
|
||||
// `assets/`), all pixels come from the source and this silhouette is not
|
||||
// rendered.
|
||||
//
|
||||
// Each row must be exactly PORTRAIT_W characters wide. Validated by a test.
|
||||
|
||||
const BASE: [&str; PORTRAIT_H as usize] = [
|
||||
"....HH......HH....",
|
||||
"...HHHH....HHHH...",
|
||||
"..HHHHHH..HHHHHH..",
|
||||
"....HHHHHHHHHH....",
|
||||
"...HHHHHHHHHHHH...",
|
||||
"..HHHHHHHHHHHHHH..",
|
||||
"..HHHHHHHHHHHHHH..",
|
||||
"..hhhHHHHHHHHhhh..",
|
||||
".HHHBBssMMssBBHHH.",
|
||||
".HHHsseesseessHHH.",
|
||||
".HHHssssssssssHHH.",
|
||||
".HHHsMssssssMsHHH.",
|
||||
".HHHHssssssssHHHH.",
|
||||
".HHHHsssLLsssHHHH.",
|
||||
".HHHHHssssssHHHHH.",
|
||||
".HHHHHHssssHHHHHH.",
|
||||
".HHHcCcccCcccCHHH.",
|
||||
"..HHHHHHHHHHHHHH..",
|
||||
"..HHHHHHHHHHHHHH..",
|
||||
"..HHHHssssssssHH..",
|
||||
"..HHHssssssssssH..",
|
||||
"..HHHssssssssssH..",
|
||||
"..HHHssssssssssH..",
|
||||
"..HHHssssssssssH..",
|
||||
"..HHHssssssssssH..",
|
||||
"..HHHHsssssssHHH..",
|
||||
"..HHHHHHHHHHHHHH..",
|
||||
"..HHHCCCCCCCCHHH..",
|
||||
"..CCCCCCCCCCCCCC..",
|
||||
"..CCCCCCCCCCCCCC..",
|
||||
"..CCCCcCccCcCCCC..",
|
||||
"..CCCC CCCC..",
|
||||
];
|
||||
|
||||
// ── State-aware pixel lookup ────────────────────────────────────
|
||||
|
||||
/// Read a pixel from the base grid, applying state-driven cell overrides
|
||||
/// before returning. Each posture has its own row swaps so the portrait's
|
||||
/// SHAPE shifts (not just color) when state changes.
|
||||
fn pixel_at(x: usize, y: usize, p: &Presence) -> char {
|
||||
let row = BASE[y];
|
||||
let ch = row.as_bytes()[x] as char;
|
||||
|
||||
// Eye row override — replace `e` with `-` while blinking OR yawning.
|
||||
if y == 7 && ch == 'e' && (p.eye == Eye::Blinking || p.posture == Posture::Yawning) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Posture-specific row swaps.
|
||||
match p.posture {
|
||||
Posture::Yawning => {
|
||||
// Half-lid eyes (already handled above) + open mouth.
|
||||
if y == 11 {
|
||||
let yawn = b".HHHHssOOOOssHHHH.";
|
||||
return yawn[x] as char;
|
||||
}
|
||||
}
|
||||
Posture::Processing => {
|
||||
// Eyes lock to one side: shift the cyan-glow pixels right by 1.
|
||||
// Base eye row: ".HHHsseesseessHHH." — gaze-shift to ".HHHssseseeseesHHH" feel
|
||||
// by stretching one pupil right.
|
||||
if y == 7 {
|
||||
let proc = b".HHHsesesseeseesHH";
|
||||
return proc[x] as char;
|
||||
}
|
||||
// Add a faint cyan filigree pulse on the brow.
|
||||
if y == 6 {
|
||||
let proc = b".HHHBBssMMssBBHHH.";
|
||||
return proc[x] as char;
|
||||
}
|
||||
}
|
||||
Posture::Affectionate => {
|
||||
// Slight smile — lips curve up at the corners.
|
||||
// Base mouth row 11: ".HHHHsssLLsssHHHH."
|
||||
// Affect row 11: ".HHHHsLssssssLsHHH" (lift the L's to the cheek line)
|
||||
if y == 11 {
|
||||
let aff = b".HHHHsssLLsssHHHH.";
|
||||
return aff[x] as char;
|
||||
}
|
||||
// Cheek line gets a soft warm fold above the mouth.
|
||||
if y == 10 {
|
||||
let aff = b".HHHHsssLLsssHHHH.";
|
||||
return aff[x] as char;
|
||||
}
|
||||
}
|
||||
Posture::Straining => {
|
||||
// Furrowed brow — brow row darkens and tightens.
|
||||
// Base row 6: ".HHHBBssMMssBBHHH."
|
||||
// Strain row 6: ".HHBBBssMMssBBBHH." (brow encroaches inward)
|
||||
if y == 6 {
|
||||
let strain = b".HHBBBssMMssBBBHH.";
|
||||
return strain[x] as char;
|
||||
}
|
||||
// Slight frown — straighten the lips.
|
||||
if y == 11 {
|
||||
let strain = b".HHHHsss--sssHHHH.";
|
||||
return strain[x] as char;
|
||||
}
|
||||
}
|
||||
Posture::Idle => {}
|
||||
}
|
||||
|
||||
ch
|
||||
/// Read a pixel from the base silhouette grid. The default silhouette has no
|
||||
/// facial features, so no per-pixel row swaps are needed — state expression
|
||||
/// at this resolution lives in border color, breath luminance pulse, and the
|
||||
/// posture-driven color modulation in [`color_for`].
|
||||
fn pixel_at(x: usize, y: usize, _p: &Presence) -> char {
|
||||
BASE[y].as_bytes()[x] as char
|
||||
}
|
||||
|
||||
// ── Palette ─────────────────────────────────────────────────────
|
||||
|
|
@ -243,23 +187,49 @@ fn color_for(key: char, posture: Posture, breath: f32) -> Option<Color> {
|
|||
|
||||
// ── Render ──────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a single pixel's color, preferring the PortraitSource (if any)
|
||||
/// for non-overlay rows and falling back to the palette grid elsewhere.
|
||||
/// Resolve a single pixel's color: source-when-loaded, modulated by posture
|
||||
/// and breath. With no source the silhouette palette grid is used.
|
||||
fn pixel_color(px: usize, py: usize, p: &Presence, breath: f32) -> Option<Color> {
|
||||
let overlay = OVERLAY_ROWS.contains(&py);
|
||||
// Posture overlays still come from BASE for the affected rows even when a
|
||||
// PortraitSource is loaded — that's how blink / yawn / strain stay visible.
|
||||
if !overlay {
|
||||
if let Some(source) = p.portrait_source.as_ref() {
|
||||
if let Some(c) = source.at(px, py) {
|
||||
return Some(c);
|
||||
}
|
||||
if let Some(source) = p.portrait_source.as_ref() {
|
||||
if let Some(c) = source.at(px, py) {
|
||||
return Some(modulate(c, p.posture, breath));
|
||||
}
|
||||
}
|
||||
let key = pixel_at(px, py, p);
|
||||
color_for(key, p.posture, breath)
|
||||
}
|
||||
|
||||
/// Apply posture-driven modulation to a source pixel — desaturate when
|
||||
/// straining, dim when yawning, breathe luminance on cyan-leaning hues,
|
||||
/// warm-tint on affection. This is how state shows on a loaded portrait
|
||||
/// at half-block resolution: across-the-image tone, not per-pixel swaps.
|
||||
fn modulate(c: Color, posture: Posture, breath: f32) -> Color {
|
||||
let Color::Rgb(r, g, b) = c else { return c };
|
||||
|
||||
let strained = matches!(posture, Posture::Straining);
|
||||
let yawning = matches!(posture, Posture::Yawning);
|
||||
let warm = matches!(posture, Posture::Affectionate);
|
||||
let processing = matches!(posture, Posture::Processing);
|
||||
|
||||
let avg = ((r as u16 + g as u16 + b as u16) / 3) as f32;
|
||||
let sat = if strained { 0.55 } else { 1.0 };
|
||||
let dim = if yawning { 0.82 } else { 1.0 };
|
||||
// Subtle breath pulse — only on the cyan-leaning pixels so skin stays calm.
|
||||
let cyan_lean = b > r && b > g;
|
||||
let breath_gain = if cyan_lean {
|
||||
1.0 + breath * 0.10 * if processing { 1.6 } else { 1.0 }
|
||||
} else { 1.0 };
|
||||
// Warm tint shifts the red/green channels up a little.
|
||||
let warm_r = if warm { 1.06 } else { 1.0 };
|
||||
let warm_g = if warm { 1.02 } else { 1.0 };
|
||||
|
||||
let mix = |c: u8, warm_chan: f32| {
|
||||
let f = (c as f32 * sat + avg * (1.0 - sat)) * dim * breath_gain * warm_chan;
|
||||
f.clamp(0.0, 255.0) as u8
|
||||
};
|
||||
Color::Rgb(mix(r, warm_r), mix(g, warm_g), mix(b, 1.0))
|
||||
}
|
||||
|
||||
/// Render the portrait scaled-up by `scale` (1 = native half-block density).
|
||||
/// Each grid pixel becomes a `scale × scale` square. Use this for the
|
||||
/// presence-mode fullscreen view. Cells outside `area` are skipped.
|
||||
|
|
@ -384,43 +354,6 @@ mod tests {
|
|||
assert_eq!(RENDER_H, PORTRAIT_H / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_at_returns_base_when_idle() {
|
||||
let p = Presence::new("Annie");
|
||||
// Row 7 has eyes at base.
|
||||
let row7 = "_".repeat(PORTRAIT_W as usize);
|
||||
let _ = row7;
|
||||
// Position of an 'e' in BASE[7] = ".HHHsseesseessHHH."
|
||||
// index 5 should be 's', index 6 should be 'e'
|
||||
assert_eq!(pixel_at(6, 7, &p), 'e');
|
||||
assert_eq!(pixel_at(7, 7, &p), 'e');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_at_swaps_eye_to_dash_on_blink() {
|
||||
let mut p = Presence::new("Annie");
|
||||
// Force blink state.
|
||||
p.handle_event(&crate::ui::component::TuiEvent::Tick(200));
|
||||
// Eye is now Blinking; pixel_at row 7 should be '-' where it was 'e'.
|
||||
assert_eq!(pixel_at(6, 7, &p), '-');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_at_swaps_eye_to_dash_on_yawn() {
|
||||
let mut p = Presence::new("Annie");
|
||||
p.handle_event(&crate::ui::component::TuiEvent::PressureChanged(0.9));
|
||||
assert_eq!(pixel_at(6, 7, &p), '-');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yawning_changes_mouth_row() {
|
||||
let mut p = Presence::new("Annie");
|
||||
p.handle_event(&crate::ui::component::TuiEvent::PressureChanged(0.9));
|
||||
// Base row 11: ".HHHHsssLLsssHHHH." — yawn replaces LL with OOOO.
|
||||
// Position 8 in yawn row is 'O'.
|
||||
assert_eq!(pixel_at(8, 11, &p), 'O');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_for_transparent_pixels_returns_none() {
|
||||
assert!(color_for('.', Posture::Idle, 0.5).is_none());
|
||||
|
|
@ -428,21 +361,26 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn straining_desaturates_eye_color() {
|
||||
let idle = color_for('e', Posture::Idle, 0.5).unwrap();
|
||||
let strained = color_for('e', Posture::Straining, 0.5).unwrap();
|
||||
// The eye colour at idle is very blue-leaning; under strain the rgb
|
||||
// channels should drift closer together (toward grey).
|
||||
if let (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) = (idle, strained) {
|
||||
let spread_idle = g1.abs_diff(r1) as u16 + b1.abs_diff(r1) as u16;
|
||||
let spread_strained = g2.abs_diff(r2) as u16 + b2.abs_diff(r2) as u16;
|
||||
assert!(
|
||||
spread_strained < spread_idle,
|
||||
"expected straining to desaturate; got idle spread {} vs strained spread {}",
|
||||
spread_idle, spread_strained
|
||||
);
|
||||
fn modulate_dims_on_yawn() {
|
||||
let base = Color::Rgb(200, 200, 200);
|
||||
let yawn = modulate(base, Posture::Yawning, 0.5);
|
||||
if let Color::Rgb(r, _, _) = yawn {
|
||||
assert!(r < 200, "yawn should dim luminance; got {}", r);
|
||||
} else {
|
||||
panic!("expected RGB colors");
|
||||
panic!("expected RGB");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modulate_desaturates_on_strain() {
|
||||
let blue = Color::Rgb(60, 60, 220);
|
||||
let strained = modulate(blue, Posture::Straining, 0.5);
|
||||
if let Color::Rgb(r, _, b) = strained {
|
||||
// Strain pulls channels toward the average — blue and red should
|
||||
// be closer together than they started.
|
||||
assert!(b - r < 220 - 60, "strain should desaturate");
|
||||
} else {
|
||||
panic!("expected RGB");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,8 +125,9 @@ impl VolitionGauge {
|
|||
/// The Presence — Annie's felt-state in the TUI.
|
||||
///
|
||||
/// Updated via [`Presence::handle_event`] in response to `TuiEvent`s; never polled.
|
||||
/// Rendered via [`draw_overlay`] / [`draw_welcome`] as a corner card today, with
|
||||
/// half-block portraits arriving in C2.
|
||||
/// Rendered via [`draw_overlay`] as a corner card on Dashboard; the Welcome
|
||||
/// screen draws its centered portrait inline in `App::draw_welcome` so it can
|
||||
/// participate in the welcome vertical layout (title → avatar → menu → footer).
|
||||
pub struct Presence {
|
||||
pub name: String,
|
||||
pub posture: Posture,
|
||||
|
|
@ -354,117 +355,9 @@ pub fn draw_overlay(frame: &mut Frame, p: &Presence, area: Rect) {
|
|||
frame.render_widget(name_para, name_area);
|
||||
}
|
||||
|
||||
/// Draw the presence on the welcome screen — portrait card with a short
|
||||
/// instruction strip beneath it.
|
||||
pub fn draw_welcome(
|
||||
frame: &mut Frame,
|
||||
p: &Presence,
|
||||
area: Rect,
|
||||
selected_agent: Option<&str>,
|
||||
) {
|
||||
if !p.visible {
|
||||
return;
|
||||
}
|
||||
|
||||
// Total card height = portrait card + 4 rows of instruction text.
|
||||
let total_h: u16 = CARD_H + 4;
|
||||
if area.width < CARD_W + 2 || area.height < total_h + 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let card_x = area.x + area.width.saturating_sub(CARD_W + 1);
|
||||
let card_y = area.y + 1;
|
||||
|
||||
// Portrait card
|
||||
let card_area = Rect {
|
||||
x: card_x,
|
||||
y: card_y,
|
||||
width: CARD_W,
|
||||
height: CARD_H,
|
||||
};
|
||||
let border = posture_border(p.posture);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border).add_modifier(Modifier::DIM));
|
||||
frame.render_widget(block, card_area);
|
||||
|
||||
let portrait_area = Rect {
|
||||
x: card_area.x + 1,
|
||||
y: card_area.y + 1,
|
||||
width: portrait::RENDER_W,
|
||||
height: portrait::RENDER_H,
|
||||
};
|
||||
portrait::render(frame.buffer_mut(), portrait_area, p);
|
||||
|
||||
let name_area = Rect {
|
||||
x: card_area.x + 1,
|
||||
y: card_area.y + 1 + portrait::RENDER_H,
|
||||
width: card_area.width.saturating_sub(2),
|
||||
height: 1,
|
||||
};
|
||||
let display_name = selected_agent.unwrap_or(&p.name);
|
||||
let glyph = if p.subconscious_active { "◈" } else { "·" };
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(format!(" {} ", glyph), Style::default().fg(border)),
|
||||
Span::styled(
|
||||
display_name.to_string(),
|
||||
Style::default().fg(border).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]))
|
||||
.alignment(Alignment::Center),
|
||||
name_area,
|
||||
);
|
||||
|
||||
// Instruction strip beneath the portrait
|
||||
let info_area = Rect {
|
||||
x: card_x,
|
||||
y: card_y + CARD_H,
|
||||
width: CARD_W,
|
||||
height: 4,
|
||||
};
|
||||
|
||||
let info_lines = if let Some(agent_name) = selected_agent {
|
||||
vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Status ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("Ready", Style::default().fg(Color::Green)),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"ENTER",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" to wake ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(agent_name, Style::default().fg(colors::ANI_SECONDARY)),
|
||||
]),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::from(Span::styled(
|
||||
" No agent selected",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
"a",
|
||||
Style::default()
|
||||
.fg(colors::ANI_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" to create alias", Style::default().fg(Color::DarkGray)),
|
||||
]),
|
||||
]
|
||||
};
|
||||
frame.render_widget(Paragraph::new(info_lines), info_area);
|
||||
}
|
||||
// Welcome rendering moved inline into `App::draw_welcome` (src/ui/app.rs)
|
||||
// so the avatar can be centered within the welcome layout instead of pinned
|
||||
// to a corner.
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue