1use bevy_app::prelude::*;
31use bevy_ecs::prelude::*;
32use bevy_ecs::system::SystemParam;
33use bevy_reflect::prelude::*;
34
35use ratatui::layout::{Constraint, Layout};
36use ratatui::style::Style;
37use ratatui::text::{Line, Span};
38use ratatui::widgets::Widget;
39use rig_ecs::agent::{ActiveTurn, Agent, TurnOf};
40use rig_harness::main_agent::ShownAgent;
41use serde::{Deserialize, Serialize};
42use unicode_width::UnicodeWidthChar;
43
44use super::editor::Editor;
45use super::panel::{PanelCanvas, Placement, TuiPanel};
46
47#[derive(Reflect, Clone, Debug, Default, PartialEq, Eq)]
50#[reflect(Clone, Debug, Default, PartialEq)]
51pub struct StatusItem {
52 pub name: &'static str,
55 pub text: String,
57 pub tone: Tone,
59 pub side: Side,
61 pub order: u8,
63 pub keep: u8,
66 pub min_width: u16,
69 #[reflect(ignore)]
72 pub styled: Option<Line<'static>>,
73}
74
75impl StatusItem {
76 pub const fn new(name: &'static str, side: Side, order: u8, keep: u8) -> Self {
79 Self {
80 name,
81 text: String::new(),
82 tone: Tone::Plain,
83 side,
84 order,
85 keep,
86 min_width: 0,
87 styled: None,
88 }
89 }
90
91 pub const fn shrinks_to(mut self, min_width: u16) -> Self {
94 self.min_width = min_width;
95 self
96 }
97
98 pub fn says(&self, text: impl Into<String>, tone: Tone) -> Self {
100 Self {
101 text: text.into(),
102 tone,
103 styled: None,
104 ..self.clone()
105 }
106 }
107
108 pub fn styled(&self, line: Line<'static>) -> Self {
121 Self {
122 text: line.to_string(),
123 tone: Tone::Plain,
124 styled: Some(line),
125 ..self.clone()
126 }
127 }
128}
129
130#[derive(Component, Reflect, Clone, Debug, Default, PartialEq, Eq)]
135#[reflect(Component, Clone, Debug, Default, PartialEq)]
136pub struct StatusItems(Vec<StatusItem>);
137
138impl StatusItems {
139 pub fn show(&mut self, item: StatusItem) {
142 self.hide(item.name);
143 if !item.text.is_empty() {
144 self.0.push(item);
145 }
146 }
147
148 pub fn hide(&mut self, name: &str) {
152 self.0.retain(|old| old.name != name);
153 }
154
155 pub fn iter(&self) -> impl Iterator<Item = &StatusItem> {
157 self.0.iter()
158 }
159}
160
161#[derive(Resource, Reflect, Clone, Debug, Default)]
164#[reflect(Resource, Clone, Debug, Default)]
165pub struct AppStatus(pub StatusItems);
166
167#[derive(Reflect, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "lowercase")]
171pub enum Side {
172 #[default]
174 Left,
175 Right,
177}
178
179#[derive(Reflect, Clone, Copy, Debug, Default, PartialEq, Eq)]
181pub enum Tone {
182 #[default]
184 Plain,
185 Bold,
187 Dim,
189 Green,
191 Yellow,
193 Red,
195 Cyan,
197 Magenta,
199}
200
201#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
204pub enum StatusSystems {
205 Show,
207 Edit,
209}
210
211pub(crate) fn add(app: &mut App) {
214 app.register_required_components::<Agent, StatusItems>()
215 .register_required_components::<TurnOf, StatusItems>()
216 .init_resource::<AppStatus>()
217 .configure_sets(
218 PostUpdate,
219 (StatusSystems::Show, StatusSystems::Edit).chain(),
220 );
221}
222
223#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
230#[require(TuiPanel = TuiPanel::new(Placement::Bottom(Constraint::Length(1))))]
231pub struct StatusLine {
232 pub agent: Option<Entity>,
234}
235
236#[derive(SystemParam)]
239pub(crate) struct StatusSources<'w, 's> {
240 items: Query<'w, 's, &'static StatusItems>,
241 active: Query<'w, 's, &'static ActiveTurn>,
242 shown: Res<'w, ShownAgent>,
243 app: Option<Res<'w, AppStatus>>,
244}
245
246impl StatusSources<'_, '_> {
247 pub(crate) fn line(&self, status: &StatusLine, width: u16) -> (Line<'static>, Line<'static>) {
249 let agent = status.agent.or(self.shown.0);
250 let turn = agent.and_then(|agent| self.active.get(agent).ok());
251 let own = agent.and_then(|agent| self.items.get(agent).ok());
252 let turn = turn.and_then(|turn| self.items.get(turn.turn()).ok());
253 let app = self.app.as_deref().map(|app| &app.0);
254 let no_agent =
255 StatusItem::new("agent", Side::Left, 30, u8::MAX).says("no agent", Tone::Dim);
256 let no_agent = agent.is_none().then_some(&no_agent);
257 let items = [app, own, turn]
258 .into_iter()
259 .flatten()
260 .flat_map(StatusItems::iter);
261 self::line(items.chain(no_agent), width)
262 }
263}
264
265pub(crate) fn draw_status_lines(
268 mut lines: Query<(&StatusLine, &mut PanelCanvas), Without<Editor>>,
269 sources: StatusSources,
270) {
271 for (line, mut canvas) in &mut lines {
272 let area = canvas.area();
273 if area.is_empty() {
274 continue;
275 }
276 let (left, right) = sources.line(line, area.width);
277 let right_width = u16::try_from(right.width()).unwrap_or(u16::MAX);
278 let [left_area, right_area] =
279 Layout::horizontal([Constraint::Min(0), Constraint::Length(right_width)]).areas(area);
280 let buffer = canvas.buffer_mut();
281 left.render(left_area, buffer);
282 right.render(right_area, buffer);
283 }
284}
285
286struct Piece {
289 keep: u8,
290 min: usize,
292 line: Line<'static>,
293}
294
295impl Piece {
296 fn new(keep: u8, line: impl Into<Line<'static>>) -> Self {
297 let line = line.into();
298 Self { keep, min: 0, line }
299 }
300
301 fn shrinking_to(mut self, min: usize) -> Self {
304 self.min = min;
305 self
306 }
307
308 fn width(&self) -> usize {
309 self.line.width()
310 }
311
312 fn can_shrink(&self) -> bool {
314 self.min > 0 && self.width() > self.min
315 }
316
317 fn shrink(&mut self, over: usize) {
320 let target = self.width().saturating_sub(over).max(self.min);
321 let mut spans: Vec<Span<'static>> = Vec::new();
322 let mut width = 0;
323 let mut cut = Style::new();
324 'runs: for span in &self.line.spans {
325 let mut text = String::new();
326 cut = span.style;
327 for character in span.content.chars() {
328 let next = width + character.width().unwrap_or(0);
329 if next + 1 > target {
330 spans.push(Span::styled(text, span.style));
331 break 'runs;
332 }
333 text.push(character);
334 width = next;
335 }
336 spans.push(Span::styled(text, span.style));
337 }
338 spans.push(Span::styled("…", cut));
339 self.line.spans = spans;
340 }
341}
342
343const LEFT_GAP: &str = " ";
345const RIGHT_GAP: &str = " ";
347
348fn joined_width(pieces: &[Piece], gap: &str) -> usize {
350 let gaps = pieces.len().saturating_sub(1) * gap.len();
351 pieces.iter().map(Piece::width).sum::<usize>() + gaps
352}
353
354fn fit(left: &mut Vec<Piece>, right: &mut Vec<Piece>, width: usize) {
360 loop {
361 let separator = if right.is_empty() { 0 } else { LEFT_GAP.len() };
362 let used = joined_width(left, LEFT_GAP) + separator + joined_width(right, RIGHT_GAP);
363 let Some(over) = used.checked_sub(width).filter(|over| *over > 0) else {
364 return;
365 };
366 let lowest = |pieces: &[Piece]| {
367 pieces
368 .iter()
369 .enumerate()
370 .filter(|(_, piece)| piece.keep < u8::MAX || piece.can_shrink())
371 .min_by_key(|(_, piece)| piece.keep)
372 .map(|(index, piece)| (piece.keep, index))
373 };
374 let (side, index) = match (lowest(right.as_slice()), lowest(left.as_slice())) {
375 (Some((on_right, index)), on_left)
376 if on_left.is_none_or(|(on_left, _)| on_right <= on_left) =>
377 {
378 (&mut *right, index)
379 }
380 (_, Some((_, index))) => (&mut *left, index),
381 (_, None) => return,
382 };
383 match side.get_mut(index) {
384 Some(piece) if piece.can_shrink() => piece.shrink(over),
385 _ => {
386 side.remove(index);
387 }
388 }
389 }
390}
391
392fn join(pieces: Vec<Piece>, gap: &'static str) -> Line<'static> {
394 let mut spans = Vec::with_capacity(pieces.len() * 2);
395 for (index, piece) in pieces.into_iter().enumerate() {
396 if index > 0 {
397 spans.push(Span::from(gap));
398 }
399 spans.extend(piece.line.spans);
400 }
401 Line::from(spans)
402}
403
404pub fn line<'a>(
409 items: impl IntoIterator<Item = &'a StatusItem>,
410 width: u16,
411) -> (Line<'static>, Line<'static>) {
412 let (mut left, mut right) = pieces(items);
413 fit(&mut left, &mut right, usize::from(width));
414 (join(left, LEFT_GAP), join(right, RIGHT_GAP))
415}
416
417fn pieces<'a>(items: impl IntoIterator<Item = &'a StatusItem>) -> (Vec<Piece>, Vec<Piece>) {
420 let mut items: Vec<&StatusItem> = items.into_iter().collect();
421 items.sort_by_key(|item| (item.order, item.name));
422 let piece = |item: &StatusItem| {
423 let style = match item.tone {
424 Tone::Plain => Style::new(),
425 Tone::Bold => Style::new().bold(),
426 Tone::Dim => Style::new().dim(),
427 Tone::Green => Style::new().green(),
428 Tone::Yellow => Style::new().yellow(),
429 Tone::Red => Style::new().red(),
430 Tone::Cyan => Style::new().cyan(),
431 Tone::Magenta => Style::new().magenta(),
432 };
433 let line = match &item.styled {
434 Some(line) => line.clone(),
435 None => Line::from(Span::styled(item.text.clone(), style)),
436 };
437 Piece::new(item.keep, line).shrinking_to(usize::from(item.min_width))
438 };
439 let (left, right): (Vec<_>, Vec<_>) =
440 items.into_iter().partition(|item| item.side == Side::Left);
441 (
442 left.into_iter().map(piece).collect(),
443 right.into_iter().map(piece).collect(),
444 )
445}
446
447#[cfg(test)]
448mod tests;