|
1 |
+ |
//! Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! `makeover-webview`'s `list` module and `makeover-tui`'s `table` in the shape
|
|
4 |
+ |
//! immediate mode allows. It owns the same four things: which columns exist, how
|
|
5 |
+ |
//! wide they are, which ones survive a narrow viewport, and what each part of a
|
|
6 |
+ |
//! cell is. It does not own what goes in a cell, which here is not a policy but
|
|
7 |
+ |
//! a fact of the mode: a cell's contents are drawn by the app's own closure, the
|
|
8 |
+ |
//! way [`group`](crate::group) already takes one per field.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! # Why `egui_extras` and not egui
|
|
11 |
+ |
//!
|
|
12 |
+ |
//! egui itself has no table. [`egui::Grid`] gives no per-column sizing, no
|
|
13 |
+ |
//! sticky header and no scroll sync, which is why audiofiles reached for
|
|
14 |
+ |
//! `egui_extras::TableBuilder` rather than building on `Grid`. Writing a third
|
|
15 |
+ |
//! answer here would be reimplementing that crate worse, so this is a mapping
|
|
16 |
+ |
//! layer over it.
|
|
17 |
+ |
//!
|
|
18 |
+ |
//! It is the first dependency this crate has taken beyond egui itself, and it
|
|
19 |
+ |
//! moves in lockstep with egui's own version, which is the cost worth naming.
|
|
20 |
+ |
//!
|
|
21 |
+ |
//! # What immediate mode costs the narrowing
|
|
22 |
+ |
//!
|
|
23 |
+ |
//! The terminal renderer measures a [`Width::Content`] column from its cells,
|
|
24 |
+ |
//! because it holds every cell before it draws any. Here the cells do not exist
|
|
25 |
+ |
//! until the app's closure runs, so nothing can be measured before the layout is
|
|
26 |
+ |
//! decided.
|
|
27 |
+ |
//!
|
|
28 |
+ |
//! That splits the answer in two, and both halves are honest:
|
|
29 |
+ |
//!
|
|
30 |
+ |
//! - **Sizing** hands a content column to
|
|
31 |
+ |
//! [`egui_extras::Column::auto`], which measures it and holds the result
|
|
32 |
+ |
//! between frames. This is better than the terminal gets, not worse.
|
|
33 |
+ |
//! - **Narrowing** cannot wait for that, so it budgets a content column at the
|
|
34 |
+ |
//! floor the app declared in [`Sizing`]. A column that turns out wider than
|
|
35 |
+ |
//! its floor is still drawn; it is the *decision to drop* that uses the
|
|
36 |
+ |
//! declared number, and a floor is what the app already has to supply for its
|
|
37 |
+ |
//! fill columns.
|
|
38 |
+ |
//!
|
|
39 |
+ |
//! # Why positions are the bug
|
|
40 |
+ |
//!
|
|
41 |
+ |
//! Carried from the other two renderers, because the mistake is not a CSS
|
|
42 |
+ |
//! mistake and not a terminal one. goingson hides its mobile columns with
|
|
43 |
+ |
//! `nth-child(n+5)` against a seven-column table; insert a column left of the
|
|
44 |
+ |
//! cut and the wrong one disappears, silently. A renderer narrows by raising a
|
|
45 |
+ |
//! cutoff and never by counting.
|
|
46 |
+ |
|
|
47 |
+ |
use crate::Palette;
|
|
48 |
+ |
use egui::{Response, RichText, Sense, Ui};
|
|
49 |
+ |
use egui_extras::{Column as Track, TableBuilder};
|
|
50 |
+ |
use makeover_layout::{CellPart, Column, Priority, Sort, Width};
|
|
51 |
+ |
|
|
52 |
+ |
/// The cutoffs, weakest first.
|
|
53 |
+ |
///
|
|
54 |
+ |
/// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added
|
|
55 |
+ |
/// here in its place in the sequence, or a table will never narrow to it. Grep
|
|
56 |
+ |
/// this when adopting a new `makeover-layout`; `makeover-tui` carries the same
|
|
57 |
+ |
/// list for the same reason, and the two have to agree or a description narrows
|
|
58 |
+ |
/// differently in a window than in a terminal.
|
|
59 |
+ |
const CUTOFFS: [Priority; 3] = [
|
|
60 |
+ |
Priority::Optional,
|
|
61 |
+ |
Priority::Secondary,
|
|
62 |
+ |
Priority::Essential,
|
|
63 |
+ |
];
|
|
64 |
+ |
|
|
65 |
+ |
/// The lengths the description deferred, in points.
|
|
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. The other two renderers hold this same type over CSS lengths and over
|
|
70 |
+ |
/// terminal cells.
|
|
71 |
+ |
#[derive(Debug, Clone, Copy, Default)]
|
|
72 |
+ |
pub struct Sizing<'a> {
|
|
73 |
+ |
/// `(column name, points)`. The track for a [`Width::Fixed`] column, the
|
|
74 |
+ |
/// floor for a [`Width::Fill`] one, and the narrowing budget for a
|
|
75 |
+ |
/// [`Width::Content`] one.
|
|
76 |
+ |
pub lengths: &'a [(&'a str, f32)],
|
|
77 |
+ |
/// Used for a column with no entry above.
|
|
78 |
+ |
pub fallback: f32,
|
|
79 |
+ |
}
|
|
80 |
+ |
|
|
81 |
+ |
impl Sizing<'_> {
|
|
82 |
+ |
/// The length for a named column.
|
|
83 |
+ |
fn length_for(&self, name: &str) -> f32 {
|
|
84 |
+ |
self.lengths
|
|
85 |
+ |
.iter()
|
|
86 |
+ |
.find(|(column, _)| *column == name)
|
|
87 |
+ |
.map_or(self.fallback, |(_, length)| *length)
|
|
88 |
+ |
}
|
|
89 |
+ |
}
|
|
90 |
+ |
|
|
91 |
+ |
/// The tones and metrics a table draws with.
|
|
92 |
+ |
///
|
|
93 |
+ |
/// Metrics only, and the tones come from [`Palette`]. That is the division this
|
|
94 |
+ |
/// crate already draws: [`FieldStyle`](crate::FieldStyle) carries gaps and a
|
|
95 |
+ |
/// marker while the colours stay in the palette, and a table's colours are the
|
|
96 |
+ |
/// palette's `content`, `content_muted` and `action` rather than six new ones.
|
|
97 |
+ |
/// `makeover-tui` splits it the other way round because its palette carries no
|
|
98 |
+ |
/// text tones at all.
|
|
99 |
+ |
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
100 |
+ |
pub struct TableStyle {
|
|
101 |
+ |
/// The height of the heading row.
|
|
102 |
+ |
pub header_height: f32,
|
|
103 |
+ |
/// The height of a body row.
|
|
104 |
+ |
pub row_height: f32,
|
|
105 |
+ |
/// Drawn after the heading of an ascending column.
|
|
106 |
+ |
pub ascending: &'static str,
|
|
107 |
+ |
/// Drawn after the heading of a descending column.
|
|
108 |
+ |
pub descending: &'static str,
|
|
109 |
+ |
/// Whether alternate rows take a different background.
|
|
110 |
+ |
///
|
|
111 |
+ |
/// egui_extras' own striping, off by default: the description has no word
|
|
112 |
+ |
/// for it, and a renderer that turned it on would be adding a claim the
|
|
113 |
+ |
/// other two cannot make.
|
|
114 |
+ |
pub striped: bool,
|
|
115 |
+ |
/// Whether the heading stays put while the body scrolls.
|
|
116 |
+ |
pub sticky_header: bool,
|
|
117 |
+ |
}
|
|
118 |
+ |
|
|
119 |
+ |
impl Default for TableStyle {
|
|
120 |
+ |
fn default() -> Self {
|
|
121 |
+ |
Self {
|
|
122 |
+ |
header_height: 20.0,
|
|
123 |
+ |
row_height: 18.0,
|
|
124 |
+ |
// The pair audiofiles already draws, so a sorted column points the
|
|
125 |
+ |
// same way here as it does in a terminal.
|
|
126 |
+ |
ascending: " \u{25B2}",
|
|
127 |
+ |
descending: " \u{25BC}",
|
|
128 |
+ |
striped: false,
|
|
129 |
+ |
sticky_header: true,
|
|
130 |
+ |
}
|
|
131 |
+ |
}
|
|
132 |
+ |
}
|
|
133 |
+ |
|
|
134 |
+ |
/// The colour a cell of this part takes.
|
|
135 |
+ |
///
|
|
136 |
+ |
/// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
|
|
137 |
+ |
/// `content`: a part this renderer has not learned draws as text, which is a
|
|
138 |
+ |
/// cell rendering plainly rather than a build that stops. Grep this when
|
|
139 |
+ |
/// adopting a new `makeover-layout`.
|
|
140 |
+ |
#[must_use]
|
|
141 |
+ |
pub const fn part_color(part: Option<CellPart>, palette: &Palette) -> egui::Color32 {
|
|
142 |
+ |
match part {
|
|
143 |
+ |
// A token paints its own background and carries its own tone. What is
|
|
144 |
+ |
// set here is what shows between them, not what paints them.
|
|
145 |
+ |
Some(CellPart::Tokens) => palette.content_muted,
|
|
146 |
+ |
// The drift `CellPart` exists to end: a control in a cell inheriting the
|
|
147 |
+ |
// cell's text colour. Both of these take the action intent instead.
|
|
148 |
+ |
Some(CellPart::Actions | CellPart::Link) => palette.action,
|
|
149 |
+ |
_ => palette.content,
|
|
150 |
+ |
}
|
|
151 |
+ |
}
|
|
152 |
+ |
|
|
153 |
+ |
/// Draw a cell's contents with the tone its part takes.
|
|
154 |
+ |
///
|
|
155 |
+ |
/// The app calls this inside its own cell closure, wrapping whatever it draws.
|
|
156 |
+ |
/// A scoping function rather than a parameter on [`table`], for the reason
|
|
157 |
+ |
/// [`frame`](crate::frame) is one: the part is a property of the cell, the cell
|
|
158 |
+ |
/// does not exist until the closure runs, and immediate mode has no cascade to
|
|
159 |
+ |
/// carry the answer down on its own. This is the cascade, for one scope.
|
|
160 |
+ |
///
|
|
161 |
+ |
/// ```no_run
|
|
162 |
+ |
/// # use makeover_layout::CellPart;
|
|
163 |
+ |
/// # let palette: makeover_immediate::Palette = unimplemented!();
|
|
164 |
+ |
/// # let ui: &mut egui::Ui = unimplemented!();
|
|
165 |
+ |
/// makeover_immediate::table::cell(ui, Some(CellPart::Link), &palette, |ui| {
|
|
166 |
+ |
/// ui.label("opens the item");
|
|
167 |
+ |
/// });
|
|
168 |
+ |
/// ```
|
|
169 |
+ |
pub fn cell<R>(
|
|
170 |
+ |
ui: &mut Ui,
|
|
171 |
+ |
part: Option<CellPart>,
|
|
172 |
+ |
palette: &Palette,
|
|
173 |
+ |
add_contents: impl FnOnce(&mut Ui) -> R,
|
|
174 |
+ |
) -> R {
|
|
175 |
+ |
let restore = ui.visuals().override_text_color;
|
|
176 |
+ |
ui.visuals_mut().override_text_color = Some(part_color(part, palette));
|
|
177 |
+ |
let out = add_contents(ui);
|
|
178 |
+ |
ui.visuals_mut().override_text_color = restore;
|
|
179 |
+ |
out
|
|
180 |
+ |
}
|
|
181 |
+ |
|
|
182 |
+ |
/// The heading, with the caret if the table is ordered by this column.
|
|
183 |
+ |
///
|
|
184 |
+ |
/// A column [`sorted`](Column::sorted) but not [`sortable`](Column::sortable)
|
|
185 |
+ |
/// still gets its caret. Both combinations mean something, which is why the
|
|
186 |
+ |
/// description holds the two fields apart: a list ordered by a key the user
|
|
187 |
+ |
/// cannot change is a real thing, and the caret is how it says so.
|
|
188 |
+ |
#[must_use]
|
|
189 |
+ |
pub fn heading(column: &Column<'_>, style: &TableStyle) -> String {
|
|
190 |
+ |
match column.sorted {
|
|
191 |
+ |
Some(Sort::Ascending) => format!("{}{}", column.name, style.ascending),
|
|
192 |
+ |
Some(Sort::Descending) => format!("{}{}", column.name, style.descending),
|
|
193 |
+ |
None => column.name.to_owned(),
|
|
194 |
+ |
}
|
|
195 |
+ |
}
|
|
196 |
+ |
|
|
197 |
+ |
/// How wide a column asks to be at its narrowest, in points.
|
|
198 |
+ |
fn min_width(column: &Column<'_>, sizing: &Sizing<'_>) -> f32 {
|
|
199 |
+ |
// Every arm is the declared length, including `Content`: nothing can be
|
|
200 |
+ |
// measured before the app's closure has drawn it. See the module header on
|
|
201 |
+ |
// what immediate mode costs the narrowing.
|
|
202 |
+ |
sizing.length_for(column.name)
|
|
203 |
+ |
}
|
|
204 |
+ |
|
|
205 |
+ |
/// Whether the columns kept at `cutoff` fit in `width`.
|
|
206 |
+ |
fn fits(columns: &[Column<'_>], sizing: &Sizing<'_>, cutoff: Priority, width: f32) -> bool {
|
|
207 |
+ |
columns
|
|
208 |
+ |
.iter()
|
|
209 |
+ |
.filter(|c| c.kept_at(cutoff))
|
|
210 |
+ |
.map(|c| min_width(c, sizing))
|
|
211 |
+ |
.sum::<f32>()
|
|
212 |
+ |
<= width
|
|
213 |
+ |
}
|
|
214 |
+ |
|
|
215 |
+ |
/// The weakest cutoff whose columns fit in `width`.
|
|
216 |
+ |
///
|
|
217 |
+ |
/// Raised until the layout fits, and never past [`Priority::Essential`]: the
|
|
218 |
+ |
/// essential columns are what makes a row identify itself, so a window too
|
|
219 |
+ |
/// narrow for them gets them squeezed rather than dropped. Nothing here counts
|
|
220 |
+ |
/// positions, so which column drops is a property of the column.
|
|
221 |
+ |
#[must_use]
|
|
222 |
+ |
pub fn cutoff_for(columns: &[Column<'_>], sizing: &Sizing<'_>, width: f32) -> Priority {
|
|
223 |
+ |
for cutoff in CUTOFFS {
|
|
224 |
+ |
if fits(columns, sizing, cutoff, width) {
|
|
225 |
+ |
return cutoff;
|
|
226 |
+ |
}
|
|
227 |
+ |
}
|
|
228 |
+ |
Priority::Essential
|
|
229 |
+ |
}
|
|
230 |
+ |
|
|
231 |
+ |
/// The track for one column.
|
|
232 |
+ |
fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track {
|
|
233 |
+ |
match column.width {
|
|
234 |
+ |
// The one place immediate mode beats the terminal: egui_extras measures
|
|
235 |
+ |
// this and remembers it between frames, where `makeover-tui` has to walk
|
|
236 |
+ |
// the cells itself.
|
|
237 |
+ |
Width::Content => Track::auto(),
|
|
238 |
+ |
Width::Fixed => Track::exact(sizing.length_for(column.name)),
|
|
239 |
+ |
// Includes a width added to the description since this renderer was
|
|
240 |
+ |
// built. Taking the slack above a floor is the behaviour that makes no
|
|
241 |
+ |
// claim, which is the same fallback the webview renderer's `auto` track
|
|
242 |
+ |
// is chosen to be.
|
|
243 |
+ |
_ => Track::remainder().at_least(sizing.length_for(column.name)),
|
|
244 |
+ |
}
|
|
245 |
+ |
}
|
|
246 |
+ |
|
|
247 |
+ |
/// A described table, narrowed for the width available.
|
|
248 |
+ |
///
|
|
249 |
+ |
/// `rows` is a count and `draw` is called once per cell of each kept column, in
|
|
250 |
+ |
/// column order. Taking a closure rather than a slice of contents is what keeps
|
|
251 |
+ |
/// the app's own data borrowed one cell at a time, which is
|
|
252 |
+ |
/// [`group`](crate::group)'s reasoning and immediate mode's habit.
|
|
253 |
+ |
///
|
|
254 |
+ |
/// Returns the sortable column whose heading was pressed this frame, if any. The
|
|
255 |
+ |
/// app owns the ordering, so this reports the press and changes nothing: what a
|
|
256 |
+ |
/// press *calls* is an address, and the description names none. That is
|
|
257 |
+ |
/// [`Column::sortable`]'s own documented split.
|
|
258 |
+ |
///
|
|
259 |
+ |
/// A heading is only pressable when its column says
|
|
260 |
+ |
/// [`sortable`](Column::sortable). A column sorted by a key the user cannot
|
|
261 |
+ |
/// change still draws its caret and does not answer.
|
|
262 |
+ |
pub fn table<'a>(
|
|
263 |
+ |
ui: &mut Ui,
|
|
264 |
+ |
columns: &'a [Column<'a>],
|
|
265 |
+ |
rows: usize,
|
|
266 |
+ |
sizing: &Sizing<'_>,
|
|
267 |
+ |
palette: &Palette,
|
|
268 |
+ |
style: &TableStyle,
|
|
269 |
+ |
mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize),
|
|
270 |
+ |
) -> Option<&'a Column<'a>> {
|
|
271 |
+ |
let cutoff = cutoff_for(columns, sizing, ui.available_width());
|
|
272 |
+ |
let kept: Vec<&'a Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
|
|
273 |
+ |
|
|
274 |
+ |
// egui_extras panics on a table with no tracks, and a description whose
|
|
275 |
+ |
// every column dropped is reachable: `kept_at` keeps the essential ones, and
|
|
276 |
+ |
// a table described with none at all has nothing to keep.
|
|
277 |
+ |
if kept.is_empty() {
|
|
278 |
+ |
return None;
|
|
279 |
+ |
}
|
|
280 |
+ |
|
|
281 |
+ |
let mut builder = TableBuilder::new(ui).striped(style.striped);
|
|
282 |
+ |
for column in &kept {
|
|
283 |
+ |
builder = builder.column(track(column, sizing));
|
|
284 |
+ |
}
|
|
285 |
+ |
|
|
286 |
+ |
// Written through a Cell rather than returned, because egui_extras hands the
|
|
287 |
+ |
// header and the body their own closures and neither can return a value past
|
|
288 |
+ |
// the other.
|
|
289 |
+ |
let pressed = std::cell::Cell::new(None::<&'a Column<'a>>);
|
|
290 |
+ |
|
|
291 |
+ |
builder
|
|
292 |
+ |
.header(style.header_height, |mut header| {
|
|
293 |
+ |
for column in &kept {
|
|
294 |
+ |
header.col(|ui| {
|
|
295 |
+ |
if press(ui, column, palette, style) {
|
|
296 |
+ |
pressed.set(Some(column));
|
|
297 |
+ |
}
|
|
298 |
+ |
});
|
|
299 |
+ |
}
|
|
300 |
+ |
})
|
|
301 |
+ |
.body(|body| {
|
|
302 |
+ |
body.rows(style.row_height, rows, |mut row| {
|
|
303 |
+ |
let index = row.index();
|
|
304 |
+ |
for column in &kept {
|
|
305 |
+ |
row.col(|ui| draw(ui, column, index));
|
|
306 |
+ |
}
|
|
307 |
+ |
});
|
|
308 |
+ |
});
|
|
309 |
+ |
|
|
310 |
+ |
pressed.get()
|
|
311 |
+ |
}
|
|
312 |
+ |
|
|
313 |
+ |
/// One heading, and whether it was pressed.
|
|
314 |
+ |
fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
|
|
315 |
+ |
let text = RichText::new(heading(column, style)).strong();
|
|
316 |
+ |
if !column.sortable {
|
|
317 |
+ |
// Muted, and not sensed. A heading a user cannot press must not look
|
|
318 |
+ |
// like one they can, which is the affordance `Column::sortable` exists
|
|
319 |
+ |
// to carry.
|
|
320 |
+ |
ui.label(text.color(palette.content_muted));
|
|
321 |
+ |
return false;
|
|
322 |
+ |
}
|
|
323 |
+ |
let tone = if column.sorted.is_some() {
|
|
324 |
+ |
palette.content
|
|
325 |
+ |
} else {
|
|
326 |
+ |
palette.content_muted
|
|
327 |
+ |
};
|
|
328 |
+ |
let response: Response = ui
|
|
329 |
+ |
.add(egui::Label::new(text.color(tone)).sense(Sense::click()))
|
|
330 |
+ |
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
|
331 |
+ |
response.clicked()
|
|
332 |
+ |
}
|
|
333 |
+ |
|
|
334 |
+ |
#[cfg(test)]
|
|
335 |
+ |
mod tests {
|
|
336 |
+ |
use super::*;
|
|
337 |
+ |
use egui::Color32;
|
|
338 |
+ |
|
|
339 |
+ |
fn palette() -> Palette {
|
|
340 |
+ |
Palette {
|
|
341 |
+ |
page: Color32::from_rgb(1, 1, 1),
|
|
342 |
+ |
raised: Color32::from_rgb(2, 2, 2),
|
|
343 |
+ |
overlay: Color32::from_rgb(3, 3, 3),
|
|
344 |
+ |
well: Color32::from_rgb(4, 4, 4),
|
|
345 |
+ |
sunken: Color32::from_rgb(5, 5, 5),
|
|
346 |
+ |
bevel_light: Color32::WHITE,
|
|
347 |
+ |
bevel_dark: Color32::BLACK,
|
|
348 |
+ |
elevation: Color32::from_black_alpha(46),
|
|
349 |
+ |
content: Color32::from_rgb(6, 6, 6),
|
|
350 |
+ |
content_muted: Color32::from_rgb(7, 7, 7),
|
|
351 |
+ |
action: Color32::from_rgb(8, 8, 8),
|
|
352 |
+ |
danger: Color32::from_rgb(9, 9, 9),
|
|
353 |
+ |
}
|
|
354 |
+ |
}
|
|
355 |
+ |
|
|
356 |
+ |
fn columns() -> Vec<Column<'static>> {
|
|
357 |
+ |
vec![
|
|
358 |
+ |
Column {
|
|
359 |
+ |
name: "name",
|
|
360 |
+ |
width: Width::Fill,
|
|
361 |
+ |
priority: Priority::Essential,
|
|
362 |
+ |
sortable: true,
|
|
363 |
+ |
sorted: Some(Sort::Ascending),
|
|
364 |
+ |
},
|
|
365 |
+ |
Column {
|
|
366 |
+ |
name: "size",
|
|
367 |
+ |
width: Width::Fixed,
|
|
368 |
+ |
priority: Priority::Secondary,
|
|
369 |
+ |
sortable: true,
|
|
370 |
+ |
sorted: None,
|
|
371 |
+ |
},
|
|
372 |
+ |
Column {
|
|
373 |
+ |
name: "note",
|
|
374 |
+ |
width: Width::Content,
|
|
375 |
+ |
priority: Priority::Optional,
|
|
376 |
+ |
sortable: false,
|
|
377 |
+ |
sorted: None,
|
|
378 |
+ |
},
|
|
379 |
+ |
]
|
|
380 |
+ |
}
|
|
381 |
+ |
|
|
382 |
+ |
fn sizing() -> Sizing<'static> {
|
|
383 |
+ |
Sizing {
|
|
384 |
+ |
lengths: &[("name", 120.0), ("size", 60.0), ("note", 80.0)],
|
|
385 |
+ |
fallback: 40.0,
|
|
386 |
+ |
}
|
|
387 |
+ |
}
|
|
388 |
+ |
|
|
389 |
+ |
#[test]
|
|
390 |
+ |
fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
|
|
391 |
+ |
let (cols, sz) = (columns(), sizing());
|
|
392 |
+ |
assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
|
|
393 |
+ |
assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
|
|
394 |
+ |
assert_eq!(cutoff_for(&cols, &sz, 150.0), Priority::Essential);
|
|
395 |
+ |
// Narrower than the essential column, which stays anyway.
|
|
396 |
+ |
assert_eq!(cutoff_for(&cols, &sz, 10.0), Priority::Essential);
|
|
397 |
+ |
}
|
|
398 |
+ |
|
|
399 |
+ |
#[test]
|
|
400 |
+ |
fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
|
|
401 |
+ |
// The goingson bug, as a test. `nth-child(n+5)` against a seven-column
|
|
402 |
+ |
// table hides whatever lands at position five, so inserting a column
|
|
403 |
+ |
// moves the cut onto a different column with nothing edited.
|
|
404 |
+ |
//
|
|
405 |
+ |
// Asserted at a fixed cutoff, because that is where the two ways of
|
|
406 |
+ |
// addressing a column disagree. A narrower budget SHOULD drop more; what
|
|
407 |
+ |
// must not change is which ones, for a given cutoff.
|
|
408 |
+ |
let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
|
|
409 |
+ |
cols.iter()
|
|
410 |
+ |
.filter(|c| !c.kept_at(cutoff))
|
|
411 |
+ |
.map(|c| c.name.to_owned())
|
|
412 |
+ |
.collect()
|
|
413 |
+ |
};
|
|
414 |
+ |
let before = columns();
|
|
415 |
+ |
let mut after = vec![Column {
|
|
416 |
+ |
name: "mark",
|
|
417 |
+ |
width: Width::Fixed,
|
|
418 |
+ |
priority: Priority::Essential,
|
|
419 |
+ |
sortable: false,
|
|
420 |
+ |
sorted: None,
|
|
421 |
+ |
}];
|
|
422 |
+ |
after.extend(columns());
|
|
423 |
+ |
|
|
424 |
+ |
for cutoff in CUTOFFS {
|
|
425 |
+ |
assert_eq!(dropped(&before, cutoff), dropped(&after, cutoff));
|
|
426 |
+ |
}
|
|
427 |
+ |
assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
|
|
428 |
+ |
}
|
|
429 |
+ |
|
|
430 |
+ |
#[test]
|
|
431 |
+ |
fn the_two_renderers_narrow_a_description_the_same_way() {
|
|
432 |
+ |
// The cutoff ladder is duplicated in `makeover-tui` because neither
|
|
433 |
+ |
// crate depends on the other, and duplication is what drifts. This is
|
|
434 |
+ |
// the assertion that would catch it: the ladder is the description's
|
|
435 |
+ |
// order, weakest first, and a tier added upstream belongs in both.
|
|
436 |
+ |
assert_eq!(CUTOFFS.len(), 3);
|
|
437 |
+ |
assert!(CUTOFFS.windows(2).all(|pair| pair[0] < pair[1]));
|
|
438 |
+ |
assert_eq!(CUTOFFS[0], Priority::Optional);
|
|
439 |
+ |
assert_eq!(CUTOFFS[2], Priority::Essential);
|
|
440 |
+ |
}
|
|
441 |
+ |
|
|
442 |
+ |
#[test]
|
|
443 |
+ |
fn a_content_column_is_measured_by_egui_and_budgeted_by_its_floor() {
|
|
444 |
+ |
// The split the module header names. The track defers to egui_extras,
|
|
445 |
+ |
// which can measure; the narrowing cannot wait for that and uses the
|
|
446 |
+ |
// declared floor. Both readings of the same column, and both honest.
|
|
447 |
+ |
let cols = columns();
|
|
448 |
+ |
let sz = sizing();
|
|
449 |
+ |
let note = &cols[2];
|
|
450 |
+ |
assert!(matches!(note.width, Width::Content));
|
|
451 |
+ |
assert!((min_width(note, &sz) - 80.0).abs() < f32::EPSILON);
|
|
452 |
+ |
// 120 + 60 + 80 is 260, so 300 fits and 250 does not.
|
|
453 |
+ |
assert!(fits(&cols, &sz, Priority::Optional, 300.0));
|
|
454 |
+ |
assert!(!fits(&cols, &sz, Priority::Optional, 250.0));
|
|
455 |
+ |
}
|
|
456 |
+ |
|
|
457 |
+ |
#[test]
|
|
458 |
+ |
fn a_column_with_no_length_of_its_own_takes_the_fallback() {
|
|
459 |
+ |
let column = Column {
|
|
460 |
+ |
name: "unlisted",
|
|
461 |
+ |
width: Width::Fixed,
|
|
462 |
+ |
priority: Priority::Essential,
|
|
463 |
+ |
sortable: false,
|
|
464 |
+ |
sorted: None,
|
|
465 |
+ |
};
|
|
466 |
+ |
assert!((min_width(&column, &sizing()) - 40.0).abs() < f32::EPSILON);
|
|
467 |
+ |
}
|
|
468 |
+ |
|
|
469 |
+ |
#[test]
|
|
470 |
+ |
fn the_parts_a_cell_can_be_are_coloured_apart() {
|
|
471 |
+ |
// The drift `CellPart` exists to end: one colour for a whole cell paints
|
|
472 |
+ |
// a control as though it were text.
|
|
473 |
+ |
let p = palette();
|
|
474 |
+ |
assert_eq!(part_color(Some(CellPart::Value), &p), p.content);
|
|
475 |
+ |
assert_eq!(part_color(Some(CellPart::Tokens), &p), p.content_muted);
|
|
476 |
+ |
assert_eq!(part_color(Some(CellPart::Actions), &p), p.action);
|
|
477 |
+ |
assert_eq!(part_color(Some(CellPart::Link), &p), p.action);
|
|
478 |
+ |
assert_ne!(part_color(Some(CellPart::Link), &p), p.content);
|
|
479 |
+ |
// A cell mixing parts says nothing, and takes the text colour.
|
|
480 |
+ |
assert_eq!(part_color(None, &p), p.content);
|
|
481 |
+ |
}
|
|
482 |
+ |
|
|
483 |
+ |
#[test]
|
|
484 |
+ |
fn the_ordered_column_draws_a_caret_and_the_others_do_not() {
|
|
485 |
+ |
let style = TableStyle::default();
|
|
486 |
+ |
let cols = columns();
|
|
487 |
+ |
assert_eq!(heading(&cols[0], &style), "name \u{25B2}");
|
|
488 |
+ |
assert_eq!(heading(&cols[1], &style), "size");
|
|
489 |
+ |
assert_eq!(heading(&cols[2], &style), "note");
|
|
490 |
+ |
}
|
|
491 |
+ |
|
|
492 |
+ |
#[test]
|
|
493 |
+ |
fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
|
|
494 |
+ |
// A list ordered by a key the user cannot change is a real thing to
|
|
495 |
+ |
// describe, which is why the description holds the two fields apart.
|
|
496 |
+ |
let column = Column {
|
|
497 |
+ |
name: "rank",
|
|
498 |
+ |
width: Width::Content,
|
|
499 |
+ |
priority: Priority::Essential,
|
|
500 |
+ |
sortable: false,
|