use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use bevy_ecs::system::SystemParam;
use bevy_reflect::prelude::*;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Widget;
use rig_ecs::agent::{ActiveTurn, Agent, TurnOf};
use rig_harness::main_agent::ShownAgent;
use serde::{Deserialize, Serialize};
use unicode_width::UnicodeWidthChar;
use super::editor::Editor;
use super::panel::{PanelCanvas, Placement, TuiPanel};
#[derive(Reflect, Clone, Debug, Default, PartialEq, Eq)]
#[reflect(Clone, Debug, Default, PartialEq)]
pub struct StatusItem {
pub name: &'static str,
pub text: String,
pub tone: Tone,
pub side: Side,
pub order: u8,
pub keep: u8,
pub min_width: u16,
#[reflect(ignore)]
pub styled: Option<Line<'static>>,
}
impl StatusItem {
pub const fn new(name: &'static str, side: Side, order: u8, keep: u8) -> Self {
Self {
name,
text: String::new(),
tone: Tone::Plain,
side,
order,
keep,
min_width: 0,
styled: None,
}
}
pub const fn shrinks_to(mut self, min_width: u16) -> Self {
self.min_width = min_width;
self
}
pub fn says(&self, text: impl Into<String>, tone: Tone) -> Self {
Self {
text: text.into(),
tone,
styled: None,
..self.clone()
}
}
pub fn styled(&self, line: Line<'static>) -> Self {
Self {
text: line.to_string(),
tone: Tone::Plain,
styled: Some(line),
..self.clone()
}
}
}
#[derive(Component, Reflect, Clone, Debug, Default, PartialEq, Eq)]
#[reflect(Component, Clone, Debug, Default, PartialEq)]
pub struct StatusItems(Vec<StatusItem>);
impl StatusItems {
pub fn show(&mut self, item: StatusItem) {
self.hide(item.name);
if !item.text.is_empty() {
self.0.push(item);
}
}
pub fn hide(&mut self, name: &str) {
self.0.retain(|old| old.name != name);
}
pub fn iter(&self) -> impl Iterator<Item = &StatusItem> {
self.0.iter()
}
}
#[derive(Resource, Reflect, Clone, Debug, Default)]
#[reflect(Resource, Clone, Debug, Default)]
pub struct AppStatus(pub StatusItems);
#[derive(Reflect, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Side {
#[default]
Left,
Right,
}
#[derive(Reflect, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Tone {
#[default]
Plain,
Bold,
Dim,
Green,
Yellow,
Red,
Cyan,
Magenta,
}
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StatusSystems {
Show,
Edit,
}
pub(crate) fn add(app: &mut App) {
app.register_required_components::<Agent, StatusItems>()
.register_required_components::<TurnOf, StatusItems>()
.init_resource::<AppStatus>()
.configure_sets(
PostUpdate,
(StatusSystems::Show, StatusSystems::Edit).chain(),
);
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
#[require(TuiPanel = TuiPanel::new(Placement::Bottom(Constraint::Length(1))))]
pub struct StatusLine {
pub agent: Option<Entity>,
}
#[derive(SystemParam)]
pub(crate) struct StatusSources<'w, 's> {
items: Query<'w, 's, &'static StatusItems>,
active: Query<'w, 's, &'static ActiveTurn>,
shown: Res<'w, ShownAgent>,
app: Option<Res<'w, AppStatus>>,
}
impl StatusSources<'_, '_> {
pub(crate) fn line(&self, status: &StatusLine, width: u16) -> (Line<'static>, Line<'static>) {
let agent = status.agent.or(self.shown.0);
let turn = agent.and_then(|agent| self.active.get(agent).ok());
let own = agent.and_then(|agent| self.items.get(agent).ok());
let turn = turn.and_then(|turn| self.items.get(turn.turn()).ok());
let app = self.app.as_deref().map(|app| &app.0);
let no_agent =
StatusItem::new("agent", Side::Left, 30, u8::MAX).says("no agent", Tone::Dim);
let no_agent = agent.is_none().then_some(&no_agent);
let items = [app, own, turn]
.into_iter()
.flatten()
.flat_map(StatusItems::iter);
self::line(items.chain(no_agent), width)
}
}
pub(crate) fn draw_status_lines(
mut lines: Query<(&StatusLine, &mut PanelCanvas), Without<Editor>>,
sources: StatusSources,
) {
for (line, mut canvas) in &mut lines {
let area = canvas.area();
if area.is_empty() {
continue;
}
let (left, right) = sources.line(line, area.width);
let right_width = u16::try_from(right.width()).unwrap_or(u16::MAX);
let [left_area, right_area] =
Layout::horizontal([Constraint::Min(0), Constraint::Length(right_width)]).areas(area);
let buffer = canvas.buffer_mut();
left.render(left_area, buffer);
right.render(right_area, buffer);
}
}
struct Piece {
keep: u8,
min: usize,
line: Line<'static>,
}
impl Piece {
fn new(keep: u8, line: impl Into<Line<'static>>) -> Self {
let line = line.into();
Self { keep, min: 0, line }
}
fn shrinking_to(mut self, min: usize) -> Self {
self.min = min;
self
}
fn width(&self) -> usize {
self.line.width()
}
fn can_shrink(&self) -> bool {
self.min > 0 && self.width() > self.min
}
fn shrink(&mut self, over: usize) {
let target = self.width().saturating_sub(over).max(self.min);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut width = 0;
let mut cut = Style::new();
'runs: for span in &self.line.spans {
let mut text = String::new();
cut = span.style;
for character in span.content.chars() {
let next = width + character.width().unwrap_or(0);
if next + 1 > target {
spans.push(Span::styled(text, span.style));
break 'runs;
}
text.push(character);
width = next;
}
spans.push(Span::styled(text, span.style));
}
spans.push(Span::styled("…", cut));
self.line.spans = spans;
}
}
const LEFT_GAP: &str = " ";
const RIGHT_GAP: &str = " ";
fn joined_width(pieces: &[Piece], gap: &str) -> usize {
let gaps = pieces.len().saturating_sub(1) * gap.len();
pieces.iter().map(Piece::width).sum::<usize>() + gaps
}
fn fit(left: &mut Vec<Piece>, right: &mut Vec<Piece>, width: usize) {
loop {
let separator = if right.is_empty() { 0 } else { LEFT_GAP.len() };
let used = joined_width(left, LEFT_GAP) + separator + joined_width(right, RIGHT_GAP);
let Some(over) = used.checked_sub(width).filter(|over| *over > 0) else {
return;
};
let lowest = |pieces: &[Piece]| {
pieces
.iter()
.enumerate()
.filter(|(_, piece)| piece.keep < u8::MAX || piece.can_shrink())
.min_by_key(|(_, piece)| piece.keep)
.map(|(index, piece)| (piece.keep, index))
};
let (side, index) = match (lowest(right.as_slice()), lowest(left.as_slice())) {
(Some((on_right, index)), on_left)
if on_left.is_none_or(|(on_left, _)| on_right <= on_left) =>
{
(&mut *right, index)
}
(_, Some((_, index))) => (&mut *left, index),
(_, None) => return,
};
match side.get_mut(index) {
Some(piece) if piece.can_shrink() => piece.shrink(over),
_ => {
side.remove(index);
}
}
}
}
fn join(pieces: Vec<Piece>, gap: &'static str) -> Line<'static> {
let mut spans = Vec::with_capacity(pieces.len() * 2);
for (index, piece) in pieces.into_iter().enumerate() {
if index > 0 {
spans.push(Span::from(gap));
}
spans.extend(piece.line.spans);
}
Line::from(spans)
}
pub fn line<'a>(
items: impl IntoIterator<Item = &'a StatusItem>,
width: u16,
) -> (Line<'static>, Line<'static>) {
let (mut left, mut right) = pieces(items);
fit(&mut left, &mut right, usize::from(width));
(join(left, LEFT_GAP), join(right, RIGHT_GAP))
}
fn pieces<'a>(items: impl IntoIterator<Item = &'a StatusItem>) -> (Vec<Piece>, Vec<Piece>) {
let mut items: Vec<&StatusItem> = items.into_iter().collect();
items.sort_by_key(|item| (item.order, item.name));
let piece = |item: &StatusItem| {
let style = match item.tone {
Tone::Plain => Style::new(),
Tone::Bold => Style::new().bold(),
Tone::Dim => Style::new().dim(),
Tone::Green => Style::new().green(),
Tone::Yellow => Style::new().yellow(),
Tone::Red => Style::new().red(),
Tone::Cyan => Style::new().cyan(),
Tone::Magenta => Style::new().magenta(),
};
let line = match &item.styled {
Some(line) => line.clone(),
None => Line::from(Span::styled(item.text.clone(), style)),
};
Piece::new(item.keep, line).shrinking_to(usize::from(item.min_width))
};
let (left, right): (Vec<_>, Vec<_>) =
items.into_iter().partition(|item| item.side == Side::Left);
(
left.into_iter().map(piece).collect(),
right.into_iter().map(piece).collect(),
)
}
#[cfg(test)]
mod tests;