feat(pet): vendored ratatui pet elements, DotWhale (committed as-is at the founder's instruction; not yet built or tested by the committer)

Pet-model work in progress: DotWhale component, PetMode/WhalePet/avatar sprite/color changes, UPSTREAM.json refresh. Build, nextest and ratatui sync with the sibling repo are still to be run; do not push until they have been.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-11 02:12:22 -07:00
1 parent 4d1717cf05
commit dc8884455d
9 files changed
+372 -36

No files matched your search

+16
View File
@@ -185,5 +185,21 @@
"tests/fixtures/whale-motion/scenario-conformance.json": "b00bbfbe2c65ce7fb86f803599a0e04de875d6dffd8ac39f87932f5af8447198",
"tests/fixtures/whale-motion/scenarios.json": "2386a509089f1a1096398252cb526048200cf116647b03f4dc366bc7d202e381",
"vendor/codewhale-design/tokens.rs": "975b7a4e2cda30f9dfa642283f092b9770529c5ec8c401d05b9bde5b6e55bdd1"
},
"pet_update": {
"commit": "00c6505356c5264fa6fd7d01f66458624d03e951",
"scope": "Selected prepared pet renderer files; non-pet components retain the base snapshot and existing Engine patches.",
"files": {
"src/avatar_sprite.rs": "efa14ec46bae22772f01fa28375c4f75cb8fe3696ca29f392b157b6173e1adee",
"src/color.rs": "96c44be1540c4756f251ed5ffb74aa88a5ebf131b104bf25866ad7ab8e3c6ba8",
"src/components/dot_whale.rs": "191bacf808037ba4dd7d4dfffbd06fe9c951a258a31495380591ad6ab4a715fa",
"src/components/pet_mode.rs": "35142d709073a09720f1ce1cfb57b18d6ea4880530a5c8d96755c5b9fc203214",
"src/components/whale_pet.rs": "bb29442f8db20e8387482e2e1dcb4fa5259ef99c3a37a77bfe9e4b56eaa05aed",
"src/whale.rs": "126a6efc8cac550ccd7f9c8edc61745b8c959186de118c2f4d479f5a5c5ffcf7",
"src/whale_motion/pixels.rs": "f232f48a1a9c36339b3dd78e9c5ae5a2043d08749d023977bc39633821ad0bb6"
},
"merged_exports": {
"src/components/mod.rs": "0ea81b474b2137b177c5535e7523ed6b312ea972630ac94f5131883246496e8e"
}
}
}
+50 -7
View File
@@ -22,7 +22,8 @@ pub struct Sprite<'a> {
frame: usize,
}
impl<'a> Sprite<'a> {
/// One bounded RGBA image, such as the canonical pet's live cove.
/// One already-decoded RGBA image, without an avatar manifest. Useful for
/// live vector scenes; shares the same half-block and capability painter.
pub fn image(pixels: &'a [u8], width: usize, height: usize) -> Result<Self, String> {
Self::tiles(pixels, width, height, 0, 1)
}
@@ -91,6 +92,45 @@ impl<'a> Sprite<'a> {
.try_into()
.expect("validated RGBA pixel")
}
fn sample(&self, x: usize, y: usize, width: usize, height: usize) -> [u8; 4] {
if width == self.width && height == self.height {
return self.pixel(x, y);
}
let bounds = |position: usize, source: usize, target: usize| {
let scale = source as f64 / target as f64;
let center = (position as f64 + 0.5) * scale;
let radius = scale.max(1.) / 2.;
(
(center - radius).max(0.),
(center + radius).min(source as f64),
)
};
let (left, right) = bounds(x, self.width, width);
let (top, bottom) = bounds(y, self.height, height);
let mut rgba = [0.; 4];
for sy in top.floor() as usize..(bottom.ceil() as usize).min(self.height) {
let weight_y = (bottom.min(sy as f64 + 1.) - top.max(sy as f64)).max(0.);
for sx in left.floor() as usize..(right.ceil() as usize).min(self.width) {
let weight_x = (right.min(sx as f64 + 1.) - left.max(sx as f64)).max(0.);
let pixel = self.pixel(sx, sy);
let alpha = f64::from(pixel[3]) * weight_x * weight_y;
for channel in 0..3 {
rgba[channel] += f64::from(pixel[channel]) * alpha;
}
rgba[3] += alpha;
}
}
if rgba[3] == 0. {
return [0; 4];
}
[
(rgba[0] / rgba[3]).round() as u8,
(rgba[1] / rgba[3]).round() as u8,
(rgba[2] / rgba[3]).round() as u8,
(rgba[3] / ((right - left) * (bottom - top))).round() as u8,
]
}
}
impl Paint for Sprite<'_> {
fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
@@ -113,13 +153,16 @@ impl Paint for Sprite<'_> {
let colored = theme.caps().paints_tokens() && !theme.ascii();
for y in 0..rows {
for x in 0..width {
let sx = (usize::from(x) * self.width / usize::from(width)).min(self.width - 1);
let sy = |half: usize| {
((usize::from(y) * 2 + half) * self.height / (usize::from(rows) * 2))
.min(self.height - 1)
let sample = |half: usize| {
self.sample(
usize::from(x),
usize::from(y) * 2 + half,
usize::from(width),
usize::from(rows) * 2,
)
};
let a = self.pixel(sx, sy(0));
let b = self.pixel(sx, sy(1));
let a = sample(0);
let b = sample(1);
if a[3] < 40 && b[3] < 40 {
continue;
}
+34 -1
View File
@@ -7,7 +7,9 @@
//! here name roles, and [`crate::theme::Theme`] resolves a role for the
//! terminal's depth when it paints.
use ratatui::style::Color;
use ratatui::style::{Color, Style};
use crate::theme::{Role, Theme};
/// How many colors the terminal can show.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -205,6 +207,37 @@ pub fn contrast_ratio(fg: Color, bg: Color) -> Option<f32> {
Some((hi + 0.05) / (lo + 0.05))
}
pub(crate) fn prepared_ink(
pigment: [u8; 3],
alpha: f32,
background: Color,
theme: &Theme,
) -> Style {
if !theme.paints_grounds() {
return theme.fg(Role::Primary);
}
let ground = resolvable_rgb(background)
.map(|(r, g, b)| Color::Rgb(r, g, b))
.unwrap_or_else(|| theme.token(Role::Background));
let base = blend(
Color::Rgb(pigment[0], pigment[1], pigment[2]),
ground,
alpha,
);
let quantize = |color| match (theme.depth(), color) {
(ColorDepth::Ansi256, Color::Rgb(r, g, b)) => Color::Indexed(rgb_to_ansi256(r, g, b)),
(_, color) => color,
};
let mut result = quantize(base);
for step in 1..=8 {
if contrast_ratio(result, ground).is_none_or(|ratio| ratio >= 3.0) {
break;
}
result = quantize(blend(theme.token(Role::Primary), base, step as f32 / 8.0));
}
Style::default().fg(result)
}
/// `hex` moved `amount` of the way toward `toward`, then rescaled so its
/// WCAG relative luminance stays `hex`'s. Contrast depends only on
/// luminance, so every contrast pair audited for `hex` holds for the result
+219
View File
@@ -0,0 +1,219 @@
use std::collections::BTreeMap;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Modifier, Style},
widgets::{Paragraph, Widget, Wrap},
};
use crate::{Paint, Role, Theme, color, text};
#[derive(Clone, Copy, Debug)]
pub struct DotWhale<'a> {
points: &'a [[f64; 2]],
materials: &'a [[f64; 4]],
caption: &'a str,
action_id: Option<&'a str>,
hollow: bool,
}
impl<'a> DotWhale<'a> {
#[must_use]
pub const fn new(points: &'a [[f64; 2]], materials: &'a [[f64; 4]]) -> Self {
Self {
points,
materials,
caption: "Activity unobserved",
action_id: None,
hollow: false,
}
}
#[must_use]
pub const fn caption(mut self, caption: &'a str) -> Self {
self.caption = caption;
self
}
#[must_use]
pub const fn action_id(mut self, action_id: Option<&'a str>) -> Self {
self.action_id = action_id;
self
}
#[must_use]
pub const fn hollow(mut self, hollow: bool) -> Self {
self.hollow = hollow;
self
}
fn valid(&self) -> bool {
!self.points.is_empty()
&& self.points.len() <= 980
&& self.points.len() == self.materials.len()
&& self
.points
.iter()
.flatten()
.all(|n| n.is_finite() && (-1.0..=1.0).contains(n))
&& self.materials.iter().all(|m| {
m[..3]
.iter()
.all(|n| n.is_finite() && (0.0..=255.0).contains(n))
&& m[3].is_finite()
&& (0.0..=1.0).contains(&m[3])
})
}
fn label(&self, theme: &Theme) -> String {
let caption = text::display_safe(self.caption);
let caption = text::truncate(&caption, 192, theme.ascii());
let Some(action) = self.action_id.filter(|id| !id.is_empty()) else {
return caption.into_owned();
};
let action = text::display_safe(action);
let action = text::truncate(&action, 256, theme.ascii());
if caption.is_empty() {
action.into_owned()
} else {
format!(
"{caption} {} {action}",
if theme.ascii() { "/" } else { "·" }
)
}
}
}
#[derive(Default)]
struct DotCell {
bits: u8,
pigment: [f64; 3],
weight: f64,
count: u16,
}
fn caption(label: &str, area: Rect, buf: &mut Buffer, style: Style) {
let width = area.width.min(512);
if width == 0 || area.height == 0 {
return;
}
let paragraph = Paragraph::new(label)
.style(style)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false });
let height = paragraph.line_count(width).min(usize::from(area.height)) as u16;
if height == 0 {
return;
}
let x = u32::from(area.x) + u32::from((area.width - width) / 2);
let y = u32::from(area.y) + u32::from((area.height - height) / 2);
let mut local = Buffer::empty(Rect::new(0, 0, width, height));
paragraph.render(local.area, &mut local);
for row in 0..height {
for col in 0..width {
let (Ok(x), Ok(y)) = (
u16::try_from(x + u32::from(col)),
u16::try_from(y + u32::from(row)),
) else {
continue;
};
if let Some(cell) = buf.cell_mut((x, y)) {
let background = cell.bg;
*cell = local[(col, row)].clone();
cell.set_bg(background);
}
}
}
}
impl Paint for DotWhale<'_> {
fn height(&self, width: u16, theme: &Theme) -> u16 {
if width == 0 {
return 0;
}
let label = self.label(theme);
let rows = Paragraph::new(label)
.wrap(Wrap { trim: false })
.line_count(width.min(512))
.min(512) as u16;
if theme.ascii() || width < 12 || !self.valid() {
rows
} else {
rows.saturating_add((width / 2).clamp(4, 20))
}
}
fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
if area.is_empty() || area.intersection(buf.area).is_empty() {
return;
}
let label = self.label(theme);
let label_height = Paragraph::new(label.as_str())
.wrap(Wrap { trim: false })
.line_count(area.width.min(512))
.min(usize::from(area.height)) as u16;
let art_height = area.height.saturating_sub(label_height);
if theme.ascii() || area.width < 12 || art_height < 4 || !self.valid() {
caption(&label, area, buf, theme.fg(Role::Primary));
return;
}
let width = f64::from(area.width) * 2.0;
let height = f64::from(art_height) * 4.0;
let scale = (width - 1.0).min(height - 1.0) / 2.0;
let bits = [[1, 8], [2, 16], [4, 32], [64, 128]];
let mut cells: BTreeMap<(u16, u16), DotCell> = BTreeMap::new();
for (point, material) in self.points.iter().zip(self.materials) {
if material[3] <= 0.01 {
continue;
}
let x = ((width - 1.0) / 2.0 + point[0] * scale).round() as u32;
let y = ((height - 1.0) / 2.0 + point[1] * scale).round() as u32;
let (Ok(col), Ok(row)) = (
u16::try_from(u32::from(area.x) + x / 2),
u16::try_from(u32::from(area.y) + y / 4),
) else {
continue;
};
if !buf.area.contains((col, row).into()) {
continue;
}
let cell = cells.entry((col, row)).or_default();
cell.bits |= bits[(y % 4) as usize][(x % 2) as usize];
for (sum, value) in cell.pigment.iter_mut().zip(material) {
*sum += value * material[3];
}
cell.weight += material[3];
cell.count += 1;
}
for ((x, y), dot) in cells {
if let Some(cell) = buf.cell_mut((x, y)) {
let [r, g, b] = dot.pigment.map(|sum| (sum / dot.weight).round() as u8);
let mut style = color::prepared_ink(
[r, g, b],
(dot.weight / f64::from(dot.count)) as f32,
cell.bg,
theme,
);
if self.hollow {
style = style.add_modifier(Modifier::DIM);
}
let glyph = char::from_u32(0x2800 + u32::from(dot.bits)).unwrap_or(' ');
let mut encoded = [0; 4];
cell.set_symbol(glyph.encode_utf8(&mut encoded))
.set_style(style);
}
}
caption(
&label,
Rect::new(
area.x,
area.y.saturating_add(art_height),
area.width,
label_height,
),
buf,
theme.fg(Role::Primary),
);
}
}
+3
View File
@@ -123,5 +123,8 @@ mod whale_pet;
pub use whale_pet::*;
mod subagents;
pub use subagents::*;
mod dot_whale;
pub use dot_whale::*;
mod pet_mode;
pub use pet_mode::*;
+14 -7
View File
@@ -2,8 +2,8 @@
//! the host still owns sessions, roster receipts, responses and every action.
use crate::{
MotionMode, Role, StatusMark, Subagent, SubagentIntent, SubagentView, SubagentViewState,
SubagentViewWords, Theme, WhalePet, text,
DotWhale, MotionMode, Paint, Role, StatusMark, Subagent, SubagentIntent, SubagentView,
SubagentViewState, SubagentViewWords, Theme, WhalePet, text,
whale_motion::{Inputs, Presence, Stage, Tier},
};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind};
@@ -68,7 +68,7 @@ impl PetModeAreas {
} else {
body
};
if output && main.height >= 8 {
if output && main.height >= 27 && main.width >= 16 {
// Reading has priority over scenery, including at 80x24.
let height = (u32::from(main.height) * 2 / 3).max(6) as u16;
plan.output = Rect::new(main.x, main.bottom() - height, main.width, height);
@@ -155,7 +155,8 @@ impl PetModeState {
if !self.visible || !self.motion.animates() {
return None;
}
let pet = (!self.areas.pet.is_empty() && self.stage_animates)
let art = WhalePet::art_area(self.areas.pet);
let pet = (!theme.ascii() && art.width >= 16 && art.height >= 8 && self.stage_animates)
.then(|| {
self.stage.cadence(if theme.paints_grounds() {
Tier::Hero
@@ -316,6 +317,7 @@ pub struct PetMode<'a> {
pub theme: &'a Theme,
pub title: Cow<'a, str>,
pub status: StatusMark,
pub dot_whale: Option<DotWhale<'a>>,
/// The host's existing notice, using its semantic severity ink.
pub notice: Option<Line<'a>>,
pub agents: &'a [Subagent<'a>],
@@ -334,6 +336,7 @@ impl<'a> PetMode<'a> {
theme,
title: "Codewhale".into(),
status,
dot_whale: None,
notice: None,
agents: &[],
agent_words: SubagentViewWords::default(),
@@ -411,9 +414,13 @@ impl StatefulWidget for PetMode<'_> {
);
}
if !plan.pet.is_empty() {
WhalePet::new(self.theme)
.words("")
.paint(plan.pet, buf, &mut state.stage);
if let Some(whale) = self.dot_whale {
whale.paint(plan.pet, buf, self.theme);
} else {
WhalePet::new(self.theme)
.words("")
.paint(plan.pet, buf, &mut state.stage);
}
}
let marker = if self.theme.ascii() { "> " } else { "› " };
let mut agent_words = self.agent_words;
+13 -8
View File
@@ -57,7 +57,8 @@ impl<'a> WhalePet<'a> {
}
}
/// Localized words from the host. The default names the current action.
/// Localized words from the host. The default names the current action;
/// empty words hide the caption when the host already presents the state.
#[must_use]
pub fn words(mut self, words: impl Into<Cow<'a, str>>) -> Self {
self.words = Some(words.into());
@@ -154,7 +155,9 @@ impl<'a> WhalePet<'a> {
};
if self.theme.ascii() || art.width < 16 || art.height < 8 {
// A deliberately invalid frame asks the shared painter for words.
whale.paint_frame(area, buf, self.theme, &words_only);
if self.words.as_deref() != Some("") {
whale.paint_frame(area, buf, self.theme, &words_only);
}
return;
}
let dark = self.theme.caps().appearance != crate::detect::Appearance::Light;
@@ -187,12 +190,14 @@ impl<'a> WhalePet<'a> {
if let Ok(sprite) = Sprite::image(&pixels, usize::from(art.width), usize::from(art.width)) {
sprite.paint(art, buf, self.theme);
}
whale.paint_frame(
Rect::new(area.x, art.bottom(), area.width, 1),
buf,
self.theme,
&words_only,
);
if self.words.as_deref() != Some("") {
whale.paint_frame(
Rect::new(area.x, art.bottom(), area.width, 1),
buf,
self.theme,
&words_only,
);
}
}
}
+5 -1
View File
@@ -529,7 +529,11 @@ impl Whale {
}
let mark = StatusMark::new(self.state.state())
.word(self.words.clone().unwrap_or_else(|| self.state.words()));
let label = Line::from(mark.spans(theme)).centered();
let label = if self.words.as_deref() == Some("") {
Line::default()
} else {
Line::from(mark.spans(theme)).centered()
};
// Braille has no honest ASCII form: dot-count shading turns the whale
// into noise. ASCII-safe terminals get the words alone.
let grid = grid.filter(|grid| {
+18 -12
View File
@@ -3,7 +3,7 @@
use super::{CoveScene, Layer, Path, Role, ink::Fill, rig::holes_for, scene::segments};
pub(crate) const MAX_SIDE: usize = 96;
pub(crate) const MAX_SIDE: usize = 160;
struct Canvas {
side: usize,
@@ -68,24 +68,30 @@ impl Canvas {
}
}
fn layers(&mut self, layers: &[Layer]) {
fn layers(&mut self, layers: &[Layer], opacity: f64, current_ink: Option<u32>) {
for layer in layers {
self.fill(&layer.paths, Fill::Solid(layer.color), layer.alpha);
let color = if layer.color == 0xf7fcfb {
current_ink.unwrap_or(layer.color)
} else {
layer.color
};
self.fill(&layer.paths, Fill::Solid(color), layer.alpha * opacity);
}
}
}
/// Square design space, with two samples per axis for clean curved edges.
/// Square design space, with extra samples at small sizes for clean curved edges.
/// Public terminal rectangles never control an unbounded allocation.
pub(crate) fn render(scene: &CoveScene, side: usize, dark: bool) -> Vec<u8> {
if !(1..=MAX_SIDE).contains(&side) {
return Vec::new();
}
let samples = if side <= 64 { 4 } else { 2 };
let mut canvas = Canvas {
side: side * 2,
pixels: vec![[0.; 4]; side * side * 4],
side: side * samples,
pixels: vec![[0.; 4]; side * side * samples * samples],
};
canvas.layers(&scene.behind);
canvas.layers(&scene.behind, if dark { 0.68 } else { 0.9 }, None);
for shape in &scene.parts.shapes {
if shape.role == Role::Cutout {
continue;
@@ -96,16 +102,16 @@ pub(crate) fn render(scene: &CoveScene, side: usize, dark: bool) -> Vec<u8> {
canvas.fill(&paths, fill, shape.opacity);
}
}
canvas.layers(&scene.front);
canvas.layers(&scene.front, 0.82, (!dark).then_some(0x3e7789));
let mut out = Vec::with_capacity(side * side * 4);
for y in 0..side {
for x in 0..side {
let mut rgba = [0.; 4];
for dy in 0..2 {
for dx in 0..2 {
let p = canvas.pixels[(y * 2 + dy) * canvas.side + x * 2 + dx];
for dy in 0..samples {
for dx in 0..samples {
let p = canvas.pixels[(y * samples + dy) * canvas.side + x * samples + dx];
for (dst, src) in rgba.iter_mut().zip(p) {
*dst += src / 4.;
*dst += src / (samples * samples) as f64;
}
}
}