use super::*;
const BALLS_BODY_HEIGHT: u16 = 3;
const BALLS_BALL_WIDTH: u16 = 5;
#[cfg(test)]
pub(crate) fn render_entity(
renderer: &mut Renderer,
entity: &SessionEntity,
rect: Rect,
selected: bool,
tick: u64,
repo_themes: &HashMap<String, RepoTheme>,
) {
render_entity_with_theme(
renderer,
entity,
rect,
selected,
tick,
repo_themes,
SpriteTheme::Fish,
);
}
#[derive(Clone, Copy)]
pub(crate) struct BallsThemeSlot<'a> {
pub(crate) entity: &'a SessionEntity,
pub(crate) anchor_x: u16,
pub(crate) cord_top_y: u16,
pub(crate) ball_x: u16,
pub(crate) ball_y: u16,
pub(crate) ball_width: u16,
pub(crate) ball_height: u16,
pub(crate) on_floor: bool,
}
impl<'a> BallsThemeSlot<'a> {
pub(crate) fn hit_rect(self) -> Rect {
Rect {
x: self.ball_x,
y: self.cord_top_y,
width: self.ball_width,
height: self
.ball_y
.saturating_add(self.ball_height)
.saturating_sub(self.cord_top_y),
}
}
}
fn balls_theme_body_rect(count: usize, field: Rect) -> Rect {
let desired_width = (count as u16)
.saturating_mul(5)
.saturating_add(12)
.clamp(18, field.width.saturating_sub(2));
Rect {
x: field.x + field.width.saturating_sub(desired_width) / 2,
y: field.y,
width: desired_width,
height: BALLS_BODY_HEIGHT,
}
}
fn balls_center_out_indices(count: usize) -> Vec<usize> {
if count == 0 {
return Vec::new();
}
let mut order = Vec::with_capacity(count);
let left_center = (count - 1) / 2;
let right_center = count / 2;
order.push(left_center);
if right_center != left_center {
order.push(right_center);
}
let mut offset = 1usize;
while order.len() < count {
if let Some(index) = left_center.checked_sub(offset) {
order.push(index);
}
let right = right_center + offset;
if right < count {
order.push(right);
}
offset += 1;
}
order
}
fn balls_theme_centers(count: usize, body: Rect) -> Vec<u16> {
if count == 0 {
return Vec::new();
}
if count == 1 {
return vec![body.x + body.width / 2];
}
let left = body.x.saturating_add(2);
let span = body.width.saturating_sub(5);
(0..count)
.map(|index| left + ((index as u32 * span as u32) / (count as u32 - 1)) as u16)
.collect()
}
fn balls_theme_ball_height(kind: SpriteKind) -> u16 {
match kind {
SpriteKind::Attention => 2,
SpriteKind::Active | SpriteKind::Busy | SpriteKind::Error => 3,
SpriteKind::Drowsy | SpriteKind::Sleeping | SpriteKind::DeepSleep | SpriteKind::Exited => 4,
}
}
fn balls_theme_base_drop(kind: SpriteKind) -> u16 {
match kind {
SpriteKind::Attention => 1,
SpriteKind::Active => 3,
SpriteKind::Busy => 4,
SpriteKind::Error => 5,
SpriteKind::Drowsy => 7,
SpriteKind::Sleeping => 10,
SpriteKind::DeepSleep | SpriteKind::Exited => 12,
}
}
fn balls_theme_age_bonus(age_rank: usize, count: usize, kind: SpriteKind) -> u16 {
if count <= 1 || matches!(kind, SpriteKind::Attention) {
return 0;
}
let max_rank = count - 1;
let max_bonus = 3usize;
((max_rank.saturating_sub(age_rank)) * max_bonus / max_rank) as u16
}
pub(crate) fn balls_theme_slots<'a>(
entities: &[&'a SessionEntity],
field: Rect,
) -> Vec<BallsThemeSlot<'a>> {
if entities.is_empty() || field.height <= BALLS_BODY_HEIGHT + 2 {
return Vec::new();
}
let body = balls_theme_body_rect(entities.len(), field);
let centers = balls_theme_centers(entities.len(), body);
let placement = balls_center_out_indices(entities.len());
let mut ordered = entities.to_vec();
ordered.sort_by(|left, right| compare_sleepiness(&left.session, &right.session));
let floor_y = field.bottom().saturating_sub(1);
let cord_top_y = body.bottom();
let mut slots = Vec::with_capacity(ordered.len());
for (age_rank, entity) in ordered.into_iter().enumerate() {
let placement_index = *placement.get(age_rank).unwrap_or(&age_rank);
let anchor_x = *centers.get(placement_index).unwrap_or(¢ers[0]);
let kind = entity.sprite_kind();
let ball_height = balls_theme_ball_height(kind);
let mut drop =
balls_theme_base_drop(kind) + balls_theme_age_bonus(age_rank, entities.len(), kind);
if matches!(kind, SpriteKind::DeepSleep | SpriteKind::Exited) {
drop = drop.max(field.height.saturating_sub(BALLS_BODY_HEIGHT + ball_height));
}
let ball_y = (cord_top_y + drop).min(floor_y.saturating_add(1).saturating_sub(ball_height));
let ball_x = anchor_x
.saturating_sub(BALLS_BALL_WIDTH / 2)
.clamp(field.x, field.right().saturating_sub(BALLS_BALL_WIDTH));
slots.push(BallsThemeSlot {
entity,
anchor_x,
cord_top_y,
ball_x,
ball_y,
ball_width: BALLS_BALL_WIDTH,
ball_height,
on_floor: ball_y.saturating_add(ball_height) >= field.bottom(),
});
}
slots
}
pub(crate) fn balls_theme_hit_test<'a>(
entities: &[&'a SessionEntity],
field: Rect,
x: u16,
y: u16,
) -> Option<&'a SessionEntity> {
balls_theme_slots(entities, field)
.into_iter()
.find(|slot| slot.hit_rect().contains(x, y))
.map(|slot| slot.entity)
}
fn render_balls_theme_body(
renderer: &mut Renderer,
body: Rect,
slots: &[BallsThemeSlot<'_>],
tick: u64,
) {
if body.width < 6 {
return;
}
let ripple = if tick % 12 < 6 { '~' } else { '^' };
renderer.draw_char(body.x, body.y, '.', Color::DarkGrey);
renderer.draw_hline(body.x + 1, body.y, body.width - 2, ripple, Color::DarkGrey);
renderer.draw_char(body.right() - 1, body.y, '.', Color::DarkGrey);
renderer.draw_char(body.x, body.y + 1, '/', Color::DarkGrey);
renderer.draw_hline(body.x + 1, body.y + 1, body.width - 2, ' ', Color::DarkGrey);
renderer.draw_char(body.right() - 1, body.y + 1, '\\', Color::DarkGrey);
renderer.draw_char(body.x, body.bottom() - 1, '(', Color::DarkGrey);
renderer.draw_hline(
body.x + 1,
body.bottom() - 1,
body.width - 2,
'_',
Color::DarkGrey,
);
renderer.draw_char(body.right() - 1, body.bottom() - 1, ')', Color::DarkGrey);
for slot in slots {
if slot.anchor_x > body.x && slot.anchor_x < body.right().saturating_sub(1) {
renderer.draw_char(slot.anchor_x, body.bottom() - 1, 'v', Color::DarkGrey);
}
}
}
fn render_balls_theme_ball(
renderer: &mut Renderer,
slot: BallsThemeSlot<'_>,
selected: bool,
color: Color,
) {
let kind = slot.entity.sprite_kind();
let cord_char = match kind {
SpriteKind::Attention => '!',
SpriteKind::Busy => ':',
SpriteKind::Error | SpriteKind::Exited => 'x',
_ => '|',
};
let cord_color = if selected {
Color::White
} else {
Color::DarkGrey
};
for y in slot.cord_top_y..slot.ball_y {
renderer.draw_char(slot.anchor_x, y, cord_char, cord_color);
}
let rows: &[&str] = match kind {
SpriteKind::Attention => &[" .-. ", "(_!_)"],
SpriteKind::Active => &[" .-. ", "( )", " `-' "],
SpriteKind::Busy => &[" .-. ", "( * )", " `-' "],
SpriteKind::Error => &[" .-. ", "( x )", " `-' "],
SpriteKind::Drowsy => &[" .-. ", "( )", "( )", " `-' "],
SpriteKind::Sleeping => &[" .-. ", "( z )", "( )", " `-' "],
SpriteKind::DeepSleep => {
if slot.on_floor {
&[" .-. ", "( z )", "( )", "(___)"]
} else {
&[" .-. ", "( z )", "( )", " `-' "]
}
}
SpriteKind::Exited => {
if slot.on_floor {
&[" .-. ", "( x )", "( )", "(___)"]
} else {
&[" .-. ", "( x )", "( )", " `-' "]
}
}
};
for (dy, row) in rows.iter().enumerate() {
renderer.draw_text(slot.ball_x, slot.ball_y + dy as u16, row, color);
}
if selected {
renderer.draw_char(
slot.anchor_x,
slot.cord_top_y.saturating_sub(1),
'^',
Color::White,
);
}
}
pub(crate) fn render_balls_theme(
renderer: &mut Renderer,
field: Rect,
entities: &[&SessionEntity],
selected_id: Option<&str>,
repo_themes: &HashMap<String, RepoTheme>,
tick: u64,
) {
if entities.is_empty() {
return;
}
let body = balls_theme_body_rect(entities.len(), field);
let slots = balls_theme_slots(entities, field);
render_balls_theme_body(renderer, body, &slots, tick);
renderer.draw_hline(
field.x,
field.bottom().saturating_sub(1),
field.width,
'_',
Color::DarkGrey,
);
for slot in slots {
let selected = selected_id
.map(|selected_id| selected_id == slot.entity.session.session_id)
.unwrap_or(false);
let color = session_display_color(&slot.entity.session, repo_themes);
render_balls_theme_ball(renderer, slot, selected, color);
}
}
pub(crate) fn render_entity_with_theme(
renderer: &mut Renderer,
entity: &SessionEntity,
rect: Rect,
selected: bool,
tick: u64,
repo_themes: &HashMap<String, RepoTheme>,
theme: SpriteTheme,
) {
let kind = entity.sprite_kind();
let color = session_display_color(&entity.session, repo_themes);
let sprite = kind.frame_with_theme(tick, theme);
for (dy, line) in sprite.iter().enumerate() {
renderer.draw_text(rect.x, rect.y + dy as u16, line, color);
}
if selected {
if rect.x > 0 {
renderer.draw_char(rect.x - 1, rect.y + 1, '>', Color::White);
}
let label = truncate_label(&entity.session.tmux_name, ENTITY_WIDTH as usize);
renderer.draw_text(rect.x, rect.y + SPRITE_HEIGHT, &label, Color::White);
} else {
let label = truncate_label(&entity.session.tmux_name, ENTITY_WIDTH as usize);
renderer.draw_text(rect.x, rect.y + SPRITE_HEIGHT, &label, Color::DarkGrey);
}
}
pub(crate) fn render_footer<C: TuiApi>(app: &App<C>, renderer: &mut Renderer, start_y: u16) {
if start_y >= renderer.height() {
return;
}
if let Some(selected) = app.selected() {
let state_line = format!(
"selected: {} [{}] {}",
selected.session.tmux_name,
session_state_text(&selected.session),
shorten_path(&selected.session.cwd, 42)
);
renderer.draw_text(
2,
start_y,
&truncate_label(&state_line, (renderer.width() - 4) as usize),
Color::White,
);
let cmd = selected
.session
.current_command
.as_deref()
.unwrap_or("idle");
let cmd_line = format!("cmd: {}", shorten_path(cmd, 60));
renderer.draw_text(
2,
start_y + 1,
&truncate_label(&cmd_line, (renderer.width() - 4) as usize),
Color::DarkGrey,
);
} else {
renderer.draw_text(2, start_y, "selected: none", Color::DarkGrey);
}
let help = if app.initial_request.is_some() {
"request: type prompt enter create hidden backspace delete esc cancel"
} else if app.thought_config_editor.is_some() {
"thought config: tab moves arrows adjust enter saves esc cancels"
} else if app.picker.is_some() {
"picker: enter/right select c commit click badge runs action h/backspace up e env a all esc close"
} else {
"click empty field spawn click/enter open arrows or hjkl move n native target m ghostty mode s sprite theme t thought cfg r refresh q quit"
};
renderer.draw_text(
2,
start_y + 2,
&truncate_label(help, (renderer.width() - 4) as usize),
Color::Cyan,
);
if let Some(message) = app.visible_message() {
renderer.draw_text(
2,
start_y + 3,
&truncate_label(message, (renderer.width() - 4) as usize),
Color::Yellow,
);
}
}
pub(crate) fn render_too_small(renderer: &mut Renderer) {
renderer.draw_text(2, 1, "swimmers tui", Color::Cyan);
renderer.draw_text(
2,
3,
&format!(
"terminal too small - need at least {}x{}",
MIN_WIDTH, MIN_HEIGHT
),
Color::Red,
);
renderer.draw_text(
2,
5,
"resize the terminal and reopen the TUI",
Color::DarkGrey,
);
}
pub(crate) fn render_aquarium_background(renderer: &mut Renderer, field: Rect, tick: u64) {
if field.width < 4 || field.height < 4 {
return;
}
let width = usize::from(field.width.max(1));
let scroll = (tick as usize / 3) % width;
let lane_count = usize::from((field.width / 18).clamp(1, 4));
let lane_spacing = (field.width / lane_count as u16).max(1);
let bottom_y = field.bottom().saturating_sub(1);
for lane in 0..lane_count {
let base_offset = (2 + lane as u16 * lane_spacing) as usize;
let x = field
.right()
.saturating_sub(1)
.saturating_sub(((base_offset + scroll) % width) as u16);
let rise = ((tick / 4) as u16 + lane as u16 * 4) % field.height.max(1);
let y = bottom_y.saturating_sub(rise);
renderer.draw_char(x, y, 'o', Color::DarkCyan);
if x + 1 < field.right() && y + 1 < field.bottom() {
renderer.draw_char(x + 1, y + 1, '.', Color::Blue);
}
}
let sparkle_count = usize::from((field.width / 24).clamp(1, 3));
for sparkle in 0..sparkle_count {
let x = field
.right()
.saturating_sub(1)
.saturating_sub((((tick as usize / 2) + sparkle * 11) % width) as u16);
let y_span = field.height.saturating_sub(3).max(1);
let y = field.y + 1 + (((tick / 2) as u16 + sparkle as u16 * 6) % y_span);
renderer.draw_char(x, y, '~', Color::DarkBlue);
if x > field.x {
renderer.draw_char(x - 1, y, '.', Color::DarkBlue);
}
}
}
pub(crate) fn pluralize(count: usize) -> &'static str {
if count == 1 {
""
} else {
"s"
}
}
pub(crate) fn truncate_label(text: &str, max_chars: usize) -> String {
let mut chars = text.chars().collect::<Vec<_>>();
if chars.len() <= max_chars {
return chars.into_iter().collect();
}
if max_chars == 0 {
return String::new();
}
chars.truncate(max_chars.saturating_sub(1));
let mut out: String = chars.into_iter().collect();
out.push('~');
out
}
pub(crate) fn tail_text(text: &str, max_chars: usize) -> String {
let chars = text.chars().collect::<Vec<_>>();
if chars.len() <= max_chars {
return chars.into_iter().collect();
}
if max_chars == 0 {
return String::new();
}
chars[chars.len() - max_chars..].iter().collect()
}
pub(crate) fn shorten_path(path: &str, max_chars: usize) -> String {
if path.chars().count() <= max_chars {
return path.to_string();
}
if path.contains('/') && max_chars > 3 {
let budget = max_chars - 3;
let mut suffix = String::new();
for part in path.split('/').filter(|part| !part.is_empty()).rev() {
let candidate = if suffix.is_empty() {
format!("/{part}")
} else {
format!("/{part}{suffix}")
};
if candidate.chars().count() > budget {
break;
}
suffix = candidate;
}
if !suffix.is_empty() {
return format!("...{suffix}");
}
}
let chars = path.chars().collect::<Vec<_>>();
if max_chars <= 3 {
return chars.into_iter().take(max_chars).collect();
}
let tail = chars
.into_iter()
.rev()
.take(max_chars - 3)
.collect::<Vec<_>>();
let tail = tail.into_iter().rev().collect::<String>();
format!("...{tail}")
}
pub(crate) fn session_state_text(session: &SessionSummary) -> &'static str {
match session.state {
SessionState::Idle | SessionState::Attention => match session.rest_state {
RestState::Active => match session.state {
SessionState::Attention => "attention",
SessionState::Idle => "active",
_ => unreachable!("only idle/attention reach active rest-state branch"),
},
RestState::Drowsy => "drowsy",
RestState::Sleeping => "sleeping",
RestState::DeepSleep => "deep sleep",
},
SessionState::Busy => "busy",
SessionState::Error => "error",
SessionState::Exited => "exited",
}
}
pub(crate) fn selected_label(name: Option<&String>) -> String {
name.cloned().unwrap_or_else(|| "session".to_string())
}