|
1 |
+ |
//! Column layout and row structure for tables.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! `makeover-webview`'s `list` module in the shape a terminal allows. It owns
|
|
4 |
+ |
//! the same four things: which columns exist, how wide they are, which ones
|
|
5 |
+ |
//! survive a narrow viewport, and what each part of a cell is. It does not own
|
|
6 |
+ |
//! what goes in a cell, for the reason that module states: a cell holds whatever
|
|
7 |
+ |
//! the app builds, and a description expressive enough to emit a task row's five
|
|
8 |
+ |
//! nested spans is a templating language wearing a description's name.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! # What ratatui already answers
|
|
11 |
+ |
//!
|
|
12 |
+ |
//! Most of the drawing. [`ratatui::widgets::Table`] lays tracks out from
|
|
13 |
+ |
//! [`Constraint`]s, draws a header, highlights a selected row and scrolls
|
|
14 |
+ |
//! through [`TableState`](ratatui::widgets::TableState). So this is a mapping
|
|
15 |
+ |
//! layer over it rather than a second table implementation, and it hands back a
|
|
16 |
+ |
//! `Table` instead of painting one: selection and scroll belong to the app's
|
|
17 |
+ |
//! state, and a function that painted would have to take that state to give it
|
|
18 |
+ |
//! back.
|
|
19 |
+ |
//!
|
|
20 |
+ |
//! Two things ratatui does not answer, and they are what this module is:
|
|
21 |
+ |
//!
|
|
22 |
+ |
//! - **Content measurement.** There is no track that sizes to what is in it, so
|
|
23 |
+ |
//! [`Width::Content`] is measured here from the cells and the heading.
|
|
24 |
+ |
//! - **Narrowing.** A terminal window is resized far more often than a browser
|
|
25 |
+ |
//! one, and [`Priority`] is how a column earns its place. See below.
|
|
26 |
+ |
//!
|
|
27 |
+ |
//! # Why positions are the bug
|
|
28 |
+ |
//!
|
|
29 |
+ |
//! Carried from the webview renderer verbatim, because the mistake is not a CSS
|
|
30 |
+ |
//! mistake. goingson hides its mobile columns with `nth-child(n+5)` against a
|
|
31 |
+ |
//! seven-column table; insert a column left of the cut and the wrong one
|
|
32 |
+ |
//! disappears, silently, because nothing in the rule knows what column five
|
|
33 |
+ |
//! *is*. A renderer narrows by raising a cutoff and never by counting, which is
|
|
34 |
+ |
//! the whole reason [`Priority`] exists. `a_column_inserted_left_of_the_cut_does_not_change_what_drops`
|
|
35 |
+ |
//! is that bug as a test.
|
|
36 |
+ |
//!
|
|
37 |
+ |
//! # What it costs when nothing fits
|
|
38 |
+ |
//!
|
|
39 |
+ |
//! [`Priority::Essential`] never drops, so a window narrower than the essential
|
|
40 |
+ |
//! columns leaves them overflowing rather than emptying the table. That is
|
|
41 |
+ |
//! deliberate: a row that cannot identify itself is not a narrower row, it is a
|
|
42 |
+ |
//! different one, and ratatui truncates a cell it cannot fit. Truncated and
|
|
43 |
+ |
//! present beats absent.
|
|
44 |
+ |
|
|
45 |
+ |
use makeover_layout::{CellPart, Column, Priority, Sort, Width};
|
|
46 |
+ |
use ratatui::layout::Constraint;
|
|
47 |
+ |
use ratatui::style::{Modifier, Style};
|
|
48 |
+ |
use ratatui::text::Line;
|
|
49 |
+ |
use ratatui::widgets::{Cell as TrackCell, Row, Table};
|
|
50 |
+ |
|
|
51 |
+ |
/// The cutoffs, weakest first.
|
|
52 |
+ |
///
|
|
53 |
+ |
/// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added
|
|
54 |
+ |
/// here in its place in the sequence, or a table will never narrow to it. Grep
|
|
55 |
+ |
/// this when adopting a new `makeover-layout`, the way
|
|
56 |
+ |
/// `makeover-webview`'s `part_class` asks to be grepped. The cost of missing one
|
|
57 |
+ |
/// is a column that drops later than it should, which is visible, rather than a
|
|
58 |
+ |
/// build that stops.
|
|
59 |
+ |
const CUTOFFS: [Priority; 3] = [
|
|
60 |
+ |
Priority::Optional,
|
|
61 |
+ |
Priority::Secondary,
|
|
62 |
+ |
Priority::Essential,
|
|
63 |
+ |
];
|
|
64 |
+ |
|
|
65 |
+ |
/// The lengths the description deferred, in cells.
|
|
66 |
+ |
///
|
|
67 |
+ |
/// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude, because
|
|
68 |
+ |
/// a magnitude is an answer for one renderer and the description is read by
|
|
69 |
+ |
/// three. `makeover-webview`'s `Sizing` is this same type holding CSS lengths;
|
|
70 |
+ |
/// this one holds terminal cells, and both are looked up by column name for the
|
|
71 |
+ |
/// same reason: an app's columns are not all one size.
|
|
72 |
+ |
#[derive(Debug, Clone, Copy, Default)]
|
|
73 |
+ |
pub struct Sizing<'a> {
|
|
74 |
+ |
/// `(column name, cells)`. The track for a [`Width::Fixed`] column and the
|
|
75 |
+ |
/// floor for a [`Width::Fill`] one.
|
|
76 |
+ |
pub lengths: &'a [(&'a str, u16)],
|
|
77 |
+ |
/// Used for a column with no entry above.
|
|
78 |
+ |
pub fallback: u16,
|
|
79 |
+ |
}
|
|
80 |
+ |
|
|
81 |
+ |
impl Sizing<'_> {
|
|
82 |
+ |
/// The length for a named column.
|
|
83 |
+ |
fn length_for(&self, name: &str) -> u16 {
|
|
84 |
+ |
self.lengths
|
|
85 |
+ |
.iter()
|
|
86 |
+ |
.find(|(column, _)| *column == name)
|
|
87 |
+ |
.map_or(self.fallback, |(_, length)| *length)
|
|
88 |
+ |
}
|
|
89 |
+ |
}
|
|
90 |
+ |
|
|
91 |
+ |
/// One cell of a row.
|
|
92 |
+ |
///
|
|
93 |
+ |
/// The contents are a ratatui [`Line`] rather than a string, which is this
|
|
94 |
+ |
/// crate's version of the webview `Cell` holding markup: the app owns what goes
|
|
95 |
+ |
/// in the cell, spans and all, and says which column it belongs to by name.
|
|
96 |
+ |
#[derive(Debug, Clone)]
|
|
97 |
+ |
pub struct Cell<'a> {
|
|
98 |
+ |
/// Which column this fills, by name.
|
|
99 |
+ |
pub column: &'a str,
|
|
100 |
+ |
/// What the cell holds, when the whole cell is one thing.
|
|
101 |
+ |
///
|
|
102 |
+ |
/// `None` for a cell mixing parts. A cell holding a value *and* a strip of
|
|
103 |
+ |
/// tokens *and* a control is three parts in one cell, and a terminal cell
|
|
104 |
+ |
/// has one style to give, so the app styles the spans itself. This field is
|
|
105 |
+ |
/// for the single-part case, which is the common one.
|
|
106 |
+ |
pub part: Option<CellPart>,
|
|
107 |
+ |
/// The contents.
|
|
108 |
+ |
pub content: Line<'a>,
|
|
109 |
+ |
}
|
|
110 |
+ |
|
|
111 |
+ |
impl<'a> Cell<'a> {
|
|
112 |
+ |
/// A cell with no cell part.
|
|
113 |
+ |
#[must_use]
|
|
114 |
+ |
pub fn new(column: &'a str, content: impl Into<Line<'a>>) -> Self {
|
|
115 |
+ |
Self {
|
|
116 |
+ |
column,
|
|
117 |
+ |
part: None,
|
|
118 |
+ |
content: content.into(),
|
|
119 |
+ |
}
|
|
120 |
+ |
}
|
|
121 |
+ |
|
|
122 |
+ |
/// The same cell, saying which part it is.
|
|
123 |
+ |
#[must_use]
|
|
124 |
+ |
pub fn part(mut self, part: CellPart) -> Self {
|
|
125 |
+ |
self.part = Some(part);
|
|
126 |
+ |
self
|
|
127 |
+ |
}
|
|
128 |
+ |
}
|
|
129 |
+ |
|
|
130 |
+ |
/// The tones and metrics a table draws with.
|
|
131 |
+ |
///
|
|
132 |
+ |
/// Apart from [`Palette`] rather than added to it, and the split is the one
|
|
133 |
+ |
/// `makeover-immediate` draws between its palette and its `FieldStyle`:
|
|
134 |
+ |
/// [`Palette`] answers what a *surface* is, which is what
|
|
135 |
+ |
/// [`frame`](crate::frame) needs, and a table is the first thing in this crate
|
|
136 |
+ |
/// that draws text. Folding text tones into [`Palette`] would make every
|
|
137 |
+ |
/// consumer that only paints a bevel supply six colours it never uses.
|
|
138 |
+ |
///
|
|
139 |
+ |
/// [`from_theme`](Self::from_theme) is the answer for anyone with a loaded
|
|
140 |
+ |
/// theme, and is what a consumer should reach for first.
|
|
141 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
142 |
+ |
pub struct TableStyle {
|
|
143 |
+ |
/// The heading row.
|
|
144 |
+ |
pub header: Style,
|
|
145 |
+ |
/// The heading of the column the table is ordered by.
|
|
146 |
+ |
pub sorted: Style,
|
|
147 |
+ |
/// A cell that is text.
|
|
148 |
+ |
pub value: Style,
|
|
149 |
+ |
/// A cell holding badges or chips. They carry their own tone, so this is
|
|
150 |
+ |
/// what sits under one rather than what paints it.
|
|
151 |
+ |
pub tokens: Style,
|
|
152 |
+ |
/// A cell holding controls.
|
|
153 |
+ |
pub actions: Style,
|
|
154 |
+ |
/// A cell whose value is itself a link.
|
|
155 |
+ |
pub link: Style,
|
|
156 |
+ |
/// The row under the cursor, for a caller rendering with a
|
|
157 |
+ |
/// [`TableState`](ratatui::widgets::TableState).
|
|
158 |
+ |
pub selected: Style,
|
|
159 |
+ |
/// Cells between columns. Counted when deciding what fits, so a table that
|
|
160 |
+ |
/// narrows and a table that draws agree about the room available.
|
|
161 |
+ |
pub column_spacing: u16,
|
|
162 |
+ |
/// Drawn after the heading of an ascending column.
|
|
163 |
+ |
pub ascending: &'static str,
|
|
164 |
+ |
/// Drawn after the heading of a descending column.
|
|
165 |
+ |
pub descending: &'static str,
|
|
166 |
+ |
}
|
|
167 |
+ |
|
|
168 |
+ |
impl Default for TableStyle {
|
|
169 |
+ |
fn default() -> Self {
|
|
170 |
+ |
Self {
|
|
171 |
+ |
header: Style::new().add_modifier(Modifier::BOLD),
|
|
172 |
+ |
sorted: Style::new().add_modifier(Modifier::BOLD),
|
|
173 |
+ |
value: Style::new(),
|
|
174 |
+ |
tokens: Style::new(),
|
|
175 |
+ |
actions: Style::new(),
|
|
176 |
+ |
link: Style::new().add_modifier(Modifier::UNDERLINED),
|
|
177 |
+ |
selected: Style::new().add_modifier(Modifier::REVERSED),
|
|
178 |
+ |
column_spacing: 1,
|
|
179 |
+ |
// The pair audiofiles already draws, so a sorted column points the
|
|
180 |
+ |
// same way in a terminal as it does in the egui browser.
|
|
181 |
+ |
ascending: " \u{25B2}",
|
|
182 |
+ |
descending: " \u{25BC}",
|
|
183 |
+ |
}
|
|
184 |
+ |
}
|
|
185 |
+ |
}
|
|
186 |
+ |
|
|
187 |
+ |
impl TableStyle {
|
|
188 |
+ |
/// The house table, from a loaded theme.
|
|
189 |
+ |
///
|
|
190 |
+ |
/// This is the lift `mnw-cli` and `viewer` were each doing by hand: a muted
|
|
191 |
+ |
/// bold heading, the ordered column brought back up to primary, actions and
|
|
192 |
+ |
/// links on the action colour rather than on the cell's text colour, and
|
|
193 |
+ |
/// selection carried by the background alone.
|
|
194 |
+ |
///
|
|
195 |
+ |
/// Selection carries no foreground on purpose. A row can be red for a failed
|
|
196 |
+ |
/// upload or green for a published item, and repainting its text on
|
|
197 |
+ |
/// selection loses that distinction on exactly the row the user is looking
|
|
198 |
+ |
/// at. `mnw-cli`'s `selected_style` found this and its comment says so;
|
|
199 |
+ |
/// this is that comment's code, in the library, once.
|
|
200 |
+ |
#[cfg(feature = "theme")]
|
|
201 |
+ |
#[must_use]
|
|
202 |
+ |
pub fn from_theme(theme: &crate::Theme) -> Self {
|
|
203 |
+ |
Self {
|
|
204 |
+ |
header: Style::new()
|
|
205 |
+ |
.fg(theme.content_muted)
|
|
206 |
+ |
.add_modifier(Modifier::BOLD),
|
|
207 |
+ |
sorted: Style::new()
|
|
208 |
+ |
.fg(theme.content_primary)
|
|
209 |
+ |
.add_modifier(Modifier::BOLD),
|
|
210 |
+ |
value: Style::new().fg(theme.content_primary),
|
|
211 |
+ |
// A token paints its own background, and a tone underneath it would
|
|
212 |
+ |
// fight the one sitting on it. Secondary is what shows through the
|
|
213 |
+ |
// gaps.
|
|
214 |
+ |
tokens: Style::new().fg(theme.content_secondary),
|
|
215 |
+ |
actions: Style::new().fg(theme.action_primary),
|
|
216 |
+ |
link: Style::new()
|
|
217 |
+ |
.fg(theme.action_primary)
|
|
218 |
+ |
.add_modifier(Modifier::UNDERLINED),
|
|
219 |
+ |
selected: Style::new()
|
|
220 |
+ |
.bg(theme.surface_raised)
|
|
221 |
+ |
.add_modifier(Modifier::BOLD),
|
|
222 |
+ |
column_spacing: 1,
|
|
223 |
+ |
ascending: " \u{25B2}",
|
|
224 |
+ |
descending: " \u{25BC}",
|
|
225 |
+ |
}
|
|
226 |
+ |
}
|
|
227 |
+ |
|
|
228 |
+ |
/// The style a cell of this part takes.
|
|
229 |
+ |
///
|
|
230 |
+ |
/// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
|
|
231 |
+ |
/// [`value`](Self::value): a part this renderer has not learned draws as
|
|
232 |
+ |
/// text, which is a cell rendering plainly rather than a build that stops.
|
|
233 |
+ |
/// Grep this when adopting a new `makeover-layout`.
|
|
234 |
+ |
#[must_use]
|
|
235 |
+ |
pub fn for_part(&self, part: Option<CellPart>) -> Style {
|
|
236 |
+ |
match part {
|
|
237 |
+ |
Some(CellPart::Tokens) => self.tokens,
|
|
238 |
+ |
Some(CellPart::Actions) => self.actions,
|
|
239 |
+ |
Some(CellPart::Link) => self.link,
|
|
240 |
+ |
_ => self.value,
|
|
241 |
+ |
}
|
|
242 |
+ |
}
|
|
243 |
+ |
}
|
|
244 |
+ |
|
|
245 |
+ |
/// The heading, with the caret if the table is ordered by this column.
|
|
246 |
+ |
///
|
|
247 |
+ |
/// A column [`sorted`](Column::sorted) but not
|
|
248 |
+ |
/// [`sortable`](Column::sortable) still gets its caret. Both combinations mean
|
|
249 |
+ |
/// something, which is why the description holds the two fields apart: a list
|
|
250 |
+ |
/// ordered by a key the user cannot change is a real thing, and the caret is how
|
|
251 |
+ |
/// it says so.
|
|
252 |
+ |
fn heading<'a>(column: &Column<'a>, style: &TableStyle) -> Line<'a> {
|
|
253 |
+ |
match column.sorted {
|
|
254 |
+ |
Some(Sort::Ascending) => Line::from(format!("{}{}", column.name, style.ascending)),
|
|
255 |
+ |
Some(Sort::Descending) => Line::from(format!("{}{}", column.name, style.descending)),
|
|
256 |
+ |
None => Line::from(column.name),
|
|
257 |
+ |
}
|
|
258 |
+ |
}
|
|
259 |
+ |
|
|
260 |
+ |
/// How wide a column wants to be, in cells, at its narrowest.
|
|
261 |
+ |
///
|
|
262 |
+ |
/// The floor for a fill column rather than its appetite, because narrowing asks
|
|
263 |
+ |
/// what a layout costs at minimum and a fill column costs its floor.
|
|
264 |
+ |
fn min_width<'a, R>(column: &Column<'a>, rows: &[R], sizing: &Sizing<'_>, style: &TableStyle) -> u16
|
|
265 |
+ |
where
|
|
266 |
+ |
R: AsRef<[Cell<'a>]>,
|
|
267 |
+ |
{
|
|
268 |
+ |
match column.width {
|
|
269 |
+ |
Width::Content => measure(column, rows, style),
|
|
270 |
+ |
Width::Fixed => sizing.length_for(column.name),
|
|
271 |
+ |
// Includes a width added to the description since this renderer was
|
|
272 |
+ |
// built. Taking the slack above a floor is the behaviour that makes no
|
|
273 |
+ |
// claim, which is the same fallback the webview renderer's `auto` track
|
|
274 |
+ |
// is chosen to be.
|
|
275 |
+ |
_ => sizing.length_for(column.name),
|
|
276 |
+ |
}
|
|
277 |
+ |
}
|
|
278 |
+ |
|
|
279 |
+ |
/// The widest thing in a column, heading included.
|
|
280 |
+ |
///
|
|
281 |
+ |
/// The heading counts because it is drawn: a column sized to its cells alone
|
|
282 |
+ |
/// truncates its own name, and a two-character column called `duration` reads as
|
|
283 |
+ |
/// `du`. The caret counts for the same reason, which is why this measures
|
|
284 |
+ |
/// [`heading`] rather than [`Column::name`].
|
|
285 |
+ |
fn measure<'a, R>(column: &Column<'a>, rows: &[R], style: &TableStyle) -> u16
|
|
286 |
+ |
where
|
|
287 |
+ |
R: AsRef<[Cell<'a>]>,
|
|
288 |
+ |
{
|
|
289 |
+ |
let widest = rows
|
|
290 |
+ |
.iter()
|
|
291 |
+ |
.filter_map(|row| {
|
|
292 |
+ |
row.as_ref()
|
|
293 |
+ |
.iter()
|
|
294 |
+ |
.find(|cell| cell.column == column.name)
|
|
295 |
+ |
.map(|cell| cell.content.width())
|
|
296 |
+ |
})
|
|
297 |
+ |
.max()
|
|
298 |
+ |
.unwrap_or(0);
|
|
299 |
+ |
u16::try_from(widest.max(heading(column, style).width())).unwrap_or(u16::MAX)
|
|
300 |
+ |
}
|
|
301 |
+ |
|
|
302 |
+ |
/// Whether the columns kept at `cutoff` fit in `width`.
|
|
303 |
+ |
fn fits<'a, R>(
|
|
304 |
+ |
columns: &[Column<'a>],
|
|
305 |
+ |
rows: &[R],
|
|
306 |
+ |
sizing: &Sizing<'_>,
|
|
307 |
+ |
style: &TableStyle,
|
|
308 |
+ |
cutoff: Priority,
|
|
309 |
+ |
width: u16,
|
|
310 |
+ |
) -> bool
|
|
311 |
+ |
where
|
|
312 |
+ |
R: AsRef<[Cell<'a>]>,
|
|
313 |
+ |
{
|
|
314 |
+ |
let kept: Vec<&Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
|
|
315 |
+ |
let gaps = u32::from(style.column_spacing) * (kept.len().saturating_sub(1)) as u32;
|
|
316 |
+ |
let tracks: u32 = kept
|
|
317 |
+ |
.iter()
|
|
318 |
+ |
.map(|c| u32::from(min_width(c, rows, sizing, style)))
|
|
319 |
+ |
.sum();
|
|
320 |
+ |
tracks + gaps <= u32::from(width)
|
|
321 |
+ |
}
|
|
322 |
+ |
|
|
323 |
+ |
/// The weakest cutoff whose columns fit in `width`.
|
|
324 |
+ |
///
|
|
325 |
+ |
/// Raised until the layout fits, and never past [`Priority::Essential`]: the
|
|
326 |
+ |
/// essential columns are what makes a row identify itself, so a window too
|
|
327 |
+ |
/// narrow for them gets them truncated rather than dropped. Nothing here counts
|
|
328 |
+ |
/// positions, so which column drops is a property of the column.
|
|
329 |
+ |
#[must_use]
|
|
330 |
+ |
pub fn cutoff_for<'a, R>(
|
|
331 |
+ |
columns: &[Column<'a>],
|
|
332 |
+ |
rows: &[R],
|
|
333 |
+ |
sizing: &Sizing<'_>,
|
|
334 |
+ |
style: &TableStyle,
|
|
335 |
+ |
width: u16,
|
|
336 |
+ |
) -> Priority
|
|
337 |
+ |
where
|
|
338 |
+ |
R: AsRef<[Cell<'a>]>,
|
|
339 |
+ |
{
|
|
340 |
+ |
for cutoff in CUTOFFS {
|
|
341 |
+ |
if fits(columns, rows, sizing, style, cutoff, width) {
|
|
342 |
+ |
return cutoff;
|
|
343 |
+ |
}
|
|
344 |
+ |
}
|
|
345 |
+ |
Priority::Essential
|
|
346 |
+ |
}
|
|
347 |
+ |
|
|
348 |
+ |
/// The tracks for the columns kept at `cutoff`.
|
|
349 |
+ |
///
|
|
350 |
+ |
/// Only the surviving tracks, which is what keeps the track list and the hiding
|
|
351 |
+ |
/// in agreement. A caller that dropped a cell but left its track would get a
|
|
352 |
+ |
/// column of empty space, which is the other half of the goingson bug the
|
|
353 |
+ |
/// webview renderer's `grid_template_columns` names.
|
|
354 |
+ |
#[must_use]
|
|
355 |
+ |
pub fn constraints<'a, R>(
|
|
356 |
+ |
columns: &[Column<'a>],
|
|
357 |
+ |
rows: &[R],
|
|
358 |
+ |
sizing: &Sizing<'_>,
|
|
359 |
+ |
style: &TableStyle,
|
|
360 |
+ |
cutoff: Priority,
|
|
361 |
+ |
) -> Vec<Constraint>
|
|
362 |
+ |
where
|
|
363 |
+ |
R: AsRef<[Cell<'a>]>,
|
|
364 |
+ |
{
|
|
365 |
+ |
columns
|
|
366 |
+ |
.iter()
|
|
367 |
+ |
.filter(|column| column.kept_at(cutoff))
|
|
368 |
+ |
.map(|column| match column.width {
|
|
369 |
+ |
// Takes what it needs and no more, which is a fixed track once the
|
|
370 |
+ |
// needing has been measured.
|
|
371 |
+ |
Width::Content => Constraint::Length(measure(column, rows, style)),
|
|
372 |
+ |
Width::Fixed => Constraint::Length(sizing.length_for(column.name)),
|
|
373 |
+ |
// `Min` and not `Fill`: a fill column absorbs the slack *and* keeps
|
|
374 |
+ |
// its floor, which is what `minmax(len, 1fr)` says at the webview
|
|
375 |
+ |
// renderer. `Fill` would let it collapse below the floor when a
|
|
376 |
+ |
// fixed column takes the room.
|
|
377 |
+ |
_ => Constraint::Min(sizing.length_for(column.name)),
|
|
378 |
+ |
})
|
|
379 |
+ |
.collect()
|
|
380 |
+ |
}
|
|
381 |
+ |
|
|
382 |
+ |
/// One row's cells, in column order.
|
|
383 |
+ |
///
|
|
384 |
+ |
/// Ordered by the columns and not by the cells, so a row cannot silently
|
|
385 |
+ |
/// disagree with its table about what comes where. A column with no cell gets an
|
|
386 |
+ |
/// empty cell, which keeps the tracks aligned; a cell naming no column is
|
|
387 |
+ |
/// dropped, because there is nowhere to put it. That is
|
|
388 |
+ |
/// `makeover-webview`'s `cells_html` rule, and it has to be the same rule or the
|
|
389 |
+ |
/// two renderers disagree about a row they were handed identically.
|
|
390 |
+ |
#[must_use]
|
|
391 |
+ |
pub fn row<'a>(
|
|
392 |
+ |
columns: &[Column<'a>],
|
|
393 |
+ |
cells: &[Cell<'a>],
|
|
394 |
+ |
style: &TableStyle,
|
|
395 |
+ |
cutoff: Priority,
|
|
396 |
+ |
) -> Row<'a> {
|
|
397 |
+ |
Row::new(
|
|
398 |
+ |
columns
|
|
399 |
+ |
.iter()
|
|
400 |
+ |
.filter(|column| column.kept_at(cutoff))
|
|
401 |
+ |
.map(|column| {
|
|
402 |
+ |
let found = cells.iter().find(|cell| cell.column == column.name);
|
|
403 |
+ |
let part = found.and_then(|cell| cell.part);
|
|
404 |
+ |
let content = found.map_or_else(Line::default, |cell| cell.content.clone());
|
|
405 |
+ |
TrackCell::from(content).style(style.for_part(part))
|
|
406 |
+ |
})
|
|
407 |
+ |
.collect::<Vec<_>>(),
|
|
408 |
+ |
)
|
|
409 |
+ |
}
|
|
410 |
+ |
|
|
411 |
+ |
/// The heading row for the columns kept at `cutoff`.
|
|
412 |
+ |
///
|
|
413 |
+ |
/// Exposed beside [`table`] because a caller assembling its own
|
|
414 |
+ |
/// [`Table`] still has to draw a header that agrees with the body about what
|
|
415 |
+ |
/// just disappeared. Assembling it a second time by hand is how they stop
|
|
416 |
+ |
/// agreeing.
|
|
417 |
+ |
#[must_use]
|
|
418 |
+ |
pub fn header<'a>(columns: &[Column<'a>], style: &TableStyle, cutoff: Priority) -> Row<'a> {
|
|
419 |
+ |
Row::new(
|
|
420 |
+ |
columns
|
|
421 |
+ |
.iter()
|
|
422 |
+ |
.filter(|column| column.kept_at(cutoff))
|
|
423 |
+ |
.map(|column| {
|
|
424 |
+ |
let tone = if column.sorted.is_some() {
|
|
425 |
+ |
style.sorted
|
|
426 |
+ |
} else {
|
|
427 |
+ |
style.header
|
|
428 |
+ |
};
|
|
429 |
+ |
TrackCell::from(heading(column, style)).style(tone)
|
|
430 |
+ |
})
|
|
431 |
+ |
.collect::<Vec<_>>(),
|
|
432 |
+ |
)
|
|
433 |
+ |
.style(style.header)
|
|
434 |
+ |
}
|
|
435 |
+ |
|
|
436 |
+ |
/// A described table, sized and narrowed for `width`.
|
|
437 |
+ |
///
|
|
438 |
+ |
/// Hands back a [`Table`] rather than drawing one. Selection and scroll live in
|
|
439 |
+ |
/// the app's [`TableState`](ratatui::widgets::TableState), and the row highlight
|
|
440 |
+ |
/// is already set from [`TableStyle::selected`], so a caller renders this with
|
|
441 |
+ |
/// `render_stateful_widget` and gets the house selection without saying anything
|
|
442 |
+ |
/// further.
|
|
443 |
+ |
///
|
|
444 |
+ |
/// `width` is the area the table will be drawn in, which is what narrowing is
|
|
445 |
+ |
/// decided against. Pass the [`Rect`](ratatui::layout::Rect) width that
|
|
446 |
+ |
/// [`frame`](crate::frame) handed back rather than the region's own, or the
|
|
447 |
+ |
/// table budgets for the two cells the edge took.
|
|
448 |
+ |
#[must_use]
|
|
449 |
+ |
pub fn table<'a, R>(
|
|
450 |
+ |
columns: &[Column<'a>],
|
|
451 |
+ |
rows: &[R],
|
|
452 |
+ |
sizing: &Sizing<'_>,
|
|
453 |
+ |
style: &TableStyle,
|
|
454 |
+ |
width: u16,
|
|
455 |
+ |
) -> Table<'a>
|
|
456 |
+ |
where
|
|
457 |
+ |
R: AsRef<[Cell<'a>]>,
|
|
458 |
+ |
{
|
|
459 |
+ |
let cutoff = cutoff_for(columns, rows, sizing, style, width);
|
|
460 |
+ |
let widths = constraints(columns, rows, sizing, style, cutoff);
|
|
461 |
+ |
let body: Vec<Row<'a>> = rows
|
|
462 |
+ |
.iter()
|
|
463 |
+ |
.map(|cells| row(columns, cells.as_ref(), style, cutoff))
|
|
464 |
+ |
.collect();
|
|
465 |
+ |
|
|
466 |
+ |
Table::new(body, widths)
|
|
467 |
+ |
.header(header(columns, style, cutoff))
|
|
468 |
+ |
.column_spacing(style.column_spacing)
|
|
469 |
+ |
.row_highlight_style(style.selected)
|
|
470 |
+ |
}
|
|
471 |
+ |
|
|
472 |
+ |
/// Whether a table drawn at `width` would leave anything overflowing.
|
|
473 |
+ |
///
|
|
474 |
+ |
/// True only when the essential columns alone do not fit, since that is the one
|
|
475 |
+ |
/// case narrowing cannot answer. A caller that would rather show fewer rows than
|
|
476 |
+ |
/// truncate a cell can ask this and draw something else.
|
|
477 |
+ |
#[must_use]
|
|
478 |
+ |
pub fn overflows<'a, R>(
|
|
479 |
+ |
columns: &[Column<'a>],
|
|
480 |
+ |
rows: &[R],
|
|
481 |
+ |
sizing: &Sizing<'_>,
|
|
482 |
+ |
style: &TableStyle,
|
|
483 |
+ |
width: u16,
|
|
484 |
+ |
) -> bool
|
|
485 |
+ |
where
|
|
486 |
+ |
R: AsRef<[Cell<'a>]>,
|
|
487 |
+ |
{
|
|
488 |
+ |
!fits(columns, rows, sizing, style, Priority::Essential, width)
|
|
489 |
+ |
}
|
|
490 |
+ |
|
|
491 |
+ |
#[cfg(test)]
|
|
492 |
+ |
mod tests {
|
|
493 |
+ |
use super::*;
|
|
494 |
+ |
|
|
495 |
+ |
fn columns() -> Vec<Column<'static>> {
|
|
496 |
+ |
vec![
|
|
497 |
+ |
Column {
|
|
498 |
+ |
name: "name",
|
|
499 |
+ |
width: Width::Fill,
|
|
500 |
+ |
priority: Priority::Essential,
|