Tables

Multi-column data displays with sortable headers, row selection, and flexible layouts.

View::table()
    .column("Name")
    .column("Status")
    .column("Value")
    .rows(data)
    .selected(selected.get())
    .on_select(with!(selected => move |idx| selected.set(idx)))
    .build()

Run with: cargo run -p telex-tui --example 17_table

Basic table

A table displays rows of data in aligned columns:

let rows = vec![
    vec!["Alice".to_string(), "Active".to_string(), "100".to_string()],
    vec!["Bob".to_string(), "Inactive".to_string(), "50".to_string()],
    vec!["Carol".to_string(), "Active".to_string(), "75".to_string()],
];

View::table()
    .column("Name")
    .column("Status")
    .column("Score")
    .rows(rows)
    .build()

Each row is a Vec<String>. The number of items in each row should match the number of columns.

Column configuration

Use .column() for simple headers or .column_with() for advanced configuration:

View::table()
    .column("Name")  // flexible width, left-aligned
    .column_with(
        TableColumn::new("Status")
            .width(ColumnWidth::Fixed(12))
    )
    .column_with(
        TableColumn::new("Score")
            .width(ColumnWidth::Fixed(8))
            .align(TextAlign::Right)
    )
    .rows(data)
    .build()

Column width

Fixed width: Exact number of characters

.column_with(TableColumn::new("ID").width(ColumnWidth::Fixed(8)))

Flex width: Proportional share of remaining space

.column_with(TableColumn::new("Description").width(ColumnWidth::Flex(2)))

If you don't specify width, columns default to flexible with equal weight.

Text alignment

.column_with(
    TableColumn::new("Count")
        .align(TextAlign::Right)  // right-align numbers
)

Options: TextAlign::Left (default), TextAlign::Right, TextAlign::Center.

Row selection

Track which row is selected:

let selected = state!(cx, || 0usize);

View::table()
    .column("Name")
    .column("Value")
    .rows(data)
    .selected(selected.get())
    .on_select(with!(selected => move |idx: usize| selected.set(idx)))
    .build()

Users navigate with arrow keys. on_select fires when selection changes.

Row activation

Handle Enter key on a selected row:

let on_activate = with!(selected, data => move |idx: usize| {
    if let Some(row) = data.get(idx) {
        // Do something with the selected row
        show_details(row);
    }
});

View::table()
    .rows(data.get())
    .selected(selected.get())
    .on_select(on_select)
    .on_activate(on_activate)
    .build()

Common use: open a detail view or edit modal for the selected item.

Sorting

Tables support sortable columns. You manage the sort state and re-sort data yourself:

// Track sort: (column_index, ascending)
let sort_state = state!(cx, || None::<(usize, bool)>);

// Sort your data based on state
let mut rows = base_data.clone();
if let Some((col, ascending)) = sort_state.get() {
    rows.sort_by(|a, b| {
        let a_val = a.get(col).unwrap_or(&String::new());
        let b_val = b.get(col).unwrap_or(&String::new());
        if ascending {
            a_val.cmp(b_val)
        } else {
            b_val.cmp(a_val)
        }
    });
}

let on_sort = with!(sort_state => move |col: usize, asc: bool| {
    sort_state.set(Some((col, asc)));
});

View::table()
    .rows(rows)
    .sort(sort_state.get())
    .on_sort(on_sort)
    .build()

The table widget displays sort indicators (▲/▼) in headers. You handle the actual sorting logic.

Numeric sorting

For numeric columns, parse before comparing:

rows.sort_by(|a, b| {
    let a_num = a[col].parse::<i32>().unwrap_or(0);
    let b_num = b[col].parse::<i32>().unwrap_or(0);
    if ascending {
        a_num.cmp(&b_num)
    } else {
        b_num.cmp(&a_num)
    }
});

Custom data types

Convert your structs to rows:

#[derive(Clone)]
struct Pod {
    name: String,
    status: String,
    cpu: String,
    memory: String,
}

let pods = vec![
    Pod { name: "nginx".into(), status: "Running".into(), cpu: "12%".into(), memory: "256Mi".into() },
    Pod { name: "redis".into(), status: "Running".into(), cpu: "8%".into(), memory: "128Mi".into() },
];

// Convert to table rows
let rows: Vec<Vec<String>> = pods.iter()
    .map(|p| vec![p.name.clone(), p.status.clone(), p.cpu.clone(), p.memory.clone()])
    .collect();

View::table()
    .column("Name")
    .column("Status")
    .column("CPU")
    .column("Memory")
    .rows(rows)
    .build()

Column layout example

Mix fixed and flexible columns:

