use std::io::{stdout, Stdout};
use std::time::SystemTime;
use anyhow::Result;
use ascii_agents_core::sprite::format::Pack;
use ascii_agents_core::sprite::RgbBuffer;
use ascii_agents_core::SceneState;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::{Backend, CrosstermBackend};
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::Terminal;
use crate::tui::frame_cache::FrameCache;
use crate::tui::layout::{Layout, Point};
use crate::tui::pathfind::Router;
use crate::tui::pixel_painter::render_to_rgb_buffer;
use crate::tui::pose;
pub(crate) use crate::tui::hit_test::hit_test_agent;
pub use crate::tui::hit_test::{
hit_test_cat, hit_test_coffee_machine, hit_test_from_tui, hit_test_furniture,
};
pub(crate) use crate::tui::widgets::paint_hover_tooltip;
pub use crate::tui::widgets::TickerQueue;
pub(super) use crate::tui::widgets::{
paint_cat_tooltip, paint_chitchat_bubbles, paint_coffee_tooltip, paint_elevator_indicator,
paint_footer, paint_furniture_tooltip, paint_label_widgets, paint_theme_picker,
paint_wall_display,
};
pub const PET_DURATION_MS: u64 = 2000;
pub struct CatPetState {
pub petted_at: SystemTime,
pub pet_pos: Point,
}
impl CatPetState {
pub fn is_active(&self, now: SystemTime) -> bool {
now.duration_since(self.petted_at)
.map(|d| d.as_millis() as u64)
.unwrap_or(PET_DURATION_MS + 1)
< PET_DURATION_MS
}
pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
now.duration_since(self.petted_at)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
}
pub struct DrawCtx<'a> {
pub buf: &'a mut RgbBuffer,
pub cache: &'a mut FrameCache,
pub router: &'a mut dyn Router,
pub overlay: &'a mut ascii_agents_core::walkable::OccupancyOverlay,
pub history: &'a mut pose::PoseHistory,
pub mouse_pos: Option<(u16, u16)>,
pub pinned_agent: Option<ascii_agents_core::AgentId>,
pub ticker: &'a TickerQueue,
pub theme: &'a crate::tui::theme::Theme,
pub theme_picker: Option<usize>,
pub floor_info: Option<(usize, usize)>,
pub floor: crate::tui::floor::FloorMeta,
pub cat_pet: Option<&'a CatPetState>,
pub last_cat_pos: Option<(Point, &'static str)>,
pub chitchat_state:
&'a mut std::collections::HashMap<(usize, usize), crate::tui::chitchat::ActiveChitchat>,
pub chitchat_bubbles: Vec<crate::tui::chitchat::ChitchatBubble>,
pub coffee_holders: &'a std::collections::HashSet<ascii_agents_core::AgentId>,
pub coffee_fetched_at:
&'a std::collections::HashMap<ascii_agents_core::AgentId, std::time::SystemTime>,
pub new_coffee_carriers: Vec<ascii_agents_core::AgentId>,
}
pub(crate) fn clip_widget_rect(rect: Rect, bounds: Rect) -> Option<Rect> {
if rect.x >= bounds.x + bounds.width || rect.y >= bounds.y + bounds.height {
return None;
}
if rect.x + rect.width <= bounds.x || rect.y + rect.height <= bounds.y {
return None;
}
let x = rect.x.max(bounds.x);
let y = rect.y.max(bounds.y);
let right = (rect.x + rect.width).min(bounds.x + bounds.width);
let bot = (rect.y + rect.height).min(bounds.y + bounds.height);
if right <= x || bot <= y {
return None;
}
Some(Rect {
x,
y,
width: right - x,
height: bot - y,
})
}
pub type Term = Terminal<CrosstermBackend<Stdout>>;
pub fn setup_terminal() -> Result<Term> {
enable_raw_mode()?;
let mut out = stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
Ok(Terminal::new(CrosstermBackend::new(out))?)
}
pub fn teardown_terminal(term: &mut Term) -> Result<()> {
disable_raw_mode()?;
execute!(
term.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
)?;
term.show_cursor()?;
Ok(())
}
pub fn draw_scene<B: Backend>(
term: &mut Terminal<B>,
scene: &SceneState,
pack: &Pack,
now: SystemTime,
ctx: &mut DrawCtx<'_>,
) -> Result<Option<Layout>> {
let term_size = term.size()?;
let full_rect = Rect {
x: 0,
y: 0,
width: term_size.width,
height: term_size.height,
};
let scene_rect = Rect {
x: 0,
y: 0,
width: full_rect.width,
height: full_rect.height.saturating_sub(1),
};
let theme = ctx.theme;
let floor_info = ctx.floor_info;
let floor = ctx.floor;
if scene_rect.width < 20 || scene_rect.height < 12 {
term.draw(|f| paint_footer(f, scene, full_rect, theme, floor_info))?;
return Ok(None);
}
let buf_w = scene_rect.width;
let buf_h = scene_rect.height * 2;
ctx.buf.ensure_size(buf_w, buf_h, theme.surface.bg_fallback);
use crate::tui::layout::MAX_VISIBLE_DESKS;
let Some(layout) = Layout::compute_with_seed(buf_w, buf_h, MAX_VISIBLE_DESKS, floor.floor_seed)
else {
term.draw(|f| paint_footer(f, scene, full_rect, theme, floor_info))?;
return Ok(None);
};
ctx.router.set_preferred_zone(layout.corridor);
let pixel_result = render_to_rgb_buffer(
scene,
&layout,
pack,
now,
ctx.buf,
ctx.cache,
ctx.router,
ctx.overlay,
ctx.history,
theme,
floor,
ctx.cat_pet,
ctx.chitchat_state,
ctx.coffee_holders,
ctx.coffee_fetched_at,
);
ctx.last_cat_pos = pixel_result.cat_pos;
ctx.chitchat_bubbles = pixel_result.chitchat_bubbles;
ctx.new_coffee_carriers = pixel_result.new_coffee_carriers;
let mouse_pos = ctx.mouse_pos;
let pinned_agent = ctx.pinned_agent;
let hovered = mouse_pos.and_then(|(mx, my)| {
hit_test_agent(
scene,
&layout,
now,
ctx.router,
ctx.overlay,
ctx.history,
mx,
my,
)
});
let buf = &ctx.buf;
let ticker = ctx.ticker;
let theme_picker = ctx.theme_picker;
let chitchat_bubbles = &ctx.chitchat_bubbles;
term.draw(|f| {
paint_footer(f, scene, full_rect, theme, floor_info);
flush_buffer_to_term(f, buf, scene_rect);
paint_label_widgets(
f,
scene,
&layout,
now,
ctx.router,
ctx.overlay,
ctx.history,
scene_rect,
hovered,
theme,
);
paint_chitchat_bubbles(f, chitchat_bubbles, scene_rect, theme);
paint_wall_display(f, scene, scene_rect, now, ticker, theme, floor_info);
if let Some(door) = layout.door {
let (current, _) = floor_info.unwrap_or((1, 1));
paint_elevator_indicator(f, door, current, scene_rect, theme);
}
let tooltip_agent = hovered.or(pinned_agent);
if let (Some(agent_id), Some((mx, my))) = (tooltip_agent, mouse_pos) {
paint_hover_tooltip(f, scene, agent_id, mx, my, scene_rect, now, theme);
} else if let Some(agent_id) = pinned_agent {
paint_hover_tooltip(
f,
scene,
agent_id,
scene_rect.width / 2,
scene_rect.height / 2,
scene_rect,
now,
theme,
);
}
if tooltip_agent.is_none() && pinned_agent.is_none() {
if let Some((mx, my)) = mouse_pos {
if hit_test_coffee_machine(&layout, mx, my) {
paint_coffee_tooltip(f, mx, my, scene_rect, theme);
} else if let Some((cat_pos, anim)) = ctx.last_cat_pos {
if hit_test_cat(cat_pos, anim, mx, my) {
let on_cooldown = ctx.cat_pet.is_some_and(|p| p.is_active(now));
paint_cat_tooltip(f, anim, on_cooldown, mx, my, scene_rect, theme);
} else if let Some(label) = hit_test_furniture(&layout, mx, my) {
paint_furniture_tooltip(f, label, mx, my, scene_rect, theme);
}
} else if let Some(label) = hit_test_furniture(&layout, mx, my) {
paint_furniture_tooltip(f, label, mx, my, scene_rect, theme);
}
}
}
if let Some(idx) = theme_picker {
paint_theme_picker(f, idx, full_rect, theme);
}
})?;
Ok(Some(layout))
}
pub(super) fn flush_buffer_to_term_at_offset(
f: &mut ratatui::Frame<'_>,
buf: &RgbBuffer,
scene_rect: Rect,
y_offset: i32,
) {
let term_buf = f.buffer_mut();
let w = buf.width as usize;
let cell_rows = (buf.height / 2) as usize;
for cy in 0..cell_rows {
let target_y = cy as i32 + y_offset;
if target_y < 0 || target_y >= scene_rect.height as i32 {
continue;
}
for cx in 0..(buf.width as usize) {
let x = scene_rect.x + cx as u16;
let y = scene_rect.y + target_y as u16;
if x >= scene_rect.x + scene_rect.width {
continue;
}
let py_top = cy * 2;
let py_bot = cy * 2 + 1;
let fg = buf.pixels[py_top * w + cx];
let bg = buf.pixels[py_bot * w + cx];
let cell = &mut term_buf[(x, y)];
cell.set_symbol("\u{2580}");
cell.fg = Color::Rgb(fg.0, fg.1, fg.2);
cell.bg = Color::Rgb(bg.0, bg.1, bg.2);
}
}
}
fn flush_buffer_to_term(f: &mut ratatui::Frame<'_>, buf: &RgbBuffer, scene_rect: Rect) {
let term_buf = f.buffer_mut();
let w = buf.width as usize;
let cell_rows = (buf.height / 2) as usize;
for cy in 0..cell_rows {
for cx in 0..(buf.width as usize) {
let x = scene_rect.x + cx as u16;
let y = scene_rect.y + cy as u16;
if x >= scene_rect.x + scene_rect.width || y >= scene_rect.y + scene_rect.height {
continue;
}
let py_top = cy * 2;
let py_bot = cy * 2 + 1;
let fg = buf.pixels[py_top * w + cx];
let bg = buf.pixels[py_bot * w + cx];
let cell = &mut term_buf[(x, y)];
cell.set_symbol("\u{2580}");
cell.fg = Color::Rgb(fg.0, fg.1, fg.2);
cell.bg = Color::Rgb(bg.0, bg.1, bg.2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clip_widget_rect_fully_inside() {
let r = Rect {
x: 2,
y: 2,
width: 4,
height: 4,
};
let b = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
assert_eq!(clip_widget_rect(r, b), Some(r));
}
#[test]
fn clip_widget_rect_fully_outside_right() {
let r = Rect {
x: 80,
y: 0,
width: 10,
height: 5,
};
let b = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
assert_eq!(clip_widget_rect(r, b), None);
}
#[test]
fn clip_widget_rect_partially_overflows_right() {
let r = Rect {
x: 75,
y: 0,
width: 10,
height: 5,
};
let b = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let clipped = clip_widget_rect(r, b).unwrap();
assert_eq!(clipped.x, 75);
assert_eq!(clipped.width, 5);
}
#[test]
fn clip_widget_rect_zero_size_returns_none() {
let r = Rect {
x: 0,
y: 0,
width: 0,
height: 5,
};
let b = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
assert_eq!(clip_widget_rect(r, b), None);
}
}