Skip to main content

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}