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