View::table()
    .column_with(TableColumn::new("ID").width(ColumnWidth::Fixed(6)))
    .column("Description")  // flex (fills remaining space)
    .column_with(TableColumn::new("Count").width(ColumnWidth::Fixed(8)).align(TextAlign::Right))
    .column_with(TableColumn::new("Status").width(ColumnWidth::Fixed(10)))
    .rows(data)
    .build()

Result: ID takes 6 chars, Count takes 8, Status takes 10, Description fills the rest.

Empty state

Show a message when there's no data:

if data.is_empty() {
    View::styled_text("No data available").dim().build()
} else {
    View::table()
        .column("Name")
        .column("Value")
        .rows(data)
        .build()
}

Real-world example: Kubernetes pods

From example 17:

let pods = vec![
    vec!["nginx-pod".into(), "Running".into(), "12%".into(), "256Mi".into(), "2h".into()],
    vec!["redis-cache".into(), "Running".into(), "8%".into(), "128Mi".into(), "5d".into()],
    vec!["api-server".into(), "Running".into(), "45%".into(), "512Mi".into(), "1h".into()],
];

View::table()
    .column("NAME")
    .column_with(TableColumn::new("STATUS").width(ColumnWidth::Fixed(12)))
    .column_with(TableColumn::new("CPU").width(ColumnWidth::Fixed(8)).align(TextAlign::Right))
    .column_with(TableColumn::new("MEMORY").width(ColumnWidth::Fixed(10)).align(TextAlign::Right))
    .column_with(TableColumn::new("AGE").width(ColumnWidth::Fixed(8)).align(TextAlign::Right))
    .rows(pods)
    .selected(selected.get())
    .sort(sort_state.get())
    .on_select(on_select)
    .on_sort(on_sort)
    .on_activate(on_activate)
    .build()

This creates a k9s-style pod dashboard with sortable columns and navigation.

Table vs List

Use Table when:

  • Data has multiple attributes (columns)
  • Alignment matters (numbers, dates)
  • Sorting by different fields is needed
  • Comparing values across rows

Use List when:

  • Single attribute per item
  • Items are simple text
  • Order is fixed or chronological
  • No column alignment needed

See Lists for simple collections.

Scrolling

Tables automatically scroll when content exceeds viewport height. The header remains visible while scrolling through rows.

Performance

Tables render all visible rows. For thousands of rows, consider:

  • Pagination (show 50-100 rows at a time)
  • Virtual scrolling (advanced)
  • Filtering to reduce row count

For typical use (hundreds of rows), performance is fine.

Common patterns

Status indicators with color:

let rows: Vec<Vec<String>> = data.iter().map(|item| {
    let status = if item.is_active {
        "✓ Active".to_string()
    } else {
        "✗ Inactive".to_string()
    };
    vec![item.name.clone(), status, item.count.to_string()]
}).collect();

Showing selected row details:

let selected_data = data.get(selected.get()).cloned();

View::vstack()
    .child(table_view)
    .child(if let Some(item) = selected_data {
        View::text(format!("Details: {:?}", item))
    } else {
        View::text("Select a row")
    })
    .build()

Multi-column sorting (secondary sort):

rows.sort_by(|a, b| {
    // Primary sort
    let primary = if ascending {
        a[col].cmp(&b[col])
    } else {
        b[col].cmp(&a[col])
    };

    // Secondary sort (always by first column)
    if primary == std::cmp::Ordering::Equal {
        a[0].cmp(&b[0])
    } else {
        primary
    }
});

Formatting numbers:

let rows: Vec<Vec<String>> = data.iter().map(|item| {
    vec![
        item.name.clone(),
        format!("{:>8}", item.count),  // right-aligned with padding
        format!("{:.2}%", item.percent),  // two decimal places
    ]
}).collect();

Tips

Column count must match - Every row should have the same number of items as you have columns. Extra items are ignored, missing items show as empty.

Right-align numbers - Use TextAlign::Right for numeric columns. It's easier to compare values.

Fixed width for predictable columns - Status, dates, IDs usually have known max width. Use ColumnWidth::Fixed.

Flex for variable content - Names, descriptions, messages should use flexible width to utilize available space.

Sort state is optional - If you don't provide .sort(), the table won't show sort indicators. Sorting is purely visual feedback.

Selection is zero-indexed - First row is index 0. Check bounds when accessing data based on selection.

Headers are always visible - When scrolling, the header row stays at the top.

Unicode in cells works - Emojis, box drawing, CJK characters all work. The table handles grapheme cluster widths correctly. Note: Complex grapheme clusters (emoji with modifiers, combining characters) may impact rendering performance in very large tables (1000+ rows).

Next: Trees