use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use anyhow::{Context as _, Result};
use clap::Parser;
use image::codecs::gif::{GifEncoder, Repeat};
use image::{Delay, Frame as GifFrame, Rgb as ImgRgb, RgbImage, Rgba, RgbaImage};
use pixtuoid::tui::renderer::{draw_scene, DrawCtx, TickerQueue};
use pixtuoid_core::source::jsonl::JsonlWatcher;
use pixtuoid_core::source::AgentEvent;
use pixtuoid_core::sprite::{Rgb, RgbBuffer};
use pixtuoid_core::state::ActivityState;
use pixtuoid_core::{AgentId, AgentSlot, GlobalDeskIndex, Reducer, SceneState, Transport};
use pixtuoid_scene::embedded_pack::load_sprite_pack;
use pixtuoid_scene::frame_cache::FrameCache;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use ratatui::Terminal;
use tokio::sync::{mpsc, RwLock};
const COLS: u16 = 192;
const ROWS: u16 = 80;
const CELL_W: u32 = 8;
const CELL_H: u32 = 16;
#[derive(Debug, Parser)]
#[command(about = "Render the TUI off-screen to a PNG for verification")]
struct SnapshotArgs {
#[arg(default_value = "snapshot.png")]
out: PathBuf,
#[arg(long)]
live: bool,
#[arg(long, default_value_t = default_projects_root())]
projects_root: String,
#[arg(long, default_value_t = 5)]
listen_secs: u64,
#[arg(long)]
debug_walkable: bool,
#[arg(long)]
pack_dir: Option<std::path::PathBuf>,
#[arg(long)]
cols: Option<u16>,
#[arg(long)]
rows: Option<u16>,
#[arg(long, default_value_t = 12)]
max_desks: usize,
#[arg(long, default_value_t = 12)]
agents: usize,
#[arg(long)]
gif: bool,
#[arg(long, default_value_t = 5)]
gif_duration: u64,
#[arg(long, default_value_t = 10)]
gif_fps: u64,
#[arg(long, default_value = "normal")]
theme: String,
#[arg(long, default_value_t = 0)]
floor_seed: u64,
#[arg(
long = "navigate-at",
value_name = "SEC:FLOOR",
requires = "gif",
conflicts_with = "anim"
)]
navigate_at: Vec<String>,
#[arg(long)]
empty: bool,
#[arg(long)]
openclaw: Option<String>,
#[arg(long)]
now_hour: Option<u32>,
#[arg(long, default_value_t = 1)]
now_day: u32,
#[arg(long)]
weather: Option<String>,
#[arg(long)]
help_open: bool,
#[arg(long)]
source_warning: Option<String>,
#[arg(long)]
drift_warning: Option<String>,
#[arg(long)]
theme_picker: Option<usize>,
#[arg(long)]
popup: bool,
#[arg(long, value_name = "KIND", requires = "gif", conflicts_with = "anim")]
pets: Option<String>,
#[arg(long, conflicts_with_all = ["anim", "gif", "live", "empty", "pets"])]
dashboard: bool,
#[arg(long, conflicts_with_all = ["anim", "gif", "live", "empty", "pets", "dashboard"])]
connection: bool,
#[arg(long, conflicts_with_all = ["anim", "gif", "live", "empty", "pets", "dashboard", "connection"])]
onboarding: bool,
#[arg(long)]
anim: Option<String>,
#[arg(long)]
anim_skip_ms: Option<u64>,
#[arg(
long,
value_name = "N",
value_parser = clap::value_parser!(u8).range(2..=3),
requires = "gif",
conflicts_with_all = ["anim", "dashboard", "empty", "live", "pets", "navigate_at"]
)]
meeting: Option<u8>,
#[arg(
long,
value_name = "SECS",
requires = "gif",
conflicts_with_all = ["anim", "pets", "navigate_at"]
)]
warmup_secs: Option<f64>,
#[arg(long)]
anim_facing: Option<String>,
#[arg(long, conflicts_with_all = ["crop_furniture", "gif", "anim"])]
crop_agent: Option<String>,
#[arg(long, conflicts_with_all = ["gif", "anim"])]
crop_furniture: Option<String>,
#[arg(long, conflicts_with_all = ["crop_agent", "crop_furniture", "gif", "anim"])]
crop_mascot: bool,
}
fn default_projects_root() -> String {
pixtuoid_core::source::claude_code::ClaudeCodeSource::default_paths()
.projects_root
.to_string_lossy()
.into_owned()
}
fn parse_navigations(specs: &[String]) -> Result<Vec<(u64, usize)>> {
specs
.iter()
.map(|s| {
let (sec, floor) = s
.split_once(':')
.with_context(|| format!("--navigate-at '{s}': expected SEC:FLOOR"))?;
let ms = (sec
.parse::<f64>()
.with_context(|| format!("--navigate-at '{s}': bad SEC"))?
* 1000.0) as u64;
let floor = floor
.parse::<usize>()
.with_context(|| format!("--navigate-at '{s}': bad FLOOR"))?;
Ok((ms, floor))
})
.collect()
}
fn main() -> Result<()> {
let args = SnapshotArgs::parse();
if let Err(valid) = pixtuoid_scene::pixel_painter::force_weather(args.weather.as_deref()) {
anyhow::bail!(
"unknown --weather {:?}; valid: {}",
args.weather.unwrap_or_default(),
valid.join(" | ")
);
}
let now = match args.now_hour {
Some(h) => {
use chrono::TimeZone;
chrono::Local
.with_ymd_and_hms(2026, 1, args.now_day, h, 0, 0)
.single()
.ok_or_else(|| {
anyhow::anyhow!("invalid --now-day/--now-hour {}:{}", args.now_day, h)
})?
.into()
}
None => SystemTime::now(),
};
let cols = args.cols.unwrap_or(COLS);
let rows = args.rows.unwrap_or(ROWS);
let mut skip_ms = 0u64;
let scene = if let Some(target) = args.anim.as_deref() {
let (s, skip) = anim_scene(
now,
target,
cols,
rows,
args.floor_seed,
args.anim_facing.as_deref(),
);
skip_ms = args.anim_skip_ms.unwrap_or(skip);
eprintln!("ANIM pre-roll skip = {skip_ms}ms (default {skip}ms)");
s
} else if let Some(n) = args.meeting {
let (s, warmup) = meeting_scene(
now,
n as usize,
cols,
rows,
args.floor_seed,
args.max_desks,
args.agents,
)?;
skip_ms = warmup;
eprintln!("MEETING auto warmup = {warmup}ms");
s
} else if args.empty {
SceneState::uniform(args.max_desks)
} else if args.live {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(capture_live_scene(&args.projects_root, args.listen_secs))?
} else if args.dashboard {
dashboard_scene(now)
} else {
sample_scene(now, args.max_desks, args.agents)
};
if let Some(secs) = args.warmup_secs {
skip_ms = (secs * 1000.0) as u64;
eprintln!("WARMUP pre-roll = {skip_ms}ms (explicit --warmup-secs)");
}
let mut scene = scene;
if let Some(state) = args.openclaw.as_deref() {
inject_openclaw_presence(&mut scene, state, now)?;
}
let backend = TestBackend::new(cols, rows);
let mut term = Terminal::new(backend)?;
let mut buf = RgbBuffer::filled(0, 0, Rgb { r: 0, g: 0, b: 0 });
let pack = load_sprite_pack(args.pack_dir.clone())?;
let mut cache = FrameCache::new();
let mut router = pixtuoid_scene::pathfind::AStarRouter::new();
let mut overlay = pixtuoid_core::walkable::OccupancyOverlay::new();
let mut history = pixtuoid_scene::pose::PoseHistory::new();
let theme = pixtuoid_scene::theme::theme_by_name(&args.theme).ok_or_else(|| {
let valid: Vec<&str> = pixtuoid_scene::theme::ALL_THEMES
.iter()
.map(|t| t.name)
.collect();
anyhow::anyhow!(
"unknown --theme {:?}; valid: {}",
args.theme,
valid.join(" | ")
)
})?;
let ticker = TickerQueue::new();
let navigations = parse_navigations(&args.navigate_at)?;
let pet_vec: Vec<pixtuoid_scene::pet::Pet> = match args.pets.as_deref() {
None => vec![],
Some(kind_str) => {
use pixtuoid_scene::pet::{Pet, PetKind};
let kind = match kind_str {
"cat" => PetKind::Cat,
"dog" => PetKind::Dog,
other => anyhow::bail!("unknown --pets {:?}; valid: cat | dog", other),
};
vec![Pet {
kind,
name: "Pixel".into(),
}]
}
};
if args.floor_seed != 0 && (!navigations.is_empty() || !pet_vec.is_empty()) {
eprintln!(
"--floor-seed is ignored on the renderer path (--navigate-at / --pets): \
TuiRenderer derives per-floor seeds internally"
);
}
if !navigations.is_empty() || !pet_vec.is_empty() {
save_renderer_gif(
term,
&scene,
&pack,
now,
&args.out,
cols,
rows,
args.gif_fps,
args.gif_duration,
theme,
&navigations,
pet_vec,
)?;
println!("wrote {}", args.out.display());
return Ok(());
}
if args.gif || args.anim.is_some() {
save_as_gif(
&mut term,
&scene,
&pack,
now,
&args.out,
cols,
rows,
&mut buf,
&mut cache,
&mut router,
&mut overlay,
&mut history,
args.gif_fps,
args.gif_duration,
theme,
args.floor_seed,
skip_ms,
args.debug_walkable,
)?;
println!("wrote {}", args.out.display());
return Ok(());
}
let death_text = args.source_warning.as_deref().and_then(|src| {
pixtuoid::tui::widgets::source_warning_message(&[
pixtuoid_core::source::manager::SourceDeath::new(src, "forced for screenshot"),
])
});
let drifted: Vec<String> = args
.drift_warning
.as_deref()
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let warning_text = pixtuoid::doctor::footer_warning(death_text.as_deref(), &drifted);
let mut chitchat_state = std::collections::HashMap::new();
let mut light = pixtuoid_scene::floor::LightingState::new();
let mut motion: std::collections::HashMap<
pixtuoid_core::AgentId,
pixtuoid_scene::motion::MotionState,
> = std::collections::HashMap::new();
if args.empty {
light.snap_to_empty();
}
let (dash_rows, dash_selected) = if args.dashboard {
let folds = pixtuoid::tui::dashboard::DashboardFolds::default();
let rows = pixtuoid::tui::dashboard::build_dashboard_rows(&scene, &folds);
let sel = rows.first().map(|r| r.agent_id);
(rows, sel)
} else {
(Vec::new(), None)
};
let (connection_rows, connection_live, connection_socket_line) = if args.connection {
use pixtuoid::tui::connection::{ConnState, ConnectionRow, LiveInfo};
use std::path::PathBuf;
use std::time::Duration;
let mk = |source_id, label_prefix, display_name, state, cfg: Option<&str>| ConnectionRow {
source_id,
label_prefix,
display_name,
state,
config_path: cfg.map(PathBuf::from),
target: None,
health: None,
};
let rows = vec![
mk(
"claude-code",
"cc",
"Claude Code",
ConnState::Connected,
Some("~/.claude/settings.json"),
),
mk(
"codex",
"cx",
"Codex",
ConnState::Disconnected,
Some("~/.codex/config.toml"),
),
mk("reasonix", "rx", "Reasonix", ConnState::NoCli, None),
mk(
"codewhale",
"cw",
"CodeWhale",
ConnState::Connected,
Some("~/.codewhale/config.toml"),
),
mk(
"opencode",
"oc",
"opencode",
ConnState::Disconnected,
Some("~/.config/opencode/plugins/pixtuoid.ts"),
),
mk(
"antigravity",
"ag",
"Antigravity",
ConnState::Connected,
None,
),
];
let live = vec![
LiveInfo {
agents: 2,
last_event_age: Some(Duration::from_secs(3)),
dead: false,
},
LiveInfo::default(),
LiveInfo::default(),
LiveInfo {
agents: 0,
last_event_age: None,
dead: true,
},
LiveInfo::default(),
LiveInfo {
agents: 1,
last_event_age: Some(Duration::from_secs(12)),
dead: false,
},
];
(
rows,
live,
"socket /run/user/501/pixtuoid.sock (listening)".to_string(),
)
} else {
(Vec::new(), Vec::new(), String::new())
};
let onboarding_frame = if args.onboarding {
use pixtuoid::tui::welcome::WelcomeRow;
let mk = |source_id, label_prefix, display_name: &str, checked| WelcomeRow {
source_id,
label_prefix,
display_name: display_name.to_string(),
checked,
};
pixtuoid::tui::welcome::OnboardingFrame {
open: true,
rows: vec![
mk("claude-code", "cc", "Claude Code", true),
mk("codex", "cx", "Codex", true),
mk("cursor", "cu", "Cursor CLI", false),
],
selected: 0,
elapsed_ms: 100_000,
dim: pixtuoid::tui::welcome::dim_opening(100_000),
}
} else {
pixtuoid::tui::welcome::OnboardingFrame::default()
};
let dashboard_frame = pixtuoid::tui::dashboard::DashboardFrame {
open: args.dashboard,
rows: dash_rows,
selected: dash_selected,
scroll: 0,
};
let connection_frame = pixtuoid::tui::connection::ConnectionFrame {
open: args.connection,
rows: connection_rows,
live: connection_live,
selected: 0,
confirm: None,
result: None,
socket_line: connection_socket_line,
};
let mut draw_ctx = DrawCtx {
buf: &mut buf,
cache: &mut cache,
router: &mut router,
overlay: &mut overlay,
history: &mut history,
motion: &mut motion,
door_anim_max_ms: 0,
light: &mut light,
mouse_pos: None,
pinned_agent: None,
debug_walkable: args.debug_walkable,
ticker: &ticker,
theme,
theme_picker: args.theme_picker,
floor_info: None,
floor: {
let mut m = pixtuoid_scene::floor::FloorMeta::ground();
m.floor_seed = args.floor_seed;
m
},
active_pet: None,
last_pet_pos: None,
last_mascot_pos: None,
floor_pet: None,
chitchat_state: &mut chitchat_state,
chitchat_bubbles: Vec::new(),
coffee_holders: &std::collections::HashSet::new(),
coffee_fetched_at: &std::collections::HashMap::new(),
new_coffee_carriers: Vec::new(),
popup_scale: if args.popup { 1.0 } else { 0.0 },
help_open: args.help_open,
source_warning: warning_text.as_deref(),
dashboard: &dashboard_frame,
connection: &connection_frame,
onboarding: &onboarding_frame,
};
draw_scene(&mut term, &scene, &pack, now, &mut draw_ctx)?;
if args.debug_walkable {
debug_paint_walkable_overlay(&mut term, &scene)?;
}
let crop_rect = if args.crop_mascot {
let m = draw_ctx.last_mascot_pos.as_ref().ok_or_else(|| {
anyhow::anyhow!("--crop-mascot needs a visible mascot; pass --openclaw <state>")
})?;
Some(centered_crop(m.pos.x, m.pos.y / 2, cols, rows))
} else {
compute_crop_rect(&args, &scene, &history, cols, rows, now)?
};
save_backend_as_png(&term, &args.out, cols, rows, crop_rect)?;
println!("wrote {}", args.out.display());
println!("\n--- text preview (symbols only) ---");
let buf = term.backend().buffer();
let (start_x, start_y, render_w, render_h) = match crop_rect {
Some(r) => (r.x, r.y, r.width, r.height),
None => (0, 0, cols, rows),
};
for y in 0..render_h {
for x in 0..render_w {
print!("{}", buf[(start_x + x, start_y + y)].symbol());
}
println!();
}
Ok(())
}
fn debug_paint_walkable_overlay(
term: &mut Terminal<TestBackend>,
scene: &SceneState,
) -> Result<()> {
use pixtuoid_scene::layout::SceneLayout;
let size = term.size()?;
let scene_w = size.width;
let scene_h = size.height.saturating_sub(1);
let buf_w = scene_w;
let buf_h = scene_h * 2;
let Some(layout) = SceneLayout::compute(buf_w, buf_h, scene.floor_capacities[0]) else {
println!("(debug_walkable) layout too small to compute");
return Ok(());
};
let reach_mask = compute_reachable(&layout);
let w = layout.buf_w as usize;
let h = layout.buf_h as usize;
let mut reachable = 0usize;
let mut walkable_total = 0usize;
let mut sample_disconnects: Vec<(u16, u16)> = Vec::new();
for y in 0..h {
for x in 0..w {
if layout.is_walkable(x as u16, y as u16) {
walkable_total += 1;
if reach_mask[y * w + x] {
reachable += 1;
} else if sample_disconnects.len() < 10 {
sample_disconnects.push((x as u16, y as u16));
}
}
}
}
let disconnected = walkable_total.saturating_sub(reachable);
println!(
"--- walkability report ---\n\
total walkable pixels : {walkable_total}\n\
reachable from threshold: {reachable}\n\
disconnected pixels : {disconnected}{}",
if disconnected == 0 {
" ✓ all open areas connected"
} else {
" ⚠ disconnected components present"
}
);
if !sample_disconnects.is_empty() {
print!("sample disconnected : ");
for (i, (x, y)) in sample_disconnects.iter().enumerate() {
if i > 0 {
print!(", ");
}
print!("({x},{y})");
}
println!();
let probe = |x: u16, y: u16, name: &str| {
let wk = layout.is_walkable(x, y);
let r = is_reachable(&reach_mask, &layout, x, y);
println!(" probe {name} ({x},{y}): walkable={wk} reachable={r}");
};
if let Some(t) = layout.door_threshold {
probe(t.x, t.y, "threshold");
}
probe(0, layout.top_margin, "MR top-left");
println!("row y=66 walkability:");
for x in 0..30u16 {
let w = layout.is_walkable(x, 66);
let r = is_reachable(&reach_mask, &layout, x, 66);
println!(" x={x}: walk={w} reach={r}");
}
}
Ok(())
}
fn compute_reachable(layout: &pixtuoid_scene::layout::SceneLayout) -> Vec<bool> {
use std::collections::VecDeque;
let w = layout.buf_w as usize;
let h = layout.buf_h as usize;
let mut visited = vec![false; w * h];
let Some(start) = layout.door_threshold else {
return visited;
};
if !layout.is_walkable(start.x, start.y) {
return visited;
}
let (sx, sy) = (start.x as usize, start.y as usize);
visited[sy * w + sx] = true;
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
queue.push_back((sx, sy));
while let Some((x, y)) = queue.pop_front() {
for (dx, dy) in [(1, 0), (-1, 0), (0, 1), (0, -1)] {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || ny < 0 {
continue;
}
let (nx, ny) = (nx as usize, ny as usize);
if nx >= w || ny >= h || visited[ny * w + nx] {
continue;
}
if !layout.is_walkable(nx as u16, ny as u16) {
continue;
}
visited[ny * w + nx] = true;
queue.push_back((nx, ny));
}
}
visited
}
fn is_reachable(
mask: &[bool],
layout: &pixtuoid_scene::layout::SceneLayout,
x: u16,
y: u16,
) -> bool {
let w = layout.buf_w as usize;
let h = layout.buf_h as usize;
let (xi, yi) = (x as usize, y as usize);
if xi >= w || yi >= h {
return false;
}
mask[yi * w + xi]
}
async fn capture_live_scene(projects_root: &str, listen_secs: u64) -> Result<SceneState> {
println!(
"listening for real CC events under {} for {}s...",
projects_root, listen_secs
);
let scene: Arc<RwLock<SceneState>> = Arc::new(RwLock::new(SceneState::uniform(12)));
let (tx, mut rx) = mpsc::channel::<(Transport, AgentEvent)>(1024);
let root = PathBuf::from(projects_root);
let watcher = JsonlWatcher::new(
root,
pixtuoid_core::source::claude_code::SOURCE_NAME.to_string(),
pixtuoid_core::source::claude_code::decode_cc_line,
pixtuoid_core::source::claude_code::cc_derive_label,
pixtuoid_core::source::claude_code::cc_session_ended,
);
let watcher_handle = tokio::spawn(async move { watcher.run(tx).await });
let mut reducer = Reducer::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(listen_secs);
let mut event_count: u64 = 0;
while tokio::time::Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Some((transport, ev))) => {
let now = SystemTime::now();
let mut s = scene.write().await;
reducer.apply(&mut s, ev, now, transport);
event_count += 1;
}
_ => break,
}
}
let snapshot = scene.read().await.clone();
println!(
"captured {} events; final scene has {} agents",
event_count,
snapshot.agents.len()
);
for (id, slot) in &snapshot.agents {
println!(
" {} ({}) at desk {}: {:?}",
slot.label, id, slot.desk_index.0, slot.state
);
}
watcher_handle.abort();
Ok(snapshot)
}
fn sample_scene(now: SystemTime, max_desks: usize, n_agents: usize) -> SceneState {
let mut s = SceneState::uniform(max_desks);
fill_sample_agents(&mut s, now, 0..n_agents);
s
}
fn inject_openclaw_presence(s: &mut SceneState, state: &str, now: SystemTime) -> Result<()> {
use pixtuoid_core::state::{DaemonPresence, DaemonState};
let (state, active_sessions, runs) = match state {
"idle" => (DaemonState::Idle, 1, Vec::new()),
"busy" => (
DaemonState::Busy,
1,
vec!["run-a".to_string(), "run-b".to_string()],
),
"degraded" => (DaemonState::Degraded, 1, Vec::new()),
"down" => (DaemonState::Down, 0, Vec::new()),
other => {
anyhow::bail!("unknown --openclaw {other:?}; valid: idle | busy | degraded | down")
}
};
let entered_at = now
.checked_sub(std::time::Duration::from_secs(20))
.unwrap_or(now);
s.daemons_mut().insert(
pixtuoid_core::source::openclaw::SOURCE_NAME.to_string(),
DaemonPresence {
state,
active_sessions,
last_seen: now,
entered_at,
in_flight_run_keys: runs.into_iter().collect(),
current_pid: Some(4242),
},
);
Ok(())
}
fn fill_sample_agents(s: &mut SceneState, now: SystemTime, desks: std::ops::Range<usize>) {
use std::time::Duration as D;
let agents: [(&str, ActivityState, D); 12] = [
(
"working",
ActivityState::Active {
tool_use_id: Some("tu_a".into()),
detail: Some("Write: src/foo.rs".into()),
},
D::from_millis(0),
),
(
"waiting",
ActivityState::Waiting {
reason: "permission?".into(),
},
D::from_secs(10),
),
("thinking", ActivityState::Idle, D::from_secs(5)), ("idle-a", ActivityState::Idle, D::from_secs(300)), ("idle-b", ActivityState::Idle, D::from_secs(301)),
("idle-c", ActivityState::Idle, D::from_secs(303)),
(
"couch-act",
ActivityState::Active {
tool_use_id: Some("tu_c".into()),
detail: Some("Read: README.md".into()),
},
D::from_millis(140),
),
(
"couch-bk",
ActivityState::Waiting {
reason: "review".into(),
},
D::from_millis(0),
),
(
"floor-act",
ActivityState::Active {
tool_use_id: Some("tu_d".into()),
detail: Some("Bash: cargo test".into()),
},
D::from_millis(140),
),
("floor-idle", ActivityState::Idle, D::from_millis(2_000)),
(
"floor-act2",
ActivityState::Active {
tool_use_id: Some("tu_e".into()),
detail: Some("Grep: TODO".into()),
},
D::from_millis(280),
),
("floor-idle2", ActivityState::Idle, D::from_millis(3_000)),
];
for i in desks {
let (key, state, age) = &agents[i % agents.len()];
let unique_key = if i < agents.len() {
key.to_string()
} else {
format!("{key}-{i}")
};
let id = AgentId::from_transcript_path(&format!("/demo/{unique_key}.jsonl"));
s.agents.insert(
id,
AgentSlot {
agent_id: id,
source: std::sync::Arc::from("claude-code"),
session_id: std::sync::Arc::from(format!("demo-{unique_key}").as_str()),
cwd: std::sync::Arc::from(PathBuf::from("/demo").as_path()),
label: std::sync::Arc::from(unique_key.as_str()),
state: state.clone(),
state_started_at: now - *age,
created_at: now - *age,
last_event_at: now - *age,
exiting_at: None,
pending_idle_at: None,
desk_index: GlobalDeskIndex(i),
floor_idx: s.floor_of(GlobalDeskIndex(i)),
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
},
);
}
}
#[derive(Debug, Clone)]
struct MeetingCandidate {
path: String,
id: AgentId,
cycle_n: u64,
wp_idx: usize,
room_id: usize,
is_sofa: bool,
is_south_seat: bool,
dwell_ms: u64,
}
const MEETING_DWELL_SPREAD_MS: u64 = 3_000;
const MEETING_WARMUP_LEAD_MS: u64 = 1_500;
fn meeting_scene(
now: SystemTime,
n: usize,
cols: u16,
rows: u16,
floor_seed: u64,
max_desks: usize,
n_agents: usize,
) -> Result<(SceneState, u64)> {
use pixtuoid_core::layout::{SceneLayout, WaypointKind};
use pixtuoid_core::pose::{
est_wander_cycle_ms, is_aimless_cycle, seated_dwell_ms, takes_trip,
waypoint_index_for_cycle,
};
let (buf_w, buf_h) = (cols, rows.saturating_sub(1).saturating_mul(2));
let l = SceneLayout::compute_with_seed(buf_w, buf_h, max_desks, floor_seed)
.ok_or_else(|| anyhow::anyhow!("--meeting: scene too small to compute a layout"))?;
let nw = l.waypoints.len();
let south_y_of_room = |room: usize| -> Option<u16> {
l.waypoints
.iter()
.filter(|w| {
w.room_id == Some(room)
&& matches!(
w.kind,
WaypointKind::MeetingSofa | WaypointKind::MeetingStand
)
})
.map(|w| w.pos.y)
.max()
};
let mut cands: Vec<MeetingCandidate> = Vec::new();
for i in 0..20_000u64 {
let path = format!("/meeting/agent_{i}.jsonl");
let id = AgentId::from_transcript_path(&path);
for cycle_n in 1..=5u64 {
if !takes_trip(id, cycle_n) || is_aimless_cycle(id, cycle_n) {
continue;
}
let wp_idx = waypoint_index_for_cycle(id, cycle_n, nw);
let wp = l.waypoints[wp_idx];
let is_sofa = match wp.kind {
WaypointKind::MeetingSofa => true,
WaypointKind::MeetingStand => false,
_ => continue,
};
let room_id = wp.room_id.unwrap_or(0);
cands.push(MeetingCandidate {
path,
id,
cycle_n,
wp_idx,
room_id,
is_sofa,
is_south_seat: south_y_of_room(room_id) == Some(wp.pos.y),
dwell_ms: seated_dwell_ms(id),
});
break;
}
}
cands.sort_by_key(|c| (c.dwell_ms, c.id.raw()));
let pick = |need_sofas: usize, avoid_south: bool| -> Option<Vec<MeetingCandidate>> {
for (i, base) in cands.iter().enumerate() {
if avoid_south && base.is_south_seat {
continue;
}
let mut sel = vec![base.clone()];
for c in cands[i + 1..].iter() {
if c.dwell_ms - base.dwell_ms > MEETING_DWELL_SPREAD_MS {
break;
}
if (avoid_south && c.is_south_seat)
|| c.room_id != base.room_id
|| sel.iter().any(|s| s.wp_idx == c.wp_idx)
{
continue;
}
sel.push(c.clone());
if sel.len() == n {
break;
}
}
if sel.len() == n && sel.iter().filter(|c| c.is_sofa).count() >= need_sofas {
return Some(sel);
}
}
None
};
let staged = pick(2.min(n), true)
.or_else(|| pick(2.min(n), false))
.or_else(|| pick(0, false))
.ok_or_else(|| {
anyhow::anyhow!(
"--meeting {n}: no candidate group found ({} meeting-bound candidates at \
{buf_w}x{buf_h} seed {floor_seed})",
cands.len()
)
})?;
let min_dwell = staged.iter().map(|c| c.dwell_ms).min().unwrap_or(0);
let warmup_ms = min_dwell.saturating_sub(MEETING_WARMUP_LEAD_MS);
let mut s = SceneState::uniform(max_desks);
for (i, c) in staged.iter().enumerate() {
let wp = l.waypoints[c.wp_idx];
eprintln!(
"MEETING agent {} desk {} id={} cycle={} → waypoint[{}] {:?} room {} at ({}, {}) \
desk_dwell={}ms rise@{}ms",
(b'a' + i as u8) as char,
i,
c.path,
c.cycle_n,
c.wp_idx,
wp.kind,
c.room_id,
wp.pos.x,
wp.pos.y,
c.dwell_ms,
c.dwell_ms.saturating_sub(warmup_ms),
);
let back_date = Duration::from_millis(c.cycle_n * est_wander_cycle_ms(c.id) + 400);
let label = format!("meet-{}", (b'a' + i as u8) as char);
s.agents.insert(
c.id,
AgentSlot {
agent_id: c.id,
source: std::sync::Arc::from("claude-code"),
session_id: std::sync::Arc::from(format!("meeting-{i}").as_str()),
cwd: std::sync::Arc::from(PathBuf::from("/meeting").as_path()),
label: std::sync::Arc::from(label.as_str()),
state: ActivityState::Idle,
state_started_at: now - back_date,
created_at: now - back_date,
last_event_at: now - back_date,
exiting_at: None,
pending_idle_at: None,
desk_index: GlobalDeskIndex(i),
floor_idx: s.floor_of(GlobalDeskIndex(i)),
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
},
);
}
fill_sample_agents(&mut s, now, n..n_agents);
eprintln!("MEETING staged {n} agents, warmup={warmup_ms}ms (min desk_dwell {min_dwell}ms − {MEETING_WARMUP_LEAD_MS}ms)");
Ok((s, warmup_ms))
}
fn dashboard_scene(now: SystemTime) -> SceneState {
use pixtuoid_core::source::{claude_code, codex, reasonix};
use pixtuoid_core::state::ActivityState;
use std::time::Duration as D;
type DashAgentSpec = (
&'static str,
&'static str,
ActivityState,
Option<AgentId>,
usize,
&'static str,
);
let mut s = SceneState::uniform(12);
let cc_root_id = AgentId::from_transcript_path("/demo/dash_cc_root.jsonl");
let agents: &[DashAgentSpec] = &[
(
"cc·pixtuoid",
"/demo/dash_cc_root.jsonl",
ActivityState::Active {
tool_use_id: Some("tu0".into()),
detail: Some("Edit: reducer.rs".into()),
},
None,
0,
claude_code::SOURCE_NAME,
),
(
"code-explorer",
"/demo/dash_cc_sub1.jsonl",
ActivityState::Active {
tool_use_id: Some("tu1".into()),
detail: Some("Grep: TODO".into()),
},
Some(cc_root_id),
1,
claude_code::SOURCE_NAME,
),
(
"code-reviewer",
"/demo/dash_cc_sub2.jsonl",
ActivityState::Idle,
Some(cc_root_id),
2,
claude_code::SOURCE_NAME,
),
(
"cx·sidecar",
"/demo/dash_cx_root.jsonl",
ActivityState::Idle,
None,
3,
codex::SOURCE_NAME,
),
(
"rx·helper",
"/demo/dash_rx_root.jsonl",
ActivityState::Waiting {
reason: "permission?".into(),
},
None,
4,
reasonix::SOURCE_NAME,
),
];
for (label, path, state, parent_id, desk_index, source) in agents {
let id = AgentId::from_transcript_path(path);
s.agents.insert(
id,
AgentSlot {
agent_id: id,
source: std::sync::Arc::from(*source),
session_id: std::sync::Arc::from(
format!("demo-dash-{}", label.replace('·', "-")).as_str(),
),
cwd: std::sync::Arc::from(PathBuf::from("/demo").as_path()),
label: std::sync::Arc::from(*label),
state: state.clone(),
state_started_at: now,
created_at: now - D::from_secs(*desk_index as u64),
last_event_at: now,
exiting_at: None,
pending_idle_at: None,
desk_index: GlobalDeskIndex(*desk_index),
floor_idx: s.floor_of(GlobalDeskIndex(*desk_index)),
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: *parent_id,
},
);
}
s
}
fn anim_scene(
now: SystemTime,
target: &str,
cols: u16,
rows: u16,
floor_seed: u64,
facing: Option<&str>,
) -> (SceneState, u64) {
use pixtuoid_core::layout::{Facing, SceneLayout, WaypointKind, MAX_VISIBLE_DESKS};
use pixtuoid_core::pose::{
is_aimless_cycle, seated_dwell_ms, takes_trip, waypoint_index_for_cycle,
};
let (buf_w, buf_h) = (cols, rows.saturating_sub(1).saturating_mul(2));
let l = SceneLayout::compute_with_seed(buf_w, buf_h, MAX_VISIBLE_DESKS, floor_seed)
.expect("anim layout computes");
let n = l.waypoints.len();
let target_kind = match target {
"couch" => Some(WaypointKind::Couch),
"sofa" => Some(WaypointKind::MeetingSofa),
"stand" => Some(WaypointKind::MeetingStand),
"pantry" => Some(WaypointKind::Pantry),
_ => None, };
let want_facing = match facing {
Some("north") => Some(Facing::North),
Some("south") => Some(Facing::South),
Some("east") => Some(Facing::East),
Some("west") => Some(Facing::West),
_ => None,
};
let target_idxs: Vec<usize> = l
.waypoints
.iter()
.enumerate()
.filter(|(_, w)| Some(w.kind) == target_kind)
.filter(|(_, w)| want_facing.is_none_or(|f| w.facing == f))
.map(|(i, _)| i)
.collect();
if target == "desk" {
if let Some(d) = l.home_desks.first() {
eprintln!("ANIM target=desk buf_pos≈({}, {}) [home desk 0]", d.x, d.y);
}
} else if let Some(&i) = target_idxs.first() {
let p = l.waypoints[i].pos;
eprintln!(
"ANIM target={target} buf_pos=({}, {}) [{} matching waypoints, {n} total]",
p.x,
p.y,
target_idxs.len()
);
} else {
eprintln!(
"ANIM target={target}: no matching waypoint at {buf_w}x{buf_h} seed {floor_seed}"
);
}
let path = (0u64..40_000)
.map(|i| format!("/anim/{target}_{i}.jsonl"))
.find(|p| {
let id = AgentId::from_transcript_path(p);
takes_trip(id, 0)
&& !is_aimless_cycle(id, 0)
&& (target == "desk"
|| (n > 0 && target_idxs.contains(&waypoint_index_for_cycle(id, 0, n))))
})
.unwrap_or_else(|| format!("/anim/{target}_fallback.jsonl"));
let id = AgentId::from_transcript_path(&path);
if target != "desk" && n > 0 {
let wi = waypoint_index_for_cycle(id, 0, n);
let wp = l.waypoints[wi];
eprintln!(
"ANIM agent ACTUAL target = waypoint[{wi}] {:?} facing {:?} at buf_pos=({}, {})",
wp.kind, wp.facing, wp.pos.x, wp.pos.y
);
}
let skip_ms = seated_dwell_ms(id).saturating_sub(1_000);
eprintln!(
"ANIM agent seated_dwell={}ms → pre-roll skip={skip_ms}ms",
seated_dwell_ms(id)
);
let mut s = SceneState::uniform(MAX_VISIBLE_DESKS);
s.agents.insert(
id,
AgentSlot {
agent_id: id,
source: std::sync::Arc::from("claude-code"),
session_id: std::sync::Arc::from("anim"),
cwd: std::sync::Arc::from(PathBuf::from("/anim").as_path()),
label: std::sync::Arc::from(target),
state: ActivityState::Idle,
state_started_at: now,
created_at: now,
last_event_at: now,
exiting_at: None,
pending_idle_at: None,
desk_index: GlobalDeskIndex(0),
floor_idx: 0,
tool_call_count: 0,
active_ms: 0,
unknown_cwd: false,
parent_id: None,
},
);
(s, skip_ms)
}
fn compute_crop_rect(
args: &SnapshotArgs,
scene: &SceneState,
history: &pixtuoid_scene::pose::PoseHistory,
cols: u16,
rows: u16,
now: SystemTime,
) -> Result<Option<ratatui::layout::Rect>> {
use pixtuoid_core::layout::WaypointKind;
let target_pixel: pixtuoid_scene::layout::Point = if let Some(ref agent_label) = args.crop_agent
{
let slot = scene
.agents
.values()
.find(|s| s.label.as_ref() == agent_label)
.ok_or_else(|| {
let labels: Vec<&str> = scene.agents.values().map(|s| s.label.as_ref()).collect();
anyhow::anyhow!(
"--crop-agent {agent_label:?} not found in scene; labels: {}",
labels.join(", ")
)
})?;
history
.recent(slot.agent_id, u64::MAX, now)
.ok_or_else(|| anyhow::anyhow!("agent {agent_label:?} has no visual position"))?
} else if let Some(ref furniture_str) = args.crop_furniture {
let buf_w = cols;
let buf_h = rows.saturating_sub(1).saturating_mul(2);
let layout = pixtuoid_core::layout::SceneLayout::compute_with_seed(
buf_w,
buf_h,
scene.floor_capacities[0],
args.floor_seed,
)
.ok_or_else(|| anyhow::anyhow!("scene too small to compute a layout"))?;
let found = match furniture_str.to_lowercase().as_str() {
"desk" => layout.home_desks.first().copied(),
name => {
let kind = match name {
"pantry" => WaypointKind::Pantry,
"couch" => WaypointKind::Couch,
"vending" => WaypointKind::VendingMachine,
"printer" => WaypointKind::Printer,
"meeting" | "sofa" => WaypointKind::MeetingSofa,
other => anyhow::bail!(
"unknown --crop-furniture {other:?}; valid: pantry | couch | vending | printer | meeting | sofa | desk"
),
};
layout
.waypoints
.iter()
.find(|w| w.kind == kind)
.map(|w| w.pos)
}
};
found.ok_or_else(|| {
anyhow::anyhow!("no {furniture_str:?} waypoint in this layout (terminal too small?)")
})?
} else {
return Ok(None);
};
Ok(Some(centered_crop(
target_pixel.x,
target_pixel.y / 2,
cols,
rows,
)))
}
fn centered_crop(cell_x: u16, cell_y: u16, cols: u16, rows: u16) -> ratatui::layout::Rect {
let crop_w = 40u16.min(cols);
let crop_h = 24u16.min(rows);
let crop_x = cell_x
.saturating_sub(crop_w / 2)
.min(cols.saturating_sub(crop_w));
let crop_y = cell_y
.saturating_sub(crop_h / 2)
.min(rows.saturating_sub(crop_h));
ratatui::layout::Rect {
x: crop_x,
y: crop_y,
width: crop_w,
height: crop_h,
}
}
fn save_backend_as_png(
term: &Terminal<TestBackend>,
path: &PathBuf,
cols: u16,
rows: u16,
crop: Option<ratatui::layout::Rect>,
) -> Result<()> {
let buf = term.backend().buffer();
let (start_x, start_y, render_w, render_h) = match crop {
Some(r) => (r.x, r.y, r.width, r.height),
None => (0, 0, cols, rows),
};
let img_w = render_w as u32 * CELL_W;
let img_h = render_h as u32 * CELL_H;
let mut img = RgbImage::new(img_w, img_h);
for y in 0..render_h {
for x in 0..render_w {
let cell = &buf[(start_x + x, start_y + y)];
let symbol = cell.symbol();
let fg = color_to_rgb(cell.fg, ImgRgb([220, 220, 220]));
let bg = color_to_rgb(cell.bg, ImgRgb([20, 22, 28]));
let x0 = x as u32 * CELL_W;
let y0 = y as u32 * CELL_H;
if symbol == "▀" {
fill_rect(&mut img, x0, y0, CELL_W, CELL_H / 2, fg);
fill_rect(&mut img, x0, y0 + CELL_H / 2, CELL_W, CELL_H / 2, bg);
} else if symbol.trim().is_empty() {
fill_rect(&mut img, x0, y0, CELL_W, CELL_H, bg);
} else if let Some(rows) =
pixtuoid_scene::font::glyph8x8(symbol.chars().next().unwrap_or(' '))
{
fill_rect(&mut img, x0, y0, CELL_W, CELL_H, bg);
blit_glyph_cell(rows, x0, y0, |px, py| {
if px < img_w && py < img_h {
img.put_pixel(px, py, fg);
}
});
} else {
fill_rect(&mut img, x0, y0, CELL_W, CELL_H, bg);
let pad_x = 1;
let pad_y = 3;
fill_rect(
&mut img,
x0 + pad_x,
y0 + pad_y,
CELL_W - pad_x * 2,
CELL_H - pad_y * 2,
fg,
);
}
}
}
img.save(path)?;
Ok(())
}
fn cells_to_rgba(
term_buf: &ratatui::buffer::Buffer,
cols: u16,
rows: u16,
img_w: u32,
img_h: u32,
) -> RgbaImage {
let mut rgba = RgbaImage::new(img_w, img_h);
for y in 0..rows {
for x in 0..cols {
let cell = &term_buf[(x, y)];
let symbol = cell.symbol();
let fg = color_to_rgb(cell.fg, ImgRgb([220, 220, 220]));
let bg = color_to_rgb(cell.bg, ImgRgb([20, 22, 28]));
let x0 = x as u32 * CELL_W;
let y0 = y as u32 * CELL_H;
if symbol == "▀" {
fill_rgba_rect(&mut rgba, x0, y0, CELL_W, CELL_H / 2, fg);
fill_rgba_rect(&mut rgba, x0, y0 + CELL_H / 2, CELL_W, CELL_H / 2, bg);
} else if symbol.trim().is_empty() {
fill_rgba_rect(&mut rgba, x0, y0, CELL_W, CELL_H, bg);
} else if let Some(rows) =
pixtuoid_scene::font::glyph8x8(symbol.chars().next().unwrap_or(' '))
{
fill_rgba_rect(&mut rgba, x0, y0, CELL_W, CELL_H, bg);
let fg_rgba = Rgba([fg[0], fg[1], fg[2], 255]);
blit_glyph_cell(rows, x0, y0, |px, py| {
if px < img_w && py < img_h {
rgba.put_pixel(px, py, fg_rgba);
}
});
} else {
fill_rgba_rect(&mut rgba, x0, y0, CELL_W, CELL_H, bg);
let pad_x = 1;
let pad_y = 3;
fill_rgba_rect(
&mut rgba,
x0 + pad_x,
y0 + pad_y,
CELL_W - pad_x * 2,
CELL_H - pad_y * 2,
fg,
);
}
}
}
rgba
}
fn due_navigations(
navigations: &[(u64, usize)],
fired: &mut [bool],
elapsed_ms: u64,
) -> Vec<usize> {
let mut due = Vec::new();
for (n, &(at_ms, floor)) in navigations.iter().enumerate() {
if !fired[n] && elapsed_ms >= at_ms {
fired[n] = true;
due.push(floor);
}
}
due
}
#[allow(clippy::too_many_arguments)]
fn save_renderer_gif(
term: Terminal<TestBackend>,
scene: &SceneState,
pack: &pixtuoid_core::sprite::format::Pack,
start_now: SystemTime,
path: &PathBuf,
cols: u16,
rows: u16,
fps: u64,
duration_secs: u64,
theme: &'static pixtuoid_scene::theme::Theme,
navigations: &[(u64, usize)],
pets: Vec<pixtuoid_scene::pet::Pet>,
) -> Result<()> {
use pixtuoid_core::render::Renderer as _;
let frame_count = (duration_secs * fps) as usize;
let frame_ms = 1000 / fps.max(1);
let img_w = cols as u32 * CELL_W;
let img_h = rows as u32 * CELL_H;
let file = std::fs::File::create(path)?;
let mut encoder = GifEncoder::new(file);
encoder.set_repeat(Repeat::Infinite)?;
let mut r = pixtuoid::tui::tui_renderer::TuiRenderer::new(term, theme, pets);
let mut fired = vec![false; navigations.len()];
for i in 0..frame_count {
let elapsed_ms = i as u64 * 1000 / fps.max(1);
let now = start_now + Duration::from_millis(elapsed_ms);
for floor in due_navigations(navigations, &mut fired, elapsed_ms) {
r.navigate_floor(floor, now);
}
r.render(scene, pack, now)?;
let rgba = cells_to_rgba(r.terminal.backend().buffer(), cols, rows, img_w, img_h);
let delay = Delay::from_numer_denom_ms(frame_ms as u32, 1);
encoder.encode_frame(GifFrame::from_parts(rgba, 0, 0, delay))?;
let cap = i + 1;
if cap.is_multiple_of(fps as usize) {
eprint!("\r encoding: {}/{}s", cap / fps as usize, duration_secs);
}
}
eprintln!("\r encoded {frame_count} frames @ {fps}fps");
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn save_as_gif(
term: &mut Terminal<TestBackend>,
scene: &SceneState,
pack: &pixtuoid_core::sprite::format::Pack,
start_now: SystemTime,
path: &PathBuf,
cols: u16,
rows: u16,
buf: &mut RgbBuffer,
cache: &mut FrameCache,
router: &mut pixtuoid_scene::pathfind::AStarRouter,
overlay: &mut pixtuoid_core::walkable::OccupancyOverlay,
history: &mut pixtuoid_scene::pose::PoseHistory,
fps: u64,
duration_secs: u64,
theme: &pixtuoid_scene::theme::Theme,
floor_seed: u64,
skip_ms: u64,
debug_walkable: bool,
) -> Result<()> {
let frame_count = (duration_secs * fps) as usize;
let frame_ms = 1000 / fps.max(1);
let skip_frames = (skip_ms / frame_ms.max(1)) as usize;
let img_w = cols as u32 * CELL_W;
let img_h = rows as u32 * CELL_H;
let ticker = TickerQueue::new();
let file = std::fs::File::create(path)?;
let mut encoder = GifEncoder::new(file);
encoder.set_repeat(Repeat::Infinite)?;
let mut chitchat_state = std::collections::HashMap::new();
let mut light = pixtuoid_scene::floor::LightingState::new();
let mut motion: std::collections::HashMap<
pixtuoid_core::AgentId,
pixtuoid_scene::motion::MotionState,
> = std::collections::HashMap::new();
for i in 0..(skip_frames + frame_count) {
let now = start_now + Duration::from_millis(i as u64 * frame_ms);
let mut draw_ctx = DrawCtx {
buf,
cache,
router,
overlay,
history,
motion: &mut motion,
door_anim_max_ms: 0,
light: &mut light,
mouse_pos: None,
pinned_agent: None,
debug_walkable,
ticker: &ticker,
theme,
theme_picker: None,
floor_info: None,
floor: {
let mut m = pixtuoid_scene::floor::FloorMeta::ground();
m.floor_seed = floor_seed;
m
},
active_pet: None,
last_pet_pos: None,
last_mascot_pos: None,
floor_pet: None,
chitchat_state: &mut chitchat_state,
chitchat_bubbles: Vec::new(),
coffee_holders: &std::collections::HashSet::new(),
coffee_fetched_at: &std::collections::HashMap::new(),
new_coffee_carriers: Vec::new(),
popup_scale: 0.0,
help_open: false,
source_warning: None,
dashboard: &pixtuoid::tui::dashboard::DashboardFrame::default(),
connection: &pixtuoid::tui::connection::ConnectionFrame::default(),
onboarding: &pixtuoid::tui::welcome::OnboardingFrame::default(),
};
draw_scene(term, scene, pack, now, &mut draw_ctx)?;
if i < skip_frames {
continue; }
let rgba = cells_to_rgba(term.backend().buffer(), cols, rows, img_w, img_h);
let delay = Delay::from_numer_denom_ms(frame_ms as u32, 1);
let frame = GifFrame::from_parts(rgba, 0, 0, delay);
encoder.encode_frame(frame)?;
let cap = i + 1 - skip_frames;
if cap.is_multiple_of(fps as usize) {
eprint!("\r encoding: {}/{}s", cap / fps as usize, duration_secs);
}
}
eprintln!("\r encoded {frame_count} frames @ {fps}fps");
Ok(())
}
fn fill_rgba_rect(img: &mut RgbaImage, x: u32, y: u32, w: u32, h: u32, color: ImgRgb<u8>) {
let (img_w, img_h) = (img.width(), img.height());
let rgba = Rgba([color[0], color[1], color[2], 255]);
for j in 0..h {
for i in 0..w {
let px_x = x + i;
let px_y = y + j;
if px_x < img_w && px_y < img_h {
img.put_pixel(px_x, px_y, rgba);
}
}
}
}
fn fill_rect(img: &mut RgbImage, x: u32, y: u32, w: u32, h: u32, color: ImgRgb<u8>) {
let (img_w, img_h) = (img.width(), img.height());
for j in 0..h {
for i in 0..w {
let px_x = x + i;
let px_y = y + j;
if px_x < img_w && px_y < img_h {
img.put_pixel(px_x, px_y, color);
}
}
}
}
fn blit_glyph_cell(rows: [u8; 8], x0: u32, y0: u32, mut put: impl FnMut(u32, u32)) {
for (fr, &bits) in rows.iter().enumerate() {
for col in 0..CELL_W {
if bits & (1u8 << col) != 0 {
let px = x0 + col;
let py = y0 + fr as u32 * 2;
put(px, py);
put(px, py + 1);
}
}
}
}
fn color_to_rgb(c: Color, default: ImgRgb<u8>) -> ImgRgb<u8> {
match c {
Color::Rgb(r, g, b) => ImgRgb([r, g, b]),
Color::Black => ImgRgb([0, 0, 0]),
Color::Red => ImgRgb([180, 50, 50]),
Color::Green => ImgRgb([60, 180, 60]),
Color::Yellow => ImgRgb([220, 200, 50]),
Color::Blue => ImgRgb([60, 120, 220]),
Color::Magenta => ImgRgb([200, 60, 200]),
Color::Cyan => ImgRgb([50, 200, 220]),
Color::Gray => ImgRgb([160, 160, 160]),
Color::DarkGray => ImgRgb([80, 80, 80]),
Color::White => ImgRgb([240, 240, 240]),
Color::LightRed => ImgRgb([230, 100, 100]),
Color::LightGreen => ImgRgb([100, 230, 100]),
Color::LightYellow => ImgRgb([240, 230, 100]),
Color::LightBlue => ImgRgb([130, 180, 250]),
Color::LightMagenta => ImgRgb([240, 130, 240]),
Color::LightCyan => ImgRgb([130, 240, 240]),
Color::Indexed(_) | Color::Reset => default,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blit_glyph_cell_lsb_is_leftmost_and_doubles_vertically() {
let mut hits: Vec<(u32, u32)> = Vec::new();
let mut rows = [0u8; 8];
rows[0] = 0x01;
blit_glyph_cell(rows, 0, 0, |px, py| hits.push((px, py)));
assert_eq!(
hits,
vec![(0, 0), (0, 1)],
"LSB must map to the LEFTMOST column"
);
let mut hits2: Vec<(u32, u32)> = Vec::new();
let mut rows2 = [0u8; 8];
rows2[1] = 0x80;
blit_glyph_cell(rows2, 0, 0, |px, py| hits2.push((px, py)));
assert_eq!(
hits2,
vec![(7, 2), (7, 3)],
"MSB → rightmost col; row 1 → img rows 2,3"
);
let mut hits3: Vec<(u32, u32)> = Vec::new();
let mut rows3 = [0u8; 8];
rows3[0] = 0x01;
blit_glyph_cell(rows3, 8, 16, |px, py| hits3.push((px, py)));
assert_eq!(hits3, vec![(8, 16), (8, 17)]);
}
#[test]
fn parse_navigations_happy_and_fractional() {
assert_eq!(
parse_navigations(&["3:1".to_string(), "2.5:0".to_string()]).unwrap(),
vec![(3000, 1), (2500, 0)]
);
}
#[test]
fn parse_navigations_truncates_fractional_ms() {
assert_eq!(
parse_navigations(&["0.9999:0".to_string()]).unwrap(),
vec![(999, 0)]
);
}
#[test]
fn parse_navigations_rejects_bad_input() {
for bad in ["5-1", "5:x", "x:1", "", ":", "5:"] {
assert!(
parse_navigations(&[bad.to_string()]).is_err(),
"accepted {bad:?}"
);
}
}
#[test]
fn due_navigations_fires_each_exactly_once_in_schedule_order() {
let navs = vec![(7000u64, 0usize), (3000, 1)];
let mut fired = vec![false; navs.len()];
let mut hits: Vec<(u64, usize)> = Vec::new();
for i in 0..150u64 {
let elapsed_ms = i * 1000 / 15;
for floor in due_navigations(&navs, &mut fired, elapsed_ms) {
hits.push((i, floor));
}
}
assert_eq!(hits, vec![(45, 1), (105, 0)]);
}
#[test]
fn due_navigations_late_schedule_still_fires_within_capture() {
let navs = vec![(9900u64, 1usize)];
let mut fired = vec![false; 1];
let mut hit = None;
for i in 0..150u64 {
let elapsed_ms = i * 1000 / 15;
if !due_navigations(&navs, &mut fired, elapsed_ms).is_empty() {
hit = Some(i);
}
}
assert_eq!(hit, Some(149));
}
fn crop_args(extra: &[&str]) -> SnapshotArgs {
SnapshotArgs::try_parse_from([&["snapshot"], extra].concat()).unwrap()
}
#[test]
fn centered_crop_centers_in_the_open() {
let r = centered_crop(96, 32, 192, 64);
assert_eq!((r.x, r.y, r.width, r.height), (76, 20, 40, 24));
}
#[test]
fn centered_crop_clamps_at_origin_and_far_edge() {
let near_origin = centered_crop(2, 1, 192, 64);
assert_eq!((near_origin.x, near_origin.y), (0, 0));
let near_edge = centered_crop(191, 63, 192, 64);
assert_eq!((near_edge.x, near_edge.y), (152, 40));
}
#[test]
fn centered_crop_shrinks_to_a_small_terminal() {
let r = centered_crop(10, 5, 30, 20);
assert_eq!((r.x, r.y, r.width, r.height), (0, 0, 30, 20));
}
#[test]
fn crop_rect_centers_on_the_pantry_waypoint() {
let now = SystemTime::now();
let scene = sample_scene(now, 12, 12);
let history = pixtuoid_scene::pose::PoseHistory::new();
let args = crop_args(&["--crop-furniture", "pantry"]);
let rect = compute_crop_rect(&args, &scene, &history, 192, 64, now)
.unwrap()
.expect("pantry crop");
let layout =
pixtuoid_core::layout::SceneLayout::compute_with_seed(192, 126, 12, 0).unwrap();
let pantry = layout
.waypoints
.iter()
.find(|w| w.kind == pixtuoid_core::layout::WaypointKind::Pantry)
.unwrap();
let (cx, cy) = (pantry.pos.x, pantry.pos.y / 2);
assert!(rect.x <= cx && cx < rect.x + rect.width, "x not in crop");
assert!(rect.y <= cy && cy < rect.y + rect.height, "y not in crop");
}
#[test]
fn crop_rect_without_flags_is_none() {
let now = SystemTime::now();
let scene = sample_scene(now, 12, 12);
let history = pixtuoid_scene::pose::PoseHistory::new();
let args = crop_args(&[]);
assert!(compute_crop_rect(&args, &scene, &history, 192, 64, now)
.unwrap()
.is_none());
}
#[test]
fn crop_rect_fails_loudly_on_typos_and_unknown_agents() {
let now = SystemTime::now();
let scene = sample_scene(now, 12, 12);
let history = pixtuoid_scene::pose::PoseHistory::new();
let typo = crop_args(&["--crop-furniture", "fridge"]);
let err = compute_crop_rect(&typo, &scene, &history, 192, 64, now).unwrap_err();
assert!(err.to_string().contains("valid: pantry"), "{err}");
let ghost = crop_args(&["--crop-agent", "ghost"]);
let err = compute_crop_rect(&ghost, &scene, &history, 192, 64, now).unwrap_err();
assert!(err.to_string().contains("labels:"), "{err}");
}
#[test]
fn crop_flags_conflict_with_gif_and_anim() {
assert!(SnapshotArgs::try_parse_from(["snapshot", "--gif", "--crop-agent", "x"]).is_err());
assert!(SnapshotArgs::try_parse_from([
"snapshot",
"--anim",
"couch",
"--crop-furniture",
"pantry"
])
.is_err());
}
#[test]
fn meeting_flag_parses_caps_and_conflicts() {
let ok = ["snapshot", "out.gif", "--gif", "--meeting", "3"];
assert!(SnapshotArgs::try_parse_from(ok).is_ok());
for bad in [
vec!["snapshot", "--gif", "--meeting", "1"],
vec!["snapshot", "--gif", "--meeting", "4"],
vec!["snapshot", "--meeting", "3"],
vec!["snapshot", "--gif", "--meeting", "3", "--anim", "sofa"],
vec!["snapshot", "--gif", "--meeting", "3", "--pets", "cat"],
vec![
"snapshot",
"--gif",
"--meeting",
"3",
"--navigate-at",
"3:1",
],
vec!["snapshot", "--gif", "--meeting", "3", "--dashboard"],
vec!["snapshot", "--gif", "--meeting", "3", "--empty"],
] {
assert!(
SnapshotArgs::try_parse_from(bad.clone()).is_err(),
"accepted {bad:?}"
);
}
}
#[test]
fn warmup_flag_requires_gif_and_conflicts_with_anim() {
assert!(
SnapshotArgs::try_parse_from(["snapshot", "--gif", "--warmup-secs", "13.5"]).is_ok()
);
assert!(SnapshotArgs::try_parse_from(["snapshot", "--warmup-secs", "5"]).is_err());
assert!(SnapshotArgs::try_parse_from([
"snapshot",
"--gif",
"--warmup-secs",
"5",
"--anim",
"sofa"
])
.is_err());
}
#[test]
fn meeting_scene_stages_a_convergent_group() {
use pixtuoid_core::layout::{SceneLayout, WaypointKind};
use pixtuoid_core::pose::{
est_wander_cycle_ms, is_aimless_cycle, seated_dwell_ms, takes_trip,
waypoint_index_for_cycle,
};
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let (cols, rows, max_desks) = (208u16, 88u16, 12);
let (scene, warmup_ms) = meeting_scene(now, 3, cols, rows, 0, max_desks, 12).unwrap();
assert_eq!(scene.agents.len(), 12, "staged 3 + 9 archetype fillers");
let layout = SceneLayout::compute_with_seed(cols, (rows - 1) * 2, max_desks, 0).unwrap();
let staged: Vec<_> = scene
.agents
.values()
.filter(|s| s.label.starts_with("meet-"))
.collect();
assert_eq!(staged.len(), 3);
let mut wp_idxs = Vec::new();
let mut rooms = Vec::new();
let mut dwells = Vec::new();
for slot in &staged {
assert!(slot.desk_index.0 < 3, "staged agents take desks 0..n");
let id = slot.agent_id;
let elapsed_ms = now
.duration_since(slot.state_started_at)
.unwrap()
.as_millis() as u64;
let cycle_n = elapsed_ms / est_wander_cycle_ms(id);
assert!(cycle_n >= 1, "cycle 0 back-dates under the thinking window");
assert!(takes_trip(id, cycle_n), "staged cycle must be a trip");
assert!(!is_aimless_cycle(id, cycle_n), "trip must be directed");
let wp_idx = waypoint_index_for_cycle(id, cycle_n, layout.waypoints.len());
let wp = layout.waypoints[wp_idx];
assert!(
matches!(
wp.kind,
WaypointKind::MeetingSofa | WaypointKind::MeetingStand
),
"destination must be a meeting slot, got {:?}",
wp.kind
);
wp_idxs.push(wp_idx);
rooms.push(wp.room_id.expect("meeting slots carry a room_id"));
dwells.push(seated_dwell_ms(id));
}
wp_idxs.sort_unstable();
wp_idxs.dedup();
assert_eq!(wp_idxs.len(), 3, "slots must be distinct (no seat fights)");
assert!(
rooms.iter().all(|r| *r == rooms[0]),
"one room → one chitchat venue"
);
let (min_d, max_d) = (*dwells.iter().min().unwrap(), *dwells.iter().max().unwrap());
assert!(
max_d - min_d <= MEETING_DWELL_SPREAD_MS,
"dwell spread {}ms exceeds {}ms",
max_d - min_d,
MEETING_DWELL_SPREAD_MS
);
assert_eq!(warmup_ms, min_d.saturating_sub(MEETING_WARMUP_LEAD_MS));
}
#[test]
fn dashboard_flag_parses_and_conflicts_with_anim() {
assert!(SnapshotArgs::try_parse_from(["snapshot", "out.png", "--dashboard"]).is_ok());
assert!(SnapshotArgs::try_parse_from([
"snapshot",
"out.png",
"--dashboard",
"--anim",
"desk"
])
.is_err());
assert!(
SnapshotArgs::try_parse_from(["snapshot", "out.png", "--dashboard", "--gif"]).is_err()
);
}
}