Skip to main content

max / makeover-tui

0.14.0: draw a table, because 14 call sites already were The first structural widget here, and the first thing in this crate that draws content rather than a surface. Counted before building, which is what turned this from a guess into a decision. mnw-cli draws 13 tables across 11 screens and had written the mapping layer by hand in src/tui/widgets.rs; viewer draws a 14th and had written a smaller one. Two answers to a question this crate exists to answer once. spaghetti and buckets_of_money draw none. A mapping layer over ratatui's Table rather than a second table. ratatui lays the tracks out, draws a header, highlights a row and scrolls. The two things it has no answer for are what this module is: - Content measurement. No track sizes to what is in it, so Width::Content is measured from the cells and the heading. The heading counts, or a column called `duration` holding `3s` draws as `du`, and the caret counts with it, or the sorted column is two cells short of its own arrow. - Narrowing. The cutoff is raised until the layout fits and never past Essential, so a window too narrow leaves the identifying columns truncated rather than emptying the table. Nothing counts positions. That is the goingson nth-child(n+5) bug and it is asserted twice: the set dropped at a given cutoff is unchanged by inserting a column, and the kept set is closed upward at every width from 0 to 48. Sizing and Cell are makeover-webview's shapes with the magnitudes in cells instead of CSS lengths, and cells are ordered by the columns rather than by the row, so two renderers handed the same row cannot disagree about it. TableStyle sits beside Palette rather than in it, the way FieldStyle does in makeover-immediate: Palette answers what a surface is, and a consumer that only paints a bevel should not have to supply six text tones. Its from_theme is the lift, selection carried by the background alone included, which is mnw-cli's own comment turned into library code.
Author: Max Johnson <me@maxj.phd> · 2026-08-11 20:09 UTC
Signed with PGP, not checked
Commit: e64340edfe691b08eaef475334689bcbef10d54f
Parent: 9e61c72
3 files changed, +523 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.13.0"
3 + version = "0.14.0"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
M src/lib.rs +22
@@ -77,6 +77,20 @@
77 77 //! does not change and nothing on disk grows, and the promotion stays available
78 78 //! for a theme that ever needs to tune either.
79 79 //!
80 + //! # 0.14.0: the first structural widget
81 + //!
82 + //! [`table`] is the first thing here that draws content rather than a surface,
83 + //! and it exists because 14 call sites across `mnw-cli` and `viewer` were
84 + //! already drawing one. `mnw-cli` had written the mapping layer by hand
85 + //! (`src/tui/widgets.rs`: a muted bold header, a selected row carried by the
86 + //! background alone) and `viewer` had written a smaller one, which is two
87 + //! answers to a question this crate is supposed to answer once.
88 + //!
89 + //! It is a mapping layer over [`ratatui::widgets::Table`] rather than a table
90 + //! implementation, because ratatui already lays tracks out, draws a header,
91 + //! highlights a row and scrolls. What it has no answer for is content
92 + //! measurement and narrowing, and those are what the module is.
93 + //!
80 94 //! # The correction this renderer forced
81 95 //!
82 96 //! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
@@ -116,6 +130,14 @@
116 130 #[cfg(feature = "theme")]
117 131 pub use theme::{Mode, Quantize, Theme, ThemeError};
118 132
133 + /// Columns, narrowing, cell parts and the sort caret, over ratatui's own
134 + /// [`Table`](ratatui::widgets::Table).
135 + ///
136 + /// Not feature-gated. It needs no theme: [`TableStyle`](table::TableStyle)
137 + /// carries the tones, and a caller with a loaded theme gets them from
138 + /// `TableStyle::from_theme` instead of supplying them.
139 + pub mod table;
140 +
119 141 /// How many colours the terminal can actually show.
120 142 ///
121 143 /// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
A src/table.rs +500
@@ -1,0 +1,816 @@
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,
Lines truncated