Skip to main content

tiny_table/
table.rs

1//! Table rendering primitives and formatting logic.
2//!
3//! The crate root re-exports the main types from this module, so most users
4//! will interact with [`Table`], [`Column`], [`Cell`], [`Trunc`], and
5//! [`Align`].
6//!
7//! The renderer works in three steps:
8//!
9//! 1. Measure content width for headers and rows.
10//! 2. Apply column width rules, truncation, and wrapping.
11//! 3. Emit a bordered table using the active terminal width when available.
12//!
13//! The public API is intentionally small. `Table` owns the rows, `Column`
14//! defines the schema, and `Cell` gives you per-value overrides when needed.
15
16#[cfg(feature = "style")]
17pub use crate::color::{Color, CustomColor};
18use std::cmp::Ordering;
19use std::collections::HashMap;
20use terminal_size::{Width, terminal_size};
21
22#[cfg(feature = "style")]
23mod style;
24mod text;
25
26#[cfg(feature = "style")]
27use style::{StyleAction, apply_style_actions, impl_style_methods};
28use text::{layout_line, split_lines, strip_ansi, truncate_line, visible_len};
29
30const ANSI_RESET: &str = "\x1b[0m";
31
32/// Characters used to draw table borders and joints.
33///
34/// The renderer currently uses the Unicode preset from [`TableStyle::unicode`].
35/// Section and separator rows can also use their own `TableStyle` snapshot,
36/// which lets you mix multiple visual themes in a single table.
37/// This type exists so the border characters are grouped in one place if you
38/// want to experiment with alternate themes or extend the renderer.
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub struct TableStyle {
41    /// Top-left corner character.
42    pub top_left: &'static str,
43    /// Top-right corner character.
44    pub top_right: &'static str,
45    /// Bottom-left corner character.
46    pub bottom_left: &'static str,
47    /// Bottom-right corner character.
48    pub bottom_right: &'static str,
49    /// Horizontal border character.
50    pub horiz: &'static str,
51    /// Vertical border character.
52    pub vert: &'static str,
53    /// Top border joint between columns.
54    pub top_joint: &'static str,
55    /// Left border character for section separators and middle rules.
56    pub mid_left: &'static str,
57    /// Right border character for section separators and middle rules.
58    pub mid_right: &'static str,
59    /// Joint character for interior separators.
60    pub mid_joint: &'static str,
61    /// Bottom border joint between columns.
62    pub bottom_joint: &'static str,
63}
64
65impl TableStyle {
66    /// Return the default Unicode border style.
67    pub fn unicode() -> Self {
68        TableStyle {
69            top_left: "┌",
70            top_right: "┐",
71            bottom_left: "└",
72            bottom_right: "┘",
73            horiz: "─",
74            vert: "│",
75            top_joint: "┬",
76            mid_left: "├",
77            mid_right: "┤",
78            mid_joint: "┼",
79            bottom_joint: "┴",
80        }
81    }
82
83    /// Convert a [`SectionStyle`] into a [`TableStyle`] by filling in the missing fields
84    pub fn from_section_style(section_style: SectionStyle) -> Self {
85        TableStyle {
86            horiz: section_style.horiz,
87            mid_left: section_style.mid_left,
88            mid_right: section_style.mid_right,
89            mid_joint: section_style.mid_joint,
90            ..TableStyle::unicode()
91        }
92    }
93}
94
95/// Characters used to overwrite section and separator styles.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub struct SectionStyle {
98    /// Horizontal border character.
99    pub horiz: &'static str,
100    /// Left border character for section separators and middle rules.
101    pub mid_left: &'static str,
102    /// Right border character for section separators and middle rules.
103    pub mid_right: &'static str,
104    /// Joint character for interior separators.
105    pub mid_joint: &'static str,
106}
107
108impl SectionStyle {
109    /// Return the default Unicode border style for sections and separators.
110    pub fn unicode() -> Self {
111        SectionStyle::from_table_style(TableStyle::unicode())
112    }
113
114    /// Convert a [`TableStyle`] into a [`SectionStyle`] by taking the relevant fields
115    pub fn from_table_style(table_style: TableStyle) -> Self {
116        SectionStyle {
117            horiz: table_style.horiz,
118            mid_left: table_style.mid_left,
119            mid_right: table_style.mid_right,
120            mid_joint: table_style.mid_joint,
121        }
122    }
123}
124
125/// How to handle content that does not fit inside the available column width.
126///
127/// Truncation is applied after the column width is resolved. If you need the
128/// full content to remain visible, use [`Trunc::NewLine`] to wrap onto more
129/// lines instead of clipping the text.
130///
131/// # Examples
132///
133/// ```rust
134/// use tiny_table::{Cell, Trunc};
135///
136/// let cell = Cell::new("abcdefghij").truncate(Trunc::Middle);
137/// ```
138#[allow(dead_code)]
139#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
140pub enum Trunc {
141    /// Keep the beginning of the text and add an ellipsis at the end.
142    #[default]
143    End,
144    /// Keep the end of the text and add an ellipsis at the start.
145    Start,
146    /// Keep the start and end of the text with an ellipsis in the middle.
147    Middle,
148    /// Wrap onto multiple lines instead of truncating.
149    NewLine,
150}
151
152/// Alignment options for any content supporting alignment.
153///
154/// # Examples
155///
156/// ```rust
157/// use tiny_table::{Align, Table};
158///
159/// let mut table = Table::new();
160/// table.add_section("Team").align(Align::Right);
161/// ```
162#[allow(dead_code)]
163#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
164pub enum Align {
165    /// Center the content within the available width.
166    #[default]
167    Center,
168    /// Place the content toward the left side of the available width.
169    Left,
170    /// Place the content toward the right side of the available width.
171    Right,
172}
173
174/// Width policy for a table column.
175///
176/// `Auto` keeps the column at content width unless a column-specific width is
177/// applied. `Fixed` uses an exact terminal-cell width. `Fraction` claims a
178/// proportional share of the available width. `Fill` consumes whatever width
179/// remains after fixed, content-based, and fractional columns are resolved.
180///
181/// # Examples
182///
183/// ```rust
184/// use tiny_table::ColumnWidth;
185///
186/// let fixed = ColumnWidth::fixed(12);
187/// let fraction = ColumnWidth::fraction(0.5);
188/// assert_eq!(fixed, ColumnWidth::Fixed(12));
189/// assert_eq!(fraction, ColumnWidth::Fraction(0.5));
190/// ```
191#[derive(Clone, Copy, Debug, PartialEq, Default)]
192pub enum ColumnWidth {
193    /// Size the column from its content and any explicit constraints.
194    #[default]
195    Auto,
196    /// Use an exact width in terminal cells.
197    Fixed(usize),
198    /// Allocate a fraction of the remaining available width.
199    Fraction(f64),
200    /// Fill the remaining available width.
201    Fill,
202}
203
204impl ColumnWidth {
205    /// Create a fixed-width column.
206    pub fn fixed(width: usize) -> Self {
207        Self::Fixed(width)
208    }
209
210    /// Create a fractional-width column.
211    pub fn fraction(fraction: f64) -> Self {
212        Self::Fraction(fraction)
213    }
214
215    /// Create a column that takes the remaining available width.
216    pub fn fill() -> Self {
217        Self::Fill
218    }
219}
220
221macro_rules! impl_column_width_from_int {
222    ($($ty:ty),* $(,)?) => {
223        $(impl From<$ty> for ColumnWidth {
224            fn from(width: $ty) -> Self {
225                Self::Fixed(width.max(0) as usize)
226            }
227        })*
228    };
229}
230
231impl_column_width_from_int!(
232    usize, u8, u16, u32, u64, u128, isize, i8, i16, i32, i64, i128
233);
234
235impl From<f32> for ColumnWidth {
236    fn from(fraction: f32) -> Self {
237        Self::Fraction(fraction as f64)
238    }
239}
240
241impl From<f64> for ColumnWidth {
242    fn from(fraction: f64) -> Self {
243        Self::Fraction(fraction)
244    }
245}
246
247/// Selector used to apply styling to a column.
248///
249/// [`Index`](ColumnTarget::Index) is zero-based. [`Header`](ColumnTarget::Header)
250/// matches the exact header text supplied to [`Column::new`].
251///
252/// # Examples
253///
254/// ```rust
255/// use tiny_table::ColumnTarget;
256///
257/// assert_eq!(ColumnTarget::from(0usize), ColumnTarget::Index(0));
258/// assert_eq!(ColumnTarget::from("Name"), ColumnTarget::Header("Name".to_string()));
259/// ```
260#[derive(Clone, Debug, Eq, PartialEq, Hash)]
261pub enum ColumnTarget {
262    /// Target a column by zero-based index.
263    Index(usize),
264    /// Target a column by its header text.
265    Header(String),
266}
267
268impl From<usize> for ColumnTarget {
269    fn from(index: usize) -> Self {
270        Self::Index(index)
271    }
272}
273
274impl From<&str> for ColumnTarget {
275    fn from(header: &str) -> Self {
276        Self::Header(header.to_string())
277    }
278}
279
280impl From<String> for ColumnTarget {
281    fn from(header: String) -> Self {
282        Self::Header(header)
283    }
284}
285
286/// A column definition that bundles the header text with default styling.
287///
288/// Use this when you already know your schema and want the table to inherit
289/// width, truncation, and color defaults from the column definition.
290///
291/// # Examples
292///
293/// ```rust
294/// use tiny_table::{Column, Color, Trunc};
295///
296/// let column = Column::new("Status")
297///     .color(Color::BrightGreen)
298///     .width(12)
299///     .truncate(Trunc::End);
300/// ```
301pub struct Column {
302    header: Cell,
303    style: ColumnStyle,
304}
305
306impl Column {
307    /// Create a new column definition from a header value.
308    pub fn new(header: impl Into<Cell>) -> Self {
309        Self {
310            header: header.into(),
311            style: ColumnStyle::default(),
312        }
313    }
314
315    /// Set the preferred width for this column.
316    pub fn width(mut self, width: impl Into<ColumnWidth>) -> Self {
317        self.style.width = Some(width.into());
318        self
319    }
320
321    /// Alias for [`Column::width`].
322    pub fn max_width(self, width: impl Into<ColumnWidth>) -> Self {
323        self.width(width)
324    }
325
326    /// Set the default truncation strategy used by this column.
327    pub fn truncate(mut self, truncation: Trunc) -> Self {
328        self.style.truncation = Some(truncation);
329        self
330    }
331
332    /// Set the default text alignment for this column.
333    pub fn align(mut self, align: Align) -> Self {
334        self.style.align = Some(align);
335        self
336    }
337}
338
339#[cfg(feature = "style")]
340impl_style_methods!(Column, |mut column: Column, action| {
341    column.style.styles.push(action);
342    column
343});
344
345#[derive(Clone, Debug, Default)]
346struct ColumnStyle {
347    #[cfg(feature = "style")]
348    styles: Vec<StyleAction>,
349    width: Option<ColumnWidth>,
350    truncation: Option<Trunc>,
351    align: Option<Align>,
352}
353
354impl ColumnStyle {
355    fn merge(&mut self, other: &ColumnStyle) {
356        #[cfg(feature = "style")]
357        self.styles.extend_from_slice(&other.styles);
358
359        if other.width.is_some() {
360            self.width = other.width;
361        }
362
363        if other.truncation.is_some() {
364            self.truncation = other.truncation;
365        }
366
367        if other.align.is_some() {
368            self.align = other.align;
369        }
370    }
371}
372
373/// A single table cell with optional styling overrides.
374///
375/// Cells are usually created from strings and then used in [`Table::add_row`].
376/// Any color or truncation set on the cell takes priority over the owning
377/// column's defaults.
378///
379/// # Examples
380///
381/// ```rust
382/// use tiny_table::{Cell, Color, Trunc};
383///
384/// let cell = Cell::new("warning")
385///     .color(Color::BrightRed)
386///     .truncate(Trunc::Middle);
387/// ```
388pub struct Cell {
389    content: String,
390    #[cfg(feature = "style")]
391    styles: Vec<StyleAction>,
392    truncation: Option<Trunc>,
393    align: Option<Align>,
394}
395
396struct PreparedCell {
397    lines: Vec<String>,
398    align: Align,
399}
400
401enum TableRow {
402    Cells(Vec<Cell>),
403    Section(SectionRow),
404}
405
406enum PreparedRow {
407    Cells(Vec<PreparedCell>),
408    Section(SectionRow),
409}
410
411#[derive(Clone, Debug)]
412struct SectionRow {
413    title: String,
414    align: Align,
415    style: TableStyle,
416}
417
418/// Builder returned by [`Table::add_section`] and [`Table::add_separator`].
419pub struct SectionBuilder<'a> {
420    table: &'a mut Table,
421    row_index: usize,
422}
423
424/// Builder returned by [`Table::column`] for per-column styling.
425///
426/// Use this to override color, width, or truncation for one column without
427/// changing the schema-wide defaults defined by [`Column`].
428pub struct ColumnBuilder<'a> {
429    table: &'a mut Table,
430    target: ColumnTarget,
431}
432
433impl Cell {
434    /// Create a new cell from display content.
435    pub fn new(content: impl ToString) -> Self {
436        Self {
437            content: content.to_string(),
438            #[cfg(feature = "style")]
439            styles: Vec::new(),
440            truncation: None,
441            align: None,
442        }
443    }
444
445    /// Set a truncation mode for this cell, overriding any column default.
446    #[must_use]
447    pub fn truncate(mut self, truncation: Trunc) -> Self {
448        self.truncation = Some(truncation);
449        self
450    }
451
452    /// Set the text alignment for this cell, overriding any column default.
453    #[must_use]
454    pub fn align(mut self, align: Align) -> Self {
455        self.align = Some(align);
456        self
457    }
458}
459
460#[cfg(feature = "style")]
461impl_style_methods!(Cell, |mut cell: Cell, action| {
462    cell.styles.push(action);
463    cell
464});
465
466impl From<&str> for Cell {
467    fn from(content: &str) -> Self {
468        Self::new(content)
469    }
470}
471
472impl From<String> for Cell {
473    fn from(content: String) -> Self {
474        Self::new(content)
475    }
476}
477
478/// Main table type for terminal output.
479///
480/// A table owns its headers, rows, and column styling. Content is measured at
481/// render time, so the output can adapt to the current terminal width without
482/// manual layout code from the caller.
483///
484/// # Typical Flow
485///
486/// 1. Build the schema with [`Table::with_columns`] or [`Table::new`].
487/// 2. Add rows with [`Table::add_row`].
488/// 3. Adjust columns with [`Table::column`] if you need per-column overrides.
489/// 4. Call [`Table::render`] or print the table directly.
490///
491/// # Example
492///
493/// ```rust
494/// use tiny_table::{Cell, Column, Align, Table, Trunc};
495///
496/// let mut table = Table::with_columns(vec![
497///     Column::new("Name").width(0.35),
498///     Column::new("Role").truncate(Trunc::Middle),
499///     Column::new("Status"),
500/// ]);
501///
502/// table.add_section("Team").align(Align::Center);
503/// table.add_row(vec![
504///     Cell::new("Ada Lovelace"),
505///     Cell::new("Principal Engineer"),
506///     Cell::new("Active"),
507/// ]);
508///
509/// let rendered = table.render();
510/// assert!(rendered.contains("Name"));
511/// assert!(rendered.contains("Ada Lovelace"));
512/// ```
513pub struct Table {
514    headers: Vec<Cell>,
515    rows: Vec<TableRow>,
516    column_defaults: Vec<ColumnStyle>,
517    column_overrides: HashMap<ColumnTarget, ColumnStyle>,
518    style: TableStyle,
519    section_style: Option<SectionStyle>,
520    separator_style: Option<SectionStyle>,
521}
522
523impl Table {
524    /// Create an empty table using the default Unicode border style.
525    pub fn new() -> Self {
526        Self {
527            headers: Vec::new(),
528            rows: Vec::new(),
529            column_defaults: Vec::new(),
530            column_overrides: HashMap::new(),
531            style: TableStyle::unicode(),
532            section_style: None,
533            separator_style: None,
534        }
535    }
536
537    /// Build a table from an explicit column schema.
538    ///
539    /// Each [`Column`] contributes the header text and the default formatting
540    /// for that column.
541    pub fn with_columns(columns: impl IntoIterator<Item = Column>) -> Self {
542        let mut table = Self::new();
543        table.set_columns(columns);
544        table
545    }
546
547    /// Replace the default border style for this table.
548    pub fn with_style(mut self, style: TableStyle) -> Self {
549        self.style = style;
550        self
551    }
552
553    /// Set the default style used for all section rows added with [`Table::add_section`].
554    ///
555    /// Individual sections can still override this by calling `.style()` on the
556    /// [`SectionBuilder`] returned by [`Table::add_section`].
557    pub fn with_section_style(mut self, style: SectionStyle) -> Self {
558        self.section_style = Some(style);
559        self
560    }
561
562    /// Set the default style used for all separator rows added with [`Table::add_separator`].
563    ///
564    /// Individual separators can still override this by calling `.style()` on the
565    /// [`SectionBuilder`] returned by [`Table::add_separator`].
566    pub fn with_separator_style(mut self, style: SectionStyle) -> Self {
567        self.separator_style = Some(style);
568        self
569    }
570
571    /// Replace the table schema with a new set of columns.
572    ///
573    /// This clears existing headers and column-specific overrides before the
574    /// new schema is applied.
575    pub fn set_columns(&mut self, columns: impl IntoIterator<Item = Column>) {
576        let (headers, column_defaults): (Vec<_>, Vec<_>) = columns
577            .into_iter()
578            .map(|Column { header, style }| (header, style))
579            .unzip();
580
581        self.headers = headers;
582        self.column_defaults = column_defaults;
583        self.column_overrides.clear();
584    }
585
586    /// Add a data row.
587    ///
588    /// Short rows are padded with empty cells. If a row has more cells than the
589    /// header list, the table expands to fit the additional columns.
590    pub fn add_row(&mut self, row: Vec<Cell>) {
591        self.rows.push(TableRow::Cells(row));
592    }
593
594    /// Add a full-width section separator inside the table.
595    ///
596    /// Section rows span the entire table width and are useful for grouping
597    /// related data visually. The returned builder currently lets you choose
598    /// the label alignment.
599    ///
600    /// If a default section style was set with [`Table::with_section_style`], it is
601    /// applied automatically. Call `.style()` on the returned builder to override it.
602    pub fn add_section(&mut self, title: impl ToString) -> SectionBuilder<'_> {
603        let row_index = self.rows.len();
604        let style = match self.section_style {
605            Some(s) => TableStyle::from_section_style(s),
606            None => self.style,
607        };
608        self.rows.push(TableRow::Section(SectionRow {
609            title: title.to_string(),
610            align: Align::Center,
611            style,
612        }));
613
614        SectionBuilder {
615            table: self,
616            row_index,
617        }
618    }
619
620    /// Add a full-width separator row with no label.
621    ///
622    /// If a default separator style was set with [`Table::with_separator_style`], it is
623    /// applied automatically. Call `.style()` on the returned builder to override it.
624    pub fn add_separator(&mut self) -> SectionBuilder<'_> {
625        let row_index = self.rows.len();
626        let style = match self.separator_style {
627            Some(s) => TableStyle::from_section_style(s),
628            None => self.style,
629        };
630        self.rows.push(TableRow::Section(SectionRow {
631            title: String::new(),
632            align: Align::Center,
633            style,
634        }));
635
636        SectionBuilder {
637            table: self,
638            row_index,
639        }
640    }
641
642    /// Configure a column using either its zero-based index or exact header text.
643    pub fn column<T: Into<ColumnTarget>>(&mut self, target: T) -> ColumnBuilder<'_> {
644        ColumnBuilder {
645            table: self,
646            target: target.into(),
647        }
648    }
649
650    /// Print the formatted table to standard output.
651    #[allow(dead_code)]
652    pub fn print(&self) {
653        for line in self.render_lines() {
654            println!("{line}");
655        }
656    }
657
658    /// Render the formatted table as a single string.
659    ///
660    /// The returned string includes ANSI color codes when styling is applied.
661    /// Fractional widths are resolved using the current terminal size when it
662    /// can be detected.
663    pub fn render(&self) -> String {
664        self.render_lines().join("\n")
665    }
666
667    fn column_style_mut(&mut self, target: ColumnTarget) -> &mut ColumnStyle {
668        self.column_overrides.entry(target).or_default()
669    }
670
671    fn column_style(&self, col: usize) -> ColumnStyle {
672        let mut style = self.column_defaults.get(col).cloned().unwrap_or_default();
673
674        if let Some(header) = self.headers.get(col)
675            && let Some(header_style) = self
676                .column_overrides
677                .get(&ColumnTarget::Header(strip_ansi(&header.content)))
678        {
679            style.merge(header_style);
680        }
681
682        if let Some(index_style) = self.column_overrides.get(&ColumnTarget::Index(col)) {
683            style.merge(index_style);
684        }
685
686        style
687    }
688
689    fn prepare_cell(
690        &self,
691        cell: Option<&Cell>,
692        column_style: &ColumnStyle,
693        width: usize,
694        #[cfg_attr(not(feature = "style"), allow(unused_variables))] is_header: bool,
695    ) -> PreparedCell {
696        let raw = cell.map(|c| c.content.as_str()).unwrap_or("");
697        let truncation = cell
698            .and_then(|c| c.truncation)
699            .or(column_style.truncation)
700            .unwrap_or(Trunc::End);
701        let align = cell
702            .and_then(|c| c.align)
703            .or(column_style.align)
704            .unwrap_or(Align::Left);
705
706        #[cfg(feature = "style")]
707        let styled = {
708            // Merge styles: column first, then cell overrides (last color wins in
709            // the flat ANSI prefix built by apply_style_actions).
710            let mut all_styles = column_style.styles.clone();
711            if let Some(cell) = cell {
712                all_styles.extend_from_slice(&cell.styles);
713            }
714            if is_header {
715                all_styles.push(StyleAction::Bold);
716            }
717            apply_style_actions(raw, &all_styles)
718        };
719        #[cfg(not(feature = "style"))]
720        let styled = raw.to_string();
721
722        let lines = split_lines(&styled)
723            .into_iter()
724            .flat_map(|line| layout_line(&line, Some(width), truncation))
725            .collect();
726
727        PreparedCell { lines, align }
728    }
729
730    fn prepare_row(
731        &self,
732        row: &[Cell],
733        col_widths: &[usize],
734        column_styles: &[ColumnStyle],
735        is_header: bool,
736    ) -> Vec<PreparedCell> {
737        col_widths
738            .iter()
739            .zip(column_styles)
740            .enumerate()
741            .map(|(col, (&width, style))| self.prepare_cell(row.get(col), style, width, is_header))
742            .collect()
743    }
744
745    fn collect_content_widths(&self, col_count: usize) -> Vec<usize> {
746        let mut widths = vec![0usize; col_count];
747
748        let all_rows =
749            std::iter::once(self.headers.as_slice()).chain(self.rows.iter().filter_map(|row| {
750                match row {
751                    TableRow::Cells(cells) => Some(cells.as_slice()),
752                    TableRow::Section(_) => None,
753                }
754            }));
755
756        for row in all_rows {
757            for (col, cell) in row.iter().enumerate() {
758                for line in split_lines(&cell.content) {
759                    widths[col] = widths[col].max(visible_len(&line));
760                }
761            }
762        }
763
764        widths
765    }
766
767    fn resolve_column_widths(
768        &self,
769        content_widths: &[usize],
770        column_styles: &[ColumnStyle],
771        terminal_width: Option<usize>,
772    ) -> Vec<usize> {
773        let mut widths = content_widths.to_vec();
774        let mut fraction_columns = Vec::new();
775        let mut fill_columns = Vec::new();
776        let mut reserved_width = 0usize;
777
778        for (col, style) in column_styles.iter().enumerate() {
779            match style.width.unwrap_or_default() {
780                ColumnWidth::Auto => {
781                    reserved_width += widths[col];
782                }
783                ColumnWidth::Fixed(width) => {
784                    widths[col] = width;
785                    reserved_width += width;
786                }
787                ColumnWidth::Fraction(fraction) => {
788                    fraction_columns.push((col, fraction.max(0.0)));
789                }
790                ColumnWidth::Fill => {
791                    fill_columns.push(col);
792                }
793            }
794        }
795
796        let Some(terminal_width) = terminal_width else {
797            return widths;
798        };
799
800        let table_overhead = (3 * widths.len()) + 1;
801        let available_content_width = terminal_width.saturating_sub(table_overhead);
802        let remaining_width = available_content_width.saturating_sub(reserved_width);
803
804        if fraction_columns.is_empty() && fill_columns.is_empty() {
805            return widths;
806        }
807
808        let mut leftover = remaining_width;
809
810        if !fraction_columns.is_empty() {
811            let total_fraction: f64 = fraction_columns.iter().map(|(_, fraction)| *fraction).sum();
812            if total_fraction <= f64::EPSILON {
813                for (col, _) in fraction_columns {
814                    widths[col] = 0;
815                }
816            } else {
817                let fraction_budget = remaining_width;
818
819                let mut remainders = Vec::with_capacity(fraction_columns.len());
820                let mut assigned = 0usize;
821
822                for (col, fraction) in fraction_columns {
823                    let exact = if total_fraction <= 1.0 {
824                        (fraction_budget as f64) * fraction
825                    } else {
826                        (fraction_budget as f64) * fraction / total_fraction
827                    };
828                    let width = exact.floor() as usize;
829                    widths[col] = width;
830                    assigned += width;
831                    remainders.push((col, exact - width as f64));
832                }
833
834                leftover = leftover.saturating_sub(assigned);
835
836                if fill_columns.is_empty() {
837                    remainders.sort_by(|left, right| {
838                        right.1.partial_cmp(&left.1).unwrap_or(Ordering::Equal)
839                    });
840
841                    for (col, _) in remainders {
842                        if leftover == 0 {
843                            break;
844                        }
845
846                        widths[col] += 1;
847                        leftover -= 1;
848                    }
849                }
850            }
851        }
852
853        if !fill_columns.is_empty() {
854            let fill_count = fill_columns.len();
855            let fill_width = leftover / fill_count;
856            let mut fill_remainder = leftover % fill_count;
857
858            for col in fill_columns {
859                widths[col] = fill_width + usize::from(fill_remainder > 0);
860                fill_remainder = fill_remainder.saturating_sub(1);
861            }
862        }
863
864        widths
865    }
866
867    fn column_count(&self) -> usize {
868        let max_row_len = self
869            .rows
870            .iter()
871            .filter_map(|row| match row {
872                TableRow::Cells(cells) => Some(cells.len()),
873                TableRow::Section(_) => None,
874            })
875            .max()
876            .unwrap_or(0);
877
878        self.headers.len().max(max_row_len)
879    }
880
881    fn row_height(cells: &[PreparedCell]) -> usize {
882        cells.iter().map(|cell| cell.lines.len()).max().unwrap_or(1)
883    }
884
885    fn rule_line(
886        &self,
887        style: &TableStyle,
888        left: &str,
889        joint: &str,
890        right: &str,
891        col_widths: &[usize],
892    ) -> String {
893        let h = style.horiz;
894        let join = format!("{}{}{}", h, joint, h);
895        let inner = col_widths
896            .iter()
897            .map(|&width| h.repeat(width))
898            .collect::<Vec<_>>()
899            .join(&join);
900
901        format!("{}{}{}{}{}", left, h, inner, h, right)
902    }
903
904    fn push_row_lines(
905        &self,
906        lines: &mut Vec<String>,
907        cells: &[PreparedCell],
908        col_widths: &[usize],
909    ) {
910        for line_idx in 0..Self::row_height(cells) {
911            lines.push(self.render_row_line(cells, line_idx, col_widths));
912        }
913    }
914
915    fn render_row_line(
916        &self,
917        row: &[PreparedCell],
918        line_idx: usize,
919        col_widths: &[usize],
920    ) -> String {
921        let vertical = self.style.vert;
922        let rendered_cells: Vec<String> = row
923            .iter()
924            .enumerate()
925            .map(|(col, cell)| {
926                let raw = cell.lines.get(line_idx).map(String::as_str).unwrap_or("");
927                let padding = col_widths[col].saturating_sub(visible_len(raw));
928                match cell.align {
929                    Align::Left => format!("{}{}", raw, " ".repeat(padding)),
930                    Align::Right => format!("{}{}", " ".repeat(padding), raw),
931                    Align::Center => {
932                        let left_pad = padding / 2;
933                        let right_pad = padding - left_pad;
934                        format!("{}{}{}", " ".repeat(left_pad), raw, " ".repeat(right_pad))
935                    }
936                }
937            })
938            .collect();
939
940        format!(
941            "{} {} {}",
942            vertical,
943            rendered_cells.join(&format!(" {} ", vertical)),
944            vertical
945        )
946    }
947
948    fn render_section_line(&self, section: &SectionRow, col_widths: &[usize]) -> String {
949        let style = &section.style;
950
951        if section.title.trim().is_empty() {
952            return self.rule_line(
953                style,
954                style.mid_left,
955                style.mid_joint,
956                style.mid_right,
957                col_widths,
958            );
959        }
960
961        let total_inner = col_widths.iter().sum::<usize>() + 3 * col_widths.len() - 1;
962        let label = truncate_line(
963            &format!(" {} ", section.title),
964            Some(total_inner),
965            Trunc::End,
966        );
967        let label_len = label.chars().count();
968        let remaining = total_inner.saturating_sub(label_len);
969
970        let left_fill = match section.align {
971            Align::Left => 1,
972            Align::Center => remaining / 2,
973            Align::Right => remaining.saturating_sub(1),
974        };
975
976        let mut inner: Vec<char> = style.horiz.repeat(total_inner).chars().collect();
977        let joint = style.mid_joint.chars().next().unwrap_or('┼');
978        let mut cursor = 1;
979
980        for &w in col_widths.iter().take(col_widths.len().saturating_sub(1)) {
981            cursor += w + 1;
982            if cursor < inner.len() {
983                inner[cursor] = joint;
984            }
985            cursor += 2;
986        }
987
988        let prefix: String = inner[..left_fill].iter().collect();
989        let suffix: String = inner[left_fill + label_len..].iter().collect();
990
991        #[cfg(feature = "style")]
992        let bold_label = format!("\x1b[1m{}{}", label, ANSI_RESET);
993        #[cfg(not(feature = "style"))]
994        let bold_label = label;
995
996        format!(
997            "{}{}{}{}{}",
998            style.mid_left, prefix, bold_label, suffix, style.mid_right
999        )
1000    }
1001
1002    fn render_lines_with_terminal_width(&self, terminal_width: Option<usize>) -> Vec<String> {
1003        let col_count = self.column_count();
1004        if col_count == 0 {
1005            return Vec::new();
1006        }
1007
1008        let column_styles: Vec<ColumnStyle> =
1009            (0..col_count).map(|col| self.column_style(col)).collect();
1010        let content_widths = self.collect_content_widths(col_count);
1011        let col_widths =
1012            self.resolve_column_widths(&content_widths, &column_styles, terminal_width);
1013
1014        let prepared_header = (!self.headers.is_empty())
1015            .then(|| self.prepare_row(&self.headers, &col_widths, &column_styles, true));
1016        let prepared_rows: Vec<PreparedRow> = self
1017            .rows
1018            .iter()
1019            .map(|row| match row {
1020                TableRow::Cells(cells) => {
1021                    PreparedRow::Cells(self.prepare_row(cells, &col_widths, &column_styles, false))
1022                }
1023                TableRow::Section(section) => PreparedRow::Section(section.clone()),
1024            })
1025            .collect();
1026
1027        let mut lines = Vec::new();
1028
1029        lines.push(self.rule_line(
1030            &self.style,
1031            self.style.top_left,
1032            self.style.top_joint,
1033            self.style.top_right,
1034            &col_widths,
1035        ));
1036
1037        if let Some(header) = prepared_header.as_ref() {
1038            self.push_row_lines(&mut lines, header, &col_widths);
1039
1040            if prepared_rows.is_empty()
1041                || !matches!(prepared_rows.first(), Some(PreparedRow::Section(_)))
1042            {
1043                lines.push(self.rule_line(
1044                    &self.style,
1045                    self.style.mid_left,
1046                    self.style.mid_joint,
1047                    self.style.mid_right,
1048                    &col_widths,
1049                ));
1050            }
1051        }
1052
1053        for row in &prepared_rows {
1054            match row {
1055                PreparedRow::Cells(cells) => self.push_row_lines(&mut lines, cells, &col_widths),
1056                PreparedRow::Section(section) => {
1057                    lines.push(self.render_section_line(section, &col_widths))
1058                }
1059            }
1060        }
1061
1062        lines.push(self.rule_line(
1063            &self.style,
1064            self.style.bottom_left,
1065            self.style.bottom_joint,
1066            self.style.bottom_right,
1067            &col_widths,
1068        ));
1069
1070        lines
1071    }
1072
1073    fn render_lines(&self) -> Vec<String> {
1074        self.render_lines_with_terminal_width(
1075            terminal_size().map(|(Width(width), _)| width as usize),
1076        )
1077    }
1078}
1079
1080impl Default for Table {
1081    fn default() -> Self {
1082        Self::new()
1083    }
1084}
1085
1086impl std::fmt::Display for Table {
1087    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1088        for line in self.render_lines() {
1089            writeln!(f, "{line}")?;
1090        }
1091        Ok(())
1092    }
1093}
1094
1095impl<'a> SectionBuilder<'a> {
1096    /// Set the alignment for the section label.
1097    pub fn align(self, align: Align) -> Self {
1098        if let Some(TableRow::Section(section)) = self.table.rows.get_mut(self.row_index) {
1099            section.align = align;
1100        }
1101
1102        self
1103    }
1104
1105    /// Set the border style used when rendering this section or separator.
1106    pub fn style(self, style: SectionStyle) -> Self {
1107        if let Some(TableRow::Section(section)) = self.table.rows.get_mut(self.row_index) {
1108            section.style = TableStyle::from_section_style(style);
1109        }
1110
1111        self
1112    }
1113}
1114
1115impl<'a> ColumnBuilder<'a> {
1116    /// Set the default color for the selected column.
1117    #[cfg(feature = "style")]
1118    pub fn color(self, color: Color) -> Self {
1119        self.table
1120            .column_style_mut(self.target.clone())
1121            .styles
1122            .push(StyleAction::Color(color));
1123        self
1124    }
1125
1126    /// Set the preferred width for the selected column.
1127    pub fn width(self, width: impl Into<ColumnWidth>) -> Self {
1128        self.table.column_style_mut(self.target.clone()).width = Some(width.into());
1129        self
1130    }
1131
1132    /// Alias for [`ColumnBuilder::width`].
1133    pub fn max_width(self, width: impl Into<ColumnWidth>) -> Self {
1134        self.width(width)
1135    }
1136
1137    /// Set the truncation strategy for the selected column.
1138    pub fn truncate(self, truncation: Trunc) -> Self {
1139        self.table.column_style_mut(self.target.clone()).truncation = Some(truncation);
1140        self
1141    }
1142
1143    /// Set the text alignment for the selected column.
1144    pub fn align(self, align: Align) -> Self {
1145        self.table.column_style_mut(self.target.clone()).align = Some(align);
1146        self
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::text::strip_ansi;
1153    use super::*;
1154    #[cfg(feature = "style")]
1155    use crate::Color::BrightBlack;
1156
1157    impl Table {
1158        fn render_lines_for_test(&self, terminal_width: Option<usize>) -> Vec<String> {
1159            self.render_lines_with_terminal_width(terminal_width)
1160        }
1161    }
1162
1163    #[cfg(feature = "style")]
1164    #[test]
1165    fn cell_builders_are_chainable() {
1166        let cell = Cell::new("value")
1167            .color(BrightBlack)
1168            .truncate(Trunc::Middle);
1169
1170        assert!(matches!(
1171            cell.styles.as_slice(),
1172            [StyleAction::Color(BrightBlack)]
1173        ));
1174        assert_eq!(cell.truncation, Some(Trunc::Middle));
1175    }
1176
1177    #[cfg(feature = "style")]
1178    #[test]
1179    fn accepts_colorize_values_for_cells_and_headers() {
1180        let mut table = Table::with_columns(vec![
1181            Column::new("Status").bright_green().bold(),
1182            Column::new("Notes"),
1183        ]);
1184
1185        table.column("Status").width(10);
1186        table.add_row(vec![
1187            Cell::new("DefinitelyActive").bright_red().underline(),
1188            Cell::new("Ready"),
1189        ]);
1190
1191        let plain = plain_lines(&table);
1192
1193        assert!(plain[1].contains("Status"));
1194        assert!(plain[3].contains("Definitel…"));
1195    }
1196
1197    #[test]
1198    fn renders_multiline_headers_and_rows() {
1199        let mut table = Table::with_columns(vec![Column::new("Name\nAlias"), Column::new("Value")]);
1200        table.add_row(vec![Cell::new("alpha\nbeta"), Cell::new("1")]);
1201
1202        assert_eq!(
1203            plain_lines(&table),
1204            vec![
1205                "┌───────┬───────┐",
1206                "│ Name  │ Value │",
1207                "│ Alias │       │",
1208                "├───────┼───────┤",
1209                "│ alpha │ 1     │",
1210                "│ beta  │       │",
1211                "└───────┴───────┘",
1212            ]
1213        );
1214    }
1215
1216    #[test]
1217    fn renders_center_aligned_sections_inside_a_single_table() {
1218        assert_eq!(
1219            section_table_lines(Align::Center),
1220            expected_section_lines("├─── Alpha ────┤")
1221        );
1222    }
1223
1224    #[test]
1225    fn renders_left_aligned_sections_inside_a_single_table() {
1226        assert_eq!(
1227            section_table_lines(Align::Left),
1228            expected_section_lines("├─ Alpha ──────┤")
1229        );
1230    }
1231
1232    #[test]
1233    fn renders_right_aligned_sections_inside_a_single_table() {
1234        assert_eq!(
1235            section_table_lines(Align::Right),
1236            expected_section_lines("├────── Alpha ─┤")
1237        );
1238    }
1239
1240    #[test]
1241    fn renders_mid_joints_when_a_section_label_leaves_room() {
1242        let mut table =
1243            Table::with_columns(vec![Column::new("A"), Column::new("B"), Column::new("C")]);
1244        table.add_section("X");
1245        table.add_row(vec![Cell::new("1"), Cell::new("2"), Cell::new("3")]);
1246
1247        assert_eq!(
1248            plain_lines(&table),
1249            vec![
1250                "┌───┬───┬───┐",
1251                "│ A │ B │ C │",
1252                "├───┼ X ┼───┤",
1253                "│ 1 │ 2 │ 3 │",
1254                "└───┴───┴───┘",
1255            ]
1256        );
1257    }
1258
1259    #[test]
1260    fn sections_and_separators_can_use_their_own_styles() {
1261        let table_style = TableStyle {
1262            top_left: "╔",
1263            top_right: "╗",
1264            bottom_left: "╚",
1265            bottom_right: "╝",
1266            horiz: "═",
1267            vert: "║",
1268            top_joint: "╦",
1269            mid_left: "╠",
1270            mid_right: "╣",
1271            mid_joint: "╬",
1272            bottom_joint: "╩",
1273        };
1274        let section_style = SectionStyle::unicode();
1275        let separator_style = SectionStyle {
1276            horiz: "-",
1277            mid_left: "-",
1278            mid_right: "-",
1279            mid_joint: "-",
1280        };
1281
1282        let mut table = Table::with_columns(vec![Column::new("Name"), Column::new("Value")])
1283            .with_style(table_style);
1284
1285        table.add_section("Alpha").style(section_style);
1286        table.add_row(vec![Cell::new("a"), Cell::new("1")]);
1287        table.add_separator().style(separator_style);
1288        table.add_row(vec![Cell::new("b"), Cell::new("2")]);
1289
1290        let plain = plain_lines(&table);
1291
1292        assert!(plain[0].starts_with("╔"));
1293        assert!(plain[0].ends_with("╗"));
1294        assert_eq!(plain[2], "├─── Alpha ────┤");
1295        assert_eq!(plain[4], "----------------");
1296        assert!(plain[6].starts_with("╚"));
1297        assert!(plain[6].ends_with("╝"));
1298    }
1299
1300    #[test]
1301    fn applies_column_and_cell_truncation() {
1302        let mut table = Table::with_columns(vec![Column::new("Value"), Column::new("Other")]);
1303        table.column("Value").max_width(5).truncate(Trunc::Start);
1304        table.add_row(vec![Cell::new("abcdefghij"), Cell::new("z")]);
1305        table.add_row(vec![
1306            Cell::new("abcdefghij").truncate(Trunc::Middle),
1307            Cell::new("z"),
1308        ]);
1309
1310        assert_eq!(
1311            plain_lines(&table),
1312            vec![
1313                "┌───────┬───────┐",
1314                "│ Value │ Other │",
1315                "├───────┼───────┤",
1316                "│ …ghij │ z     │",
1317                "│ ab…ij │ z     │",
1318                "└───────┴───────┘",
1319            ]
1320        );
1321    }
1322
1323    #[cfg(feature = "style")]
1324    #[test]
1325    fn truncation_keeps_ellipsis_tight_and_colored() {
1326        let mut table = Table::with_columns(vec![Column::new("Name")]);
1327        table.column(0).max_width(14);
1328        table.add_row(vec![Cell::new("Cynthia \"CJ\" Lee").bright_red()]);
1329
1330        let rendered = table.render_lines_for_test(Some(40)).join("\n");
1331        let plain = strip_ansi(&rendered);
1332
1333        assert!(plain.contains("Cynthia \"CJ\"…"));
1334        assert!(!plain.contains("Cynthia \"CJ\" …"));
1335        assert!(rendered.contains("\x1b[91mCynthia \"CJ\"…\x1b[0m"));
1336    }
1337
1338    #[test]
1339    fn builds_columns_in_one_step() {
1340        let mut table = Table::with_columns(vec![
1341            Column::new("Name").width(0.3),
1342            Column::new("Age").width(0.15),
1343            Column::new("City").width(0.55),
1344        ]);
1345
1346        table.add_row(vec![
1347            Cell::new("Alice"),
1348            Cell::new("30"),
1349            Cell::new("New York"),
1350        ]);
1351
1352        let plain = table
1353            .render_lines_for_test(Some(40))
1354            .into_iter()
1355            .map(|line| strip_ansi(&line))
1356            .collect::<Vec<_>>();
1357
1358        assert_eq!(plain[0].chars().count(), 40);
1359        assert!(plain[1].contains("Name"));
1360        assert!(plain[1].contains("Age"));
1361        assert!(plain[1].contains("City"));
1362        assert!(plain[3].contains("Alice"));
1363    }
1364
1365    #[test]
1366    fn fill_columns_take_the_remainder_after_fractional_columns() {
1367        let mut table = Table::with_columns(vec![
1368            Column::new("Name").width(ColumnWidth::fill()),
1369            Column::new("Role").width(0.6),
1370            Column::new("Status").width(0.3),
1371        ]);
1372
1373        table.add_row(vec![
1374            Cell::new("Ada Lovelace"),
1375            Cell::new("Principal Engineer"),
1376            Cell::new("Active"),
1377        ]);
1378
1379        let plain = table
1380            .render_lines_for_test(Some(70))
1381            .into_iter()
1382            .map(|line| strip_ansi(&line))
1383            .collect::<Vec<_>>();
1384
1385        assert_eq!(plain[0].chars().count(), 70);
1386        assert_eq!(cell_widths(&plain[1]), vec![6, 36, 18]);
1387        assert_eq!(cell_widths(&plain[3]), vec![6, 36, 18]);
1388        assert!(plain[1].contains("Name"));
1389        assert!(plain[1].contains("Role"));
1390        assert!(plain[1].contains("Status"));
1391    }
1392
1393    fn cell_widths(line: &str) -> Vec<usize> {
1394        line.split('│')
1395            .filter(|segment| !segment.is_empty())
1396            .map(|segment| segment.chars().count().saturating_sub(2))
1397            .collect()
1398    }
1399
1400    #[test]
1401    fn renders_fractional_columns_against_terminal_width() {
1402        let mut table = Table::with_columns(vec![Column::new("Name"), Column::new("Value")]);
1403        table.column("Name").max_width(0.5);
1404        table.column("Value").max_width(0.5);
1405        table.add_row(vec![Cell::new("Alice"), Cell::new("123")]);
1406
1407        let lines = table.render_lines_for_test(Some(40));
1408        let plain = lines
1409            .iter()
1410            .map(|line| strip_ansi(line))
1411            .collect::<Vec<_>>();
1412
1413        assert_eq!(plain[0].chars().count(), 40);
1414        assert_eq!(plain.last().unwrap().chars().count(), 40);
1415        assert!(plain[1].contains("Name"));
1416        assert!(plain[3].contains("Alice"));
1417    }
1418
1419    #[test]
1420    fn newline_truncation_wraps_at_spaces_and_hard_breaks_when_needed() {
1421        let mut table = Table::with_columns(vec![Column::new("Value")]);
1422        table.column(0).max_width(8);
1423        table.add_row(vec![Cell::new("one two three").truncate(Trunc::NewLine)]);
1424        table.add_row(vec![Cell::new("abcdefghij").truncate(Trunc::NewLine)]);
1425
1426        assert_eq!(
1427            plain_lines(&table),
1428            vec![
1429                "┌──────────┐",
1430                "│ Value    │",
1431                "├──────────┤",
1432                "│ one two  │",
1433                "│ three    │",
1434                "│ abcdefgh │",
1435                "│ ij       │",
1436                "└──────────┘",
1437            ]
1438        );
1439    }
1440
1441    fn plain_lines(table: &Table) -> Vec<String> {
1442        table
1443            .render_lines()
1444            .into_iter()
1445            .map(|line| strip_ansi(&line))
1446            .collect()
1447    }
1448
1449    fn section_table_lines(align: Align) -> Vec<String> {
1450        let mut table = Table::with_columns(vec![Column::new("Name"), Column::new("Value")]);
1451        table.add_section("Alpha").align(align);
1452        table.add_row(vec![Cell::new("a"), Cell::new("1")]);
1453
1454        plain_lines(&table)
1455    }
1456
1457    fn expected_section_lines(section_line: &str) -> Vec<String> {
1458        vec![
1459            "┌──────┬───────┐".to_string(),
1460            "│ Name │ Value │".to_string(),
1461            section_line.to_string(),
1462            "│ a    │ 1     │".to_string(),
1463            "└──────┴───────┘".to_string(),
1464        ]
1465    }
1466}