rig_tui/panel.rs
1//! What a plugin draws in the terminal view: a [`TuiPanel`] entity takes a
2//! side of the transcript or a box over the screen, and an ordinary system
3//! of the plugin draws it into the entity's [`PanelCanvas`] in
4//! [`TuiSystems::Draw`], with whatever queries and resources it needs. The
5//! transcript is laid out in what the panels leave.
6//!
7//! A frame is drawn only when something drawn changed. The view watches the
8//! agents and the panels' [`TuiPanel`] components; a panel whose own state
9//! changed asks with a [`RequestRedraw`] message.
10//!
11//! ```no_run
12//! use rig_harness::prelude::*;
13//! use rig_tui::ratatui::layout::Constraint;
14//! use rig_tui::ratatui::widgets::Paragraph;
15//! use rig_tui::{PanelCanvas, Placement, Theme, TuiPanel, TuiSystems};
16//!
17//! #[derive(Component)]
18//! struct Working;
19//!
20//! fn spawn(mut commands: Commands) {
21//! commands.spawn((Working, TuiPanel::new(Placement::Right(Constraint::Length(30)))));
22//! }
23//!
24//! fn draw(
25//! mut panels: Query<&mut PanelCanvas, With<Working>>,
26//! turns: Query<&TurnOf>,
27//! theme: Res<Theme>,
28//! ) {
29//! let busy = turns.iter().count();
30//! for mut canvas in &mut panels {
31//! canvas.render(Paragraph::new(format!("{busy} working")).block(theme.panel("turns")));
32//! }
33//! }
34//!
35//! App::new().add_systems(Startup, spawn).add_systems(PostUpdate, draw.in_set(TuiSystems::Draw));
36//! ```
37
38use std::cmp::Reverse;
39
40use bevy_ecs::change_detection::Tick;
41use bevy_ecs::prelude::*;
42use bevy_ecs::query::SpawnDetails;
43use bevy_reflect::prelude::*;
44use ratatui::buffer::Buffer;
45use ratatui::layout::{Constraint, Layout, Rect};
46use ratatui::widgets::Widget;
47
48/// The terminal view's steps in `PostUpdate`, in order, after the
49/// [`StatusSystems`](crate::StatusSystems).
50#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum TuiSystems {
52 /// Before the frame is laid out: a plugin updates what it shows and
53 /// its [`TuiPanel`]s here, such as a placement that depends on the
54 /// [`TerminalSize`].
55 Prepare,
56 /// The view decides whether to draw a frame and lays it out: first
57 /// every panel by its placement (the input, the status line, the
58 /// plugins' panels), then each [`TranscriptPane`](crate::TranscriptPane)
59 /// in what its panel got, its rows laid out and its scroll placed
60 /// ([`TranscriptView::top`](crate::TranscriptView::top),
61 /// [`DrawnTranscript`](crate::DrawnTranscript)).
62 Layout,
63 /// After the frame is laid out and every pane's scroll placed: a plugin
64 /// fits its [`TuiPanel`]s to what the panes show here, such as a header
65 /// shown while a pane is scrolled up. When a panel was spawned or
66 /// changed here, the view lays the frame out again, panels then panes,
67 /// before [`Draw`](Self::Draw), so the frame that placed the scroll
68 /// draws the panel fitted to it.
69 Fit,
70 /// Plugins draw their panels into their [`PanelCanvas`]. Runs only for
71 /// a frame that is drawn.
72 Draw,
73 /// The view composites the panels' canvases (the transcript, status
74 /// line and input are panels too) and draws its overlays over them:
75 /// the picker or question, the completion list and the caret.
76 Render,
77}
78
79/// Asks the terminal view to draw a frame, for state of a plugin it does
80/// not watch, such as an animation's step.
81#[derive(Message, Clone, Copy, Debug, Default)]
82pub struct RequestRedraw;
83
84/// Asks the terminal view to write every cell of the area again after its
85/// next frame, changed or not, and to draw that frame: for a plugin that
86/// drew over cells itself (an image in the cells), so they show the view's
87/// text again once it is gone. The area is clipped to the screen; the
88/// cells go out before that frame's
89/// [`TerminalWrite`](crate::TerminalWrite)s.
90#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
91pub struct RedrawArea(pub Rect);
92
93/// The size of the terminal, from its start and each resize.
94#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
95pub struct TerminalSize(pub Rect);
96
97/// A panel in the terminal view: the default screen's transcript, input
98/// and status line are panels too, spawned first. Panels are laid out one
99/// after another, in the order spawned, each taking its side of what is left,
100/// after the rows under the input ([`Placement::BelowInput`]); a panel
101/// that would leave less than 20 columns or 3 rows gets no area this frame.
102/// Despawn the entity, or remove the component, to remove the panel.
103#[derive(Component, Reflect, Clone, Debug, PartialEq, Eq)]
104#[require(PanelCanvas)]
105#[reflect(opaque, Component, Clone, Debug, PartialEq)]
106pub struct TuiPanel {
107 /// Where it goes.
108 pub placement: Placement,
109}
110
111impl TuiPanel {
112 /// A panel at `placement`.
113 pub fn new(placement: Placement) -> Self {
114 Self { placement }
115 }
116}
117
118/// Where a [`TuiPanel`] goes, with the size it asks for.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum Placement {
121 /// Rows above what is left: above the transcript.
122 Top(Constraint),
123 /// Rows under what is left: under the transcript, above the status
124 /// line, which the default screen spawns first.
125 Bottom(Constraint),
126 /// Columns left of the transcript.
127 Left(Constraint),
128 /// Columns right of the transcript.
129 Right(Constraint),
130 /// Rows at the bottom of the screen, under the input box, such as a row
131 /// of suggestions; several stack in the order spawned.
132 BelowInput(Constraint),
133 /// A box centred over the whole screen, drawn over everything but the
134 /// view's own overlays (the picker and the completion list).
135 Over {
136 /// Its width.
137 width: Constraint,
138 /// Its height.
139 height: Constraint,
140 },
141 /// What the other panels leave, such as the default screen's
142 /// transcript; several such panels share it.
143 Rest,
144 /// Column `part` (from 0) of `of` equal columns of another panel, or
145 /// of the picker shown, such as a preview beside a picker's list or
146 /// two transcripts compared side by side; drawn over it.
147 Within {
148 /// The panel, or the picker's entity.
149 panel: Entity,
150 /// Which column.
151 part: u16,
152 /// How many columns.
153 of: u16,
154 },
155}
156
157/// Where a [`TuiPanel`] is drawn this frame and what was drawn there. The
158/// view sets the area and clears the buffer before [`TuiSystems::Draw`].
159#[derive(Component, Clone, Debug, Default)]
160pub struct PanelCanvas {
161 buffer: Buffer,
162}
163
164impl PanelCanvas {
165 /// The panel's area on the screen; empty when it has no room.
166 pub fn area(&self) -> Rect {
167 self.buffer.area
168 }
169
170 /// Draws `widget` over the whole area.
171 pub fn render(&mut self, widget: impl Widget) {
172 let area = self.buffer.area;
173 widget.render(area, &mut self.buffer);
174 }
175
176 /// The buffer, for drawing parts of the area; its area is [`Self::area`].
177 pub fn buffer_mut(&mut self) -> &mut Buffer {
178 &mut self.buffer
179 }
180
181 /// Starts the frame's drawing at `area`.
182 pub(super) fn reset(&mut self, area: Rect) {
183 if self.buffer.area == area {
184 self.buffer.reset();
185 } else {
186 self.buffer = Buffer::empty(area);
187 }
188 }
189
190 /// Copies what was drawn onto `target`, within its area.
191 pub(super) fn copy_to(&self, target: &mut Buffer) {
192 for position in self.buffer.area.intersection(target.area).positions() {
193 if let (Some(cell), Some(slot)) =
194 (self.buffer.cell(position), target.cell_mut(position))
195 {
196 slot.clone_from(cell);
197 }
198 }
199 }
200}
201
202/// The spawn ticks of the panels, for [`spawn_order`].
203pub(super) type PanelSpawns<'w, 's> = Query<'w, 's, SpawnDetails, With<TuiPanel>>;
204
205/// Where `panel` comes in the order panels are laid out and drawn: the
206/// order they were spawned, the default screen's first. Bevy hands a
207/// despawned entity's index out again, so the spawn tick orders them, and
208/// the index only those spawned at one tick (`Entity`'s own order is not
209/// the index's).
210pub(super) fn spawn_order(now: Tick, spawns: &PanelSpawns, panel: Entity) -> (Reverse<u32>, u32) {
211 let spawned = spawns.get(panel).map(SpawnDetails::spawn_tick);
212 let age = spawned.map_or(0, |spawned| now.get().wrapping_sub(spawned.get()));
213 (Reverse(age), panel.index_u32())
214}
215
216/// The smallest transcript a panel may leave.
217pub(super) const MIN_TRANSCRIPT: (u16, u16) = (20, 3);
218
219/// The rows a panel below the input asks for on a screen `height` rows
220/// high.
221pub(super) fn rows_of(size: Constraint, height: u16) -> u16 {
222 let [rows, _] = Layout::vertical([size, Constraint::Fill(1)]).areas(Rect::new(0, 0, 1, height));
223 rows.height
224}
225
226/// The rows each of the panels below the input that asked for `asked`
227/// rows gets, in order, out of `room`: a panel that does not fit whole is
228/// skipped.
229pub(super) fn below_rows(asked: impl IntoIterator<Item = u16>, mut room: u16) -> Vec<u16> {
230 asked
231 .into_iter()
232 .map(|rows| match room.checked_sub(rows) {
233 Some(left) => {
234 room = left;
235 rows
236 }
237 None => 0,
238 })
239 .collect()
240}
241
242/// A panel to lay out: its entity, where it goes, how many rows it fits its
243/// content in when it does (an editor, a strip) in place of its
244/// placement's size, and its canvas.
245pub(super) type ToLayOut<'a> = (Entity, &'a TuiPanel, Option<u16>, Mut<'a, PanelCanvas>);
246
247/// Lays the panels out on `screen`, in the order given, in these steps:
248/// 1. those below the input, stacked at the bottom while what they leave
249/// keeps the rows the others fit their content in and [`MIN_TRANSCRIPT`];
250/// 2. each side panel takes its side of what is left (one that fits its
251/// content as many rows as that takes);
252/// 3. an `Over` box goes over the screen;
253/// 4. those that take the `Rest` share what is left;
254/// 5. those `Within` another take their column of it, or of a box of
255/// `boxes` (the picker shown).
256pub(super) fn lay_out<'a>(
257 panels: impl Iterator<Item = ToLayOut<'a>>,
258 screen: Rect,
259 boxes: &[(Entity, Rect)],
260) {
261 let panels: Vec<ToLayOut<'a>> = panels.collect();
262 let fitted: u16 = panels
263 .iter()
264 .filter(|(_, panel, ..)| !matches!(panel.placement, Placement::BelowInput(_)))
265 .filter_map(|(_, _, fits, _)| *fits)
266 .sum();
267 let asked = panels
268 .iter()
269 .filter_map(|(_, panel, fits, _)| match panel.placement {
270 Placement::BelowInput(size) => Some(fits.unwrap_or(rows_of(size, screen.height))),
271 _ => None,
272 });
273 let room = (screen.height.saturating_sub(fitted)).saturating_sub(MIN_TRANSCRIPT.1);
274 let below_rows = below_rows(asked, room);
275 let total: u16 = below_rows.iter().sum();
276 let [mut main, mut below] =
277 Layout::vertical([Constraint::Min(0), Constraint::Length(total)]).areas(screen);
278 let mut below_rows = below_rows.into_iter();
279 let (mut rest, mut within) = (Vec::new(), Vec::new());
280 let mut areas: Vec<(Entity, Rect)> = boxes.to_vec();
281 for (entity, panel, fits, mut canvas) in panels {
282 let fit = |size| fits.map_or(size, Constraint::Length);
283 let area = match panel.placement {
284 Placement::Rest => {
285 rest.push((entity, canvas));
286 continue;
287 }
288 Placement::Within { panel, part, of } => {
289 within.push((panel, (part, of), canvas));
290 continue;
291 }
292 Placement::Over { width, height } => screen.centered(width, height),
293 Placement::BelowInput(_) => {
294 let rows = below_rows.next().unwrap_or(0);
295 let [panel, left] =
296 Layout::vertical([Constraint::Length(rows), Constraint::Min(0)]).areas(below);
297 below = left;
298 if rows == 0 { Rect::default() } else { panel }
299 }
300 Placement::Top(size) => {
301 let [panel, rest] = Layout::vertical([fit(size), Constraint::Min(0)]).areas(main);
302 take(&mut main, panel, rest)
303 }
304 Placement::Bottom(size) => {
305 let [rest, panel] = Layout::vertical([Constraint::Min(0), fit(size)]).areas(main);
306 take(&mut main, panel, rest)
307 }
308 Placement::Left(size) => {
309 let [panel, rest] = Layout::horizontal([size, Constraint::Min(0)]).areas(main);
310 take(&mut main, panel, rest)
311 }
312 Placement::Right(size) => {
313 let [rest, panel] = Layout::horizontal([Constraint::Min(0), size]).areas(main);
314 take(&mut main, panel, rest)
315 }
316 };
317 // Drawing changes only the buffer: not a change to redraw for.
318 canvas.bypass_change_detection().reset(area);
319 areas.push((entity, area));
320 }
321 for (entity, mut canvas) in rest {
322 canvas.bypass_change_detection().reset(main);
323 areas.push((entity, main));
324 }
325 for (host, (part, of), mut canvas) in within {
326 let host = areas.iter().find(|(entity, _)| *entity == host);
327 let host = host.map_or(Rect::default(), |(_, area)| *area);
328 let columns = Layout::horizontal(vec![Constraint::Fill(1); usize::from(of.max(1))]);
329 let column = columns.split(host).get(usize::from(part)).copied();
330 canvas
331 .bypass_change_detection()
332 .reset(column.unwrap_or_default());
333 }
334}
335
336/// `panel` when `rest` is still big enough for the transcript, which it
337/// then becomes; otherwise nothing.
338fn take(main: &mut Rect, panel: Rect, rest: Rect) -> Rect {
339 if rest.width < MIN_TRANSCRIPT.0 || rest.height < MIN_TRANSCRIPT.1 {
340 return Rect::default();
341 }
342 *main = rest;
343 panel
344}