Remove const generics from data table (runtime column count support) (#46341)

Oleksandr Kholiavko and Anthony Eid created

This PR removes the const generics limitation from the data table
component, enabling tables with a dynamic number of columns determined
at runtime.

**Context:**  
This is the next infrastructure step split out from the [original CSV
preview draft PR](https://github.com/zed-industries/zed/pull/44344). The
draft PR remains open as a reference and will continue to be decomposed
into smaller, reviewable pieces.

**Details:**  
- Introduces a `TableRow` newtype to enforce column count invariants at
runtime, replacing the previous const generic approach. It's api surface
is larger than is currently used, as it's planned to be used in CSV
feature itself.
- Refactors the data table and all usages (including the keymap editor)
to work with runtime column counts.
- This change is foundational for supporting CSV preview and other
features that require flexible, dynamic tables.
- Performance impact has not been formally measured, but there is no
noticeable slowdown in practice.

---

Release Notes:
- N/A (internal infrastructure change, no user impact)

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

crates/edit_prediction_ui/src/edit_prediction_context_view.rs |   9 
crates/keymap_editor/src/keymap_editor.rs                     |  15 
crates/ui/src/components/data_table.rs                        | 620 +++-
3 files changed, 461 insertions(+), 183 deletions(-)

Detailed changes

crates/edit_prediction_ui/src/edit_prediction_context_view.rs 🔗

@@ -243,11 +243,14 @@ impl EditPredictionContextView {
             .gap_2()
             .child(v_flex().h_full().flex_1().child({
                 let t0 = run.started_at;
-                let mut table = ui::Table::<2>::new().width(ui::px(300.)).no_ui_font();
+                let mut table = ui::Table::new(2).width(ui::px(300.)).no_ui_font();
                 for (key, value) in &run.metadata {
-                    table = table.row([key.into_any_element(), value.clone().into_any_element()])
+                    table = table.row(vec![
+                        key.into_any_element(),
+                        value.clone().into_any_element(),
+                    ])
                 }
-                table = table.row([
+                table = table.row(vec![
                     "Total Time".into_any_element(),
                     format!("{} ms", (run.finished_at.unwrap_or(t0) - t0).as_millis())
                         .into_any_element(),

crates/keymap_editor/src/keymap_editor.rs 🔗

@@ -54,6 +54,7 @@ use crate::{
 };
 
 const NO_ACTION_ARGUMENTS_TEXT: SharedString = SharedString::new_static("<no arguments>");
+const COLS: usize = 6;
 
 actions!(
     keymap_editor,
@@ -428,7 +429,7 @@ struct KeymapEditor {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     previous_edit: Option<PreviousEdit>,
     humanized_action_names: HumanizedActionNameCache,
-    current_widths: Entity<TableColumnWidths<6>>,
+    current_widths: Entity<TableColumnWidths>,
     show_hover_menus: bool,
     actions_with_schemas: HashSet<&'static str>,
     /// In order for the JSON LSP to run in the actions arguments editor, we
@@ -560,7 +561,7 @@ impl KeymapEditor {
             actions_with_schemas: HashSet::default(),
             action_args_temp_dir: None,
             action_args_temp_dir_worktree: None,
-            current_widths: cx.new(|cx| TableColumnWidths::new(cx)),
+            current_widths: cx.new(|cx| TableColumnWidths::new(COLS, cx)),
         };
 
         this.on_keymap_changed(window, cx);
@@ -1914,14 +1915,14 @@ impl Render for KeymapEditor {
                     ),
             )
             .child(
-                Table::new()
+                Table::new(COLS)
                     .interactable(&self.table_interaction_state)
                     .striped()
                     .empty_table_callback({
                         let this = cx.entity();
                         move |window, cx| this.read(cx).render_no_matches_hint(window, cx)
                     })
-                    .column_widths([
+                    .column_widths(vec![
                         DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))),
                         DefiniteLength::Fraction(0.25),
                         DefiniteLength::Fraction(0.20),
@@ -1930,7 +1931,7 @@ impl Render for KeymapEditor {
                         DefiniteLength::Fraction(0.08),
                     ])
                     .resizable_columns(
-                        [
+                        vec![
                             TableResizeBehavior::None,
                             TableResizeBehavior::Resizable,
                             TableResizeBehavior::Resizable,
@@ -1941,7 +1942,7 @@ impl Render for KeymapEditor {
                         &self.current_widths,
                         cx,
                     )
-                    .header(["", "Action", "Arguments", "Keystrokes", "Context", "Source"])
+                    .header(vec!["", "Action", "Arguments", "Keystrokes", "Context", "Source"])
                     .uniform_list(
                         "keymap-editor-table",
                         row_count,
@@ -2051,7 +2052,7 @@ impl Render for KeymapEditor {
                                         .unwrap_or_default()
                                         .into_any_element();
 
-                                    Some([
+                                    Some(vec![
                                         icon.into_any_element(),
                                         action,
                                         action_arguments,

crates/ui/src/components/data_table.rs 🔗

@@ -12,37 +12,246 @@ use crate::{
     InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
     ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled,
     StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex,
-    px, single_example, v_flex,
+    px, single_example,
+    table_row::{IntoTableRow as _, TableRow},
+    v_flex,
 };
 use itertools::intersperse_with;
 
+pub mod table_row {
+    //! A newtype for a table row that enforces a fixed column count at runtime.
+    //!
+    //! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths.
+    //! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime.
+    //! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec<T>`, without requiring const generics.
+
+    use std::{
+        any::type_name,
+        ops::{
+            Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive,
+        },
+    };
+
+    #[derive(Clone, Debug, PartialEq, Eq)]
+    pub struct TableRow<T>(Vec<T>);
+
+    impl<T> TableRow<T> {
+        /// Constructs a `TableRow` from a `Vec<T>`, panicking if the length does not match `expected_length`.
+        ///
+        /// Use this when you want to ensure at construction time that the row has the correct number of columns.
+        /// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows.
+        ///
+        /// # Panics
+        /// Panics if `data.len() != expected_length`.
+        pub fn from_vec(data: Vec<T>, expected_length: usize) -> Self {
+            Self::try_from_vec(data, expected_length).unwrap_or_else(|e| {
+                let name = type_name::<Vec<T>>();
+                panic!("Expected {name} to be created successfully: {e}");
+            })
+        }
+
+        /// Attempts to construct a `TableRow` from a `Vec<T>`, returning an error if the length does not match `expected_len`.
+        ///
+        /// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully.
+        /// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise.
+        pub fn try_from_vec(data: Vec<T>, expected_len: usize) -> Result<Self, String> {
+            if data.len() != expected_len {
+                Err(format!(
+                    "Row length {} does not match expected {}",
+                    data.len(),
+                    expected_len
+                ))
+            } else {
+                Ok(Self(data))
+            }
+        }
+
+        /// Returns reference to element by column index.
+        ///
+        /// # Panics
+        /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`).
+        pub fn expect_get(&self, col: usize) -> &T {
+            self.0.get(col).unwrap_or_else(|| {
+                panic!(
+                    "Expected table row of `{}` to have {col:?}",
+                    type_name::<T>()
+                )
+            })
+        }
+
+        pub fn get(&self, col: usize) -> Option<&T> {
+            self.0.get(col)
+        }
+
+        pub fn as_slice(&self) -> &[T] {
+            &self.0
+        }
+
+        pub fn into_vec(self) -> Vec<T> {
+            self.0
+        }
+
+        /// Like [`map`], but borrows the row and clones each element before mapping.
+        ///
+        /// This is useful when you want to map over a borrowed row without consuming it,
+        /// but your mapping function requires ownership of each element.
+        ///
+        /// # Difference
+        /// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`.
+        /// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row.
+        /// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element.
+        pub fn map_cloned<F, U>(&self, f: F) -> TableRow<U>
+        where
+            F: FnMut(T) -> U,
+            T: Clone,
+        {
+            self.clone().map(f)
+        }
+
+        /// Consumes the row and transforms all elements within it in a length-safe way.
+        ///
+        /// # Difference
+        /// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element.
+        /// - Use this when you want to transform and consume the row in one step.
+        /// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references).
+        pub fn map<F, U>(self, f: F) -> TableRow<U>
+        where
+            F: FnMut(T) -> U,
+        {
+            TableRow(self.0.into_iter().map(f).collect())
+        }
+
+        /// Borrows the row and transforms all elements by reference in a length-safe way.
+        ///
+        /// # Difference
+        /// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference.
+        /// - Use this when you want to map over a borrowed row without cloning or consuming it.
+        /// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning).
+        pub fn map_ref<F, U>(&self, f: F) -> TableRow<U>
+        where
+            F: FnMut(&T) -> U,
+        {
+            TableRow(self.0.iter().map(f).collect())
+        }
+
+        /// Number of columns (alias to `len()` with more semantic meaning)
+        pub fn cols(&self) -> usize {
+            self.0.len()
+        }
+    }
+
+    ///// Convenience traits /////
+    pub trait IntoTableRow<T> {
+        fn into_table_row(self, expected_length: usize) -> TableRow<T>;
+    }
+    impl<T> IntoTableRow<T> for Vec<T> {
+        fn into_table_row(self, expected_length: usize) -> TableRow<T> {
+            TableRow::from_vec(self, expected_length)
+        }
+    }
+
+    // Index implementations for convenient access
+    impl<T> Index<usize> for TableRow<T> {
+        type Output = T;
+
+        fn index(&self, index: usize) -> &Self::Output {
+            &self.0[index]
+        }
+    }
+
+    impl<T> IndexMut<usize> for TableRow<T> {
+        fn index_mut(&mut self, index: usize) -> &mut Self::Output {
+            &mut self.0[index]
+        }
+    }
+
+    // Range indexing implementations for slice operations
+    impl<T> Index<Range<usize>> for TableRow<T> {
+        type Output = [T];
+
+        fn index(&self, index: Range<usize>) -> &Self::Output {
+            <Vec<T> as Index<Range<usize>>>::index(&self.0, index)
+        }
+    }
+
+    impl<T> Index<RangeFrom<usize>> for TableRow<T> {
+        type Output = [T];
+
+        fn index(&self, index: RangeFrom<usize>) -> &Self::Output {
+            <Vec<T> as Index<RangeFrom<usize>>>::index(&self.0, index)
+        }
+    }
+
+    impl<T> Index<RangeTo<usize>> for TableRow<T> {
+        type Output = [T];
+
+        fn index(&self, index: RangeTo<usize>) -> &Self::Output {
+            <Vec<T> as Index<RangeTo<usize>>>::index(&self.0, index)
+        }
+    }
+
+    impl<T> Index<RangeToInclusive<usize>> for TableRow<T> {
+        type Output = [T];
+
+        fn index(&self, index: RangeToInclusive<usize>) -> &Self::Output {
+            <Vec<T> as Index<RangeToInclusive<usize>>>::index(&self.0, index)
+        }
+    }
+
+    impl<T> Index<RangeFull> for TableRow<T> {
+        type Output = [T];
+
+        fn index(&self, index: RangeFull) -> &Self::Output {
+            <Vec<T> as Index<RangeFull>>::index(&self.0, index)
+        }
+    }
+
+    impl<T> Index<RangeInclusive<usize>> for TableRow<T> {
+        type Output = [T];
+
+        fn index(&self, index: RangeInclusive<usize>) -> &Self::Output {
+            <Vec<T> as Index<RangeInclusive<usize>>>::index(&self.0, index)
+        }
+    }
+
+    impl<T> IndexMut<RangeInclusive<usize>> for TableRow<T> {
+        fn index_mut(&mut self, index: RangeInclusive<usize>) -> &mut Self::Output {
+            <Vec<T> as IndexMut<RangeInclusive<usize>>>::index_mut(&mut self.0, index)
+        }
+    }
+}
+
 const RESIZE_COLUMN_WIDTH: f32 = 8.0;
 
+/// Represents an unchecked table row, which is a vector of elements.
+/// Will be converted into `TableRow<T>` internally
+pub type UncheckedTableRow<T> = Vec<T>;
+
 #[derive(Debug)]
 struct DraggedColumn(usize);
 
-struct UniformListData<const COLS: usize> {
+struct UniformListData {
     render_list_of_rows_fn:
-        Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>,
+        Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<UncheckedTableRow<AnyElement>>>,
     element_id: ElementId,
     row_count: usize,
 }
 
-struct VariableRowHeightListData<const COLS: usize> {
+struct VariableRowHeightListData {
     /// Unlike UniformList, this closure renders only single row, allowing each one to have its own height
-    render_row_fn: Box<dyn Fn(usize, &mut Window, &mut App) -> [AnyElement; COLS]>,
+    render_row_fn: Box<dyn Fn(usize, &mut Window, &mut App) -> UncheckedTableRow<AnyElement>>,
     list_state: ListState,
     row_count: usize,
 }
 
-enum TableContents<const COLS: usize> {
-    Vec(Vec<[AnyElement; COLS]>),
-    UniformList(UniformListData<COLS>),
-    VariableRowHeightList(VariableRowHeightListData<COLS>),
+enum TableContents {
+    Vec(Vec<TableRow<AnyElement>>),
+    UniformList(UniformListData),
+    VariableRowHeightList(VariableRowHeightListData),
 }
 
-impl<const COLS: usize> TableContents<COLS> {
-    fn rows_mut(&mut self) -> Option<&mut Vec<[AnyElement; COLS]>> {
+impl TableContents {
+    fn rows_mut(&mut self) -> Option<&mut Vec<TableRow<AnyElement>>> {
         match self {
             TableContents::Vec(rows) => Some(rows),
             TableContents::UniformList(_) => None,
@@ -101,24 +310,41 @@ impl TableInteractionState {
         }
     }
 
-    fn render_resize_handles<const COLS: usize>(
+    /// Renders invisible resize handles overlaid on top of table content.
+    ///
+    /// - Spacer: invisible element that matches the width of table column content
+    /// - Divider: contains the actual resize handle that users can drag to resize columns
+    ///
+    /// Structure: [spacer] [divider] [spacer] [divider] [spacer]
+    ///
+    /// Business logic:
+    /// 1. Creates spacers matching each column width
+    /// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns)
+    /// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize
+    /// 4. Returns an absolute-positioned overlay that sits on top of table content
+    fn render_resize_handles(
         &self,
-        column_widths: &[Length; COLS],
-        resizable_columns: &[TableResizeBehavior; COLS],
-        initial_sizes: [DefiniteLength; COLS],
-        columns: Option<Entity<TableColumnWidths<COLS>>>,
+        column_widths: &TableRow<Length>,
+        resizable_columns: &TableRow<TableResizeBehavior>,
+        initial_sizes: &TableRow<DefiniteLength>,
+        columns: Option<Entity<TableColumnWidths>>,
         window: &mut Window,
         cx: &mut App,
     ) -> AnyElement {
         let spacers = column_widths
+            .as_slice()
             .iter()
             .map(|width| base_cell_style(Some(*width)).into_any_element());
 
         let mut column_ix = 0;
-        let resizable_columns_slice = *resizable_columns;
-        let mut resizable_columns = resizable_columns.iter();
+        let resizable_columns_shared = Rc::new(resizable_columns.clone());
+        let initial_sizes_shared = Rc::new(initial_sizes.clone());
+        let mut resizable_columns_iter = resizable_columns.as_slice().iter();
 
+        // Insert dividers between spacers (column content)
         let dividers = intersperse_with(spacers, || {
+            let resizable_columns = Rc::clone(&resizable_columns_shared);
+            let initial_sizes = Rc::clone(&initial_sizes_shared);
             window.with_id(column_ix, |window| {
                 let mut resize_divider = div()
                     // This is required because this is evaluated at a different time than the use_state call above
@@ -136,7 +362,7 @@ impl TableInteractionState {
                     .w(px(RESIZE_COLUMN_WIDTH))
                     .h_full();
 
-                if resizable_columns
+                if resizable_columns_iter
                     .next()
                     .is_some_and(TableResizeBehavior::is_resizable)
                 {
@@ -156,7 +382,7 @@ impl TableInteractionState {
                                         columns.on_double_click(
                                             column_ix,
                                             &initial_sizes,
-                                            &resizable_columns_slice,
+                                            &resizable_columns,
                                             window,
                                         );
                                     })
@@ -206,23 +432,27 @@ impl TableResizeBehavior {
     }
 }
 
-pub struct TableColumnWidths<const COLS: usize> {
-    widths: [DefiniteLength; COLS],
-    visible_widths: [DefiniteLength; COLS],
+pub struct TableColumnWidths {
+    widths: TableRow<DefiniteLength>,
+    visible_widths: TableRow<DefiniteLength>,
     cached_bounds_width: Pixels,
     initialized: bool,
 }
 
-impl<const COLS: usize> TableColumnWidths<COLS> {
-    pub fn new(_: &mut App) -> Self {
+impl TableColumnWidths {
+    pub fn new(cols: usize, _: &mut App) -> Self {
         Self {
-            widths: [DefiniteLength::default(); COLS],
-            visible_widths: [DefiniteLength::default(); COLS],
+            widths: vec![DefiniteLength::default(); cols].into_table_row(cols),
+            visible_widths: vec![DefiniteLength::default(); cols].into_table_row(cols),
             cached_bounds_width: Default::default(),
             initialized: false,
         }
     }
 
+    pub fn cols(&self) -> usize {
+        self.widths.cols()
+    }
+
     fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
         match length {
             DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
@@ -236,17 +466,17 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
     fn on_double_click(
         &mut self,
         double_click_position: usize,
-        initial_sizes: &[DefiniteLength; COLS],
-        resize_behavior: &[TableResizeBehavior; COLS],
+        initial_sizes: &TableRow<DefiniteLength>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
         window: &mut Window,
     ) {
         let bounds_width = self.cached_bounds_width;
         let rem_size = window.rem_size();
         let initial_sizes =
-            initial_sizes.map(|length| Self::get_fraction(&length, bounds_width, rem_size));
+            initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
         let widths = self
             .widths
-            .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
 
         let updated_widths = Self::reset_to_initial_size(
             double_click_position,
@@ -255,15 +485,15 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
             resize_behavior,
         );
         self.widths = updated_widths.map(DefiniteLength::Fraction);
-        self.visible_widths = self.widths;
+        self.visible_widths = self.widths.clone(); // previously was copy
     }
 
     fn reset_to_initial_size(
         col_idx: usize,
-        mut widths: [f32; COLS],
-        initial_sizes: [f32; COLS],
-        resize_behavior: &[TableResizeBehavior; COLS],
-    ) -> [f32; COLS] {
+        mut widths: TableRow<f32>,
+        initial_sizes: TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
+    ) -> TableRow<f32> {
         // RESET:
         // Part 1:
         // Figure out if we should shrink/grow the selected column
@@ -348,7 +578,7 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
     fn on_drag_move(
         &mut self,
         drag_event: &DragMoveEvent<DraggedColumn>,
-        resize_behavior: &[TableResizeBehavior; COLS],
+        resize_behavior: &TableRow<TableResizeBehavior>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -368,7 +598,7 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
 
         let mut widths = self
             .widths
-            .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
+            .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
 
         for length in widths[0..=col_idx].iter() {
             col_position += length + column_handle_width;
@@ -378,7 +608,8 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
         for length in widths[col_idx + 1..].iter() {
             total_length_ratio += length;
         }
-        total_length_ratio += (COLS - 1 - col_idx) as f32 * column_handle_width;
+        let cols = resize_behavior.cols();
+        total_length_ratio += (cols - 1 - col_idx) as f32 * column_handle_width;
 
         let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
         let drag_fraction = drag_fraction * total_length_ratio;
@@ -392,8 +623,8 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
     fn drag_column_handle(
         diff: f32,
         col_idx: usize,
-        widths: &mut [f32; COLS],
-        resize_behavior: &[TableResizeBehavior; COLS],
+        widths: &mut TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
     ) {
         // if diff > 0.0 then go right
         if diff > 0.0 {
@@ -406,8 +637,8 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
     fn propagate_resize_diff(
         diff: f32,
         col_idx: usize,
-        widths: &mut [f32; COLS],
-        resize_behavior: &[TableResizeBehavior; COLS],
+        widths: &mut TableRow<f32>,
+        resize_behavior: &TableRow<TableResizeBehavior>,
         direction: i8,
     ) -> f32 {
         let mut diff_remaining = diff;
@@ -429,7 +660,7 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
         }
         let mut curr_column = col_idx + step_right - step_left;
 
-        while diff_remaining != 0.0 && curr_column < COLS {
+        while diff_remaining != 0.0 && curr_column < widths.cols() {
             let Some(min_size) = resize_behavior[curr_column].min_size() else {
                 if curr_column == 0 {
                     break;
@@ -461,49 +692,54 @@ impl<const COLS: usize> TableColumnWidths<COLS> {
     }
 }
 
-pub struct TableWidths<const COLS: usize> {
-    initial: [DefiniteLength; COLS],
-    current: Option<Entity<TableColumnWidths<COLS>>>,
-    resizable: [TableResizeBehavior; COLS],
+pub struct TableWidths {
+    initial: TableRow<DefiniteLength>,
+    current: Option<Entity<TableColumnWidths>>,
+    resizable: TableRow<TableResizeBehavior>,
 }
 
-impl<const COLS: usize> TableWidths<COLS> {
-    pub fn new(widths: [impl Into<DefiniteLength>; COLS]) -> Self {
+impl TableWidths {
+    pub fn new(widths: TableRow<impl Into<DefiniteLength>>) -> Self {
         let widths = widths.map(Into::into);
 
+        let expected_length = widths.cols();
         TableWidths {
             initial: widths,
             current: None,
-            resizable: [TableResizeBehavior::None; COLS],
+            resizable: vec![TableResizeBehavior::None; expected_length]
+                .into_table_row(expected_length),
         }
     }
 
-    fn lengths(&self, cx: &App) -> [Length; COLS] {
+    fn lengths(&self, cx: &App) -> TableRow<Length> {
         self.current
             .as_ref()
-            .map(|entity| entity.read(cx).visible_widths.map(Length::Definite))
-            .unwrap_or(self.initial.map(Length::Definite))
+            .map(|entity| entity.read(cx).visible_widths.map_cloned(Length::Definite))
+            .unwrap_or_else(|| self.initial.map_cloned(Length::Definite))
     }
 }
 
 /// A table component
 #[derive(RegisterComponent, IntoElement)]
-pub struct Table<const COLS: usize = 3> {
+pub struct Table {
     striped: bool,
     width: Option<Length>,
-    headers: Option<[AnyElement; COLS]>,
-    rows: TableContents<COLS>,
+    headers: Option<TableRow<AnyElement>>,
+    rows: TableContents,
     interaction_state: Option<WeakEntity<TableInteractionState>>,
-    col_widths: Option<TableWidths<COLS>>,
+    col_widths: Option<TableWidths>,
     map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
     use_ui_font: bool,
     empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
+    /// The number of columns in the table. Used to assert column numbers in `TableRow` collections
+    cols: usize,
 }
 
-impl<const COLS: usize> Table<COLS> {
-    /// number of headers provided.
-    pub fn new() -> Self {
+impl Table {
+    /// Creates a new table with the specified number of columns.
+    pub fn new(cols: usize) -> Self {
         Self {
+            cols,
             striped: false,
             width: None,
             headers: None,
@@ -524,7 +760,11 @@ impl<const COLS: usize> Table<COLS> {
         mut self,
         id: impl Into<ElementId>,
         row_count: usize,
-        render_item_fn: impl Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>
+        render_item_fn: impl Fn(
+            Range<usize>,
+            &mut Window,
+            &mut App,
+        ) -> Vec<UncheckedTableRow<AnyElement>>
         + 'static,
     ) -> Self {
         self.rows = TableContents::UniformList(UniformListData {
@@ -548,7 +788,7 @@ impl<const COLS: usize> Table<COLS> {
         mut self,
         row_count: usize,
         list_state: ListState,
-        render_row_fn: impl Fn(usize, &mut Window, &mut App) -> [AnyElement; COLS] + 'static,
+        render_row_fn: impl Fn(usize, &mut Window, &mut App) -> UncheckedTableRow<AnyElement> + 'static,
     ) -> Self {
         self.rows = TableContents::VariableRowHeightList(VariableRowHeightListData {
             render_row_fn: Box::new(render_row_fn),
@@ -558,7 +798,7 @@ impl<const COLS: usize> Table<COLS> {
         self
     }
 
-    /// Enables row striping.
+    /// Enables row striping (alternating row colors)
     pub fn striped(mut self) -> Self {
         self.striped = true;
         self
@@ -584,33 +824,41 @@ impl<const COLS: usize> Table<COLS> {
         self
     }
 
-    pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self {
-        self.headers = Some(headers.map(IntoElement::into_any_element));
+    pub fn header(mut self, headers: UncheckedTableRow<impl IntoElement>) -> Self {
+        self.headers = Some(
+            headers
+                .into_table_row(self.cols)
+                .map(IntoElement::into_any_element),
+        );
         self
     }
 
-    pub fn row(mut self, items: [impl IntoElement; COLS]) -> Self {
+    pub fn row(mut self, items: UncheckedTableRow<impl IntoElement>) -> Self {
         if let Some(rows) = self.rows.rows_mut() {
-            rows.push(items.map(IntoElement::into_any_element));
+            rows.push(
+                items
+                    .into_table_row(self.cols)
+                    .map(IntoElement::into_any_element),
+            );
         }
         self
     }
 
-    pub fn column_widths(mut self, widths: [impl Into<DefiniteLength>; COLS]) -> Self {
+    pub fn column_widths(mut self, widths: UncheckedTableRow<impl Into<DefiniteLength>>) -> Self {
         if self.col_widths.is_none() {
-            self.col_widths = Some(TableWidths::new(widths));
+            self.col_widths = Some(TableWidths::new(widths.into_table_row(self.cols)));
         }
         self
     }
 
     pub fn resizable_columns(
         mut self,
-        resizable: [TableResizeBehavior; COLS],
-        column_widths: &Entity<TableColumnWidths<COLS>>,
+        resizable: UncheckedTableRow<TableResizeBehavior>,
+        column_widths: &Entity<TableColumnWidths>,
         cx: &mut App,
     ) -> Self {
         if let Some(table_widths) = self.col_widths.as_mut() {
-            table_widths.resizable = resizable;
+            table_widths.resizable = resizable.into_table_row(self.cols);
             let column_widths = table_widths
                 .current
                 .get_or_insert_with(|| column_widths.clone());
@@ -618,8 +866,8 @@ impl<const COLS: usize> Table<COLS> {
             column_widths.update(cx, |widths, _| {
                 if !widths.initialized {
                     widths.initialized = true;
-                    widths.widths = table_widths.initial;
-                    widths.visible_widths = widths.widths;
+                    widths.widths = table_widths.initial.clone();
+                    widths.visible_widths = widths.widths.clone();
                 }
             })
         }
@@ -663,10 +911,10 @@ fn base_cell_style_text(width: Option<Length>, use_ui_font: bool, cx: &App) -> D
     base_cell_style(width).when(use_ui_font, |el| el.text_ui(cx))
 }
 
-pub fn render_table_row<const COLS: usize>(
+pub fn render_table_row(
     row_index: usize,
-    items: [impl IntoElement; COLS],
-    table_context: TableRenderContext<COLS>,
+    items: TableRow<impl IntoElement>,
+    table_context: TableRenderContext,
     window: &mut Window,
     cx: &mut App,
 ) -> AnyElement {
@@ -677,9 +925,12 @@ pub fn render_table_row<const COLS: usize>(
     } else {
         None
     };
+    let cols = items.cols();
     let column_widths = table_context
         .column_widths
-        .map_or([None; COLS], |widths| widths.map(Some));
+        .map_or(vec![None; cols].into_table_row(cols), |widths| {
+            widths.map(Some)
+        });
 
     let mut row = div()
         // NOTE: `h_flex()` sneakily applies `items_center()` which is not default behavior for div element.
@@ -699,8 +950,9 @@ pub fn render_table_row<const COLS: usize>(
     row = row.children(
         items
             .map(IntoElement::into_any_element)
+            .into_vec()
             .into_iter()
-            .zip(column_widths)
+            .zip(column_widths.into_vec())
             .map(|(cell, width)| {
                 base_cell_style_text(width, table_context.use_ui_font, cx)
                     .px_1()
@@ -718,20 +970,23 @@ pub fn render_table_row<const COLS: usize>(
     div().size_full().child(row).into_any_element()
 }
 
-pub fn render_table_header<const COLS: usize>(
-    headers: [impl IntoElement; COLS],
-    table_context: TableRenderContext<COLS>,
+pub fn render_table_header(
+    headers: TableRow<impl IntoElement>,
+    table_context: TableRenderContext,
     columns_widths: Option<(
-        WeakEntity<TableColumnWidths<COLS>>,
-        [TableResizeBehavior; COLS],
-        [DefiniteLength; COLS],
+        WeakEntity<TableColumnWidths>,
+        TableRow<TableResizeBehavior>,
+        TableRow<DefiniteLength>,
     )>,
     entity_id: Option<EntityId>,
     cx: &mut App,
 ) -> impl IntoElement {
+    let cols = headers.cols();
     let column_widths = table_context
         .column_widths
-        .map_or([None; COLS], |widths| widths.map(Some));
+        .map_or(vec![None; cols].into_table_row(cols), |widths| {
+            widths.map(Some)
+        });
 
     let element_id = entity_id
         .map(|entity| entity.to_string())
@@ -748,52 +1003,57 @@ pub fn render_table_header<const COLS: usize>(
         .p_2()
         .border_b_1()
         .border_color(cx.theme().colors().border)
-        .children(headers.into_iter().enumerate().zip(column_widths).map(
-            |((header_idx, h), width)| {
-                base_cell_style_text(width, table_context.use_ui_font, cx)
-                    .child(h)
-                    .id(ElementId::NamedInteger(
-                        shared_element_id.clone(),
-                        header_idx as u64,
-                    ))
-                    .when_some(
-                        columns_widths.as_ref().cloned(),
-                        |this, (column_widths, resizables, initial_sizes)| {
-                            if resizables[header_idx].is_resizable() {
-                                this.on_click(move |event, window, cx| {
-                                    if event.click_count() > 1 {
-                                        column_widths
-                                            .update(cx, |column, _| {
-                                                column.on_double_click(
-                                                    header_idx,
-                                                    &initial_sizes,
-                                                    &resizables,
-                                                    window,
-                                                );
-                                            })
-                                            .ok();
-                                    }
-                                })
-                            } else {
-                                this
-                            }
-                        },
-                    )
-            },
-        ))
+        .children(
+            headers
+                .into_vec()
+                .into_iter()
+                .enumerate()
+                .zip(column_widths.into_vec())
+                .map(|((header_idx, h), width)| {
+                    base_cell_style_text(width, table_context.use_ui_font, cx)
+                        .child(h)
+                        .id(ElementId::NamedInteger(
+                            shared_element_id.clone(),
+                            header_idx as u64,
+                        ))
+                        .when_some(
+                            columns_widths.as_ref().cloned(),
+                            |this, (column_widths, resizables, initial_sizes)| {
+                                if resizables[header_idx].is_resizable() {
+                                    this.on_click(move |event, window, cx| {
+                                        if event.click_count() > 1 {
+                                            column_widths
+                                                .update(cx, |column, _| {
+                                                    column.on_double_click(
+                                                        header_idx,
+                                                        &initial_sizes,
+                                                        &resizables,
+                                                        window,
+                                                    );
+                                                })
+                                                .ok();
+                                        }
+                                    })
+                                } else {
+                                    this
+                                }
+                            },
+                        )
+                }),
+        )
 }
 
 #[derive(Clone)]
-pub struct TableRenderContext<const COLS: usize> {
+pub struct TableRenderContext {
     pub striped: bool,
     pub total_row_count: usize,
-    pub column_widths: Option<[Length; COLS]>,
+    pub column_widths: Option<TableRow<Length>>,
     pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
     pub use_ui_font: bool,
 }
 
-impl<const COLS: usize> TableRenderContext<COLS> {
-    fn new(table: &Table<COLS>, cx: &App) -> Self {
+impl TableRenderContext {
+    fn new(table: &Table, cx: &App) -> Self {
         Self {
             striped: table.striped,
             total_row_count: table.rows.len(),
@@ -804,20 +1064,26 @@ impl<const COLS: usize> TableRenderContext<COLS> {
     }
 }
 
-impl<const COLS: usize> RenderOnce for Table<COLS> {
+impl RenderOnce for Table {
     fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
         let table_context = TableRenderContext::new(&self, cx);
         let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
         let current_widths = self
             .col_widths
             .as_ref()
-            .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable)))
+            .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable.clone())))
             .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior));
 
         let current_widths_with_initial_sizes = self
             .col_widths
             .as_ref()
-            .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable, widths.initial)))
+            .and_then(|widths| {
+                Some((
+                    widths.current.as_ref()?,
+                    widths.resizable.clone(),
+                    widths.initial.clone(),
+                ))
+            })
             .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial));
 
         let width = self.width;
@@ -863,7 +1129,7 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                     .on_drop::<DraggedColumn>(move |_, _, cx| {
                         widths
                             .update(cx, |widths, _| {
-                                widths.widths = widths.visible_widths;
+                                widths.widths = widths.visible_widths.clone();
                             })
                             .ok();
                         // Finish the resize operation
@@ -895,7 +1161,10 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                                 {
                                     let render_item_fn = uniform_list_data.render_list_of_rows_fn;
                                     move |range: Range<usize>, window, cx| {
-                                        let elements = render_item_fn(range.clone(), window, cx);
+                                        let elements = render_item_fn(range.clone(), window, cx)
+                                            .into_iter()
+                                            .map(|raw_row| raw_row.into_table_row(self.cols))
+                                            .collect::<Vec<_>>();
                                         elements
                                             .into_iter()
                                             .zip(range)
@@ -933,7 +1202,8 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                             list(variable_list_data.list_state.clone(), {
                                 let render_item_fn = variable_list_data.render_row_fn;
                                 move |row_index: usize, window: &mut Window, cx: &mut App| {
-                                    let row = render_item_fn(row_index, window, cx);
+                                    let row = render_item_fn(row_index, window, cx)
+                                        .into_table_row(self.cols);
                                     render_table_row(
                                         row_index,
                                         row,
@@ -952,13 +1222,13 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
                         self.col_widths.as_ref().zip(interaction_state.as_ref()),
                         |parent, (table_widths, state)| {
                             parent.child(state.update(cx, |state, cx| {
-                                let resizable_columns = table_widths.resizable;
+                                let resizable_columns = &table_widths.resizable;
                                 let column_widths = table_widths.lengths(cx);
                                 let columns = table_widths.current.clone();
-                                let initial_sizes = table_widths.initial;
+                                let initial_sizes = &table_widths.initial;
                                 state.render_resize_handles(
                                     &column_widths,
-                                    &resizable_columns,
+                                    resizable_columns,
                                     initial_sizes,
                                     columns,
                                     window,
@@ -1012,7 +1282,7 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
     }
 }
 
-impl Component for Table<3> {
+impl Component for Table {
     fn scope() -> ComponentScope {
         ComponentScope::Layout
     }
@@ -1031,22 +1301,22 @@ impl Component for Table<3> {
                         vec![
                             single_example(
                                 "Simple Table",
-                                Table::new()
+                                Table::new(3)
                                     .width(px(400.))
-                                    .header(["Name", "Age", "City"])
-                                    .row(["Alice", "28", "New York"])
-                                    .row(["Bob", "32", "San Francisco"])
-                                    .row(["Charlie", "25", "London"])
+                                    .header(vec!["Name", "Age", "City"])
+                                    .row(vec!["Alice", "28", "New York"])
+                                    .row(vec!["Bob", "32", "San Francisco"])
+                                    .row(vec!["Charlie", "25", "London"])
                                     .into_any_element(),
                             ),
                             single_example(
                                 "Two Column Table",
-                                Table::new()
-                                    .header(["Category", "Value"])
+                                Table::new(2)
+                                    .header(vec!["Category", "Value"])
                                     .width(px(300.))
-                                    .row(["Revenue", "$100,000"])
-                                    .row(["Expenses", "$75,000"])
-                                    .row(["Profit", "$25,000"])
+                                    .row(vec!["Revenue", "$100,000"])
+                                    .row(vec!["Expenses", "$75,000"])
+                                    .row(vec!["Profit", "$25,000"])
                                     .into_any_element(),
                             ),
                         ],
@@ -1056,24 +1326,24 @@ impl Component for Table<3> {
                         vec![
                             single_example(
                                 "Default",
-                                Table::new()
+                                Table::new(3)
                                     .width(px(400.))
-                                    .header(["Product", "Price", "Stock"])
-                                    .row(["Laptop", "$999", "In Stock"])
-                                    .row(["Phone", "$599", "Low Stock"])
-                                    .row(["Tablet", "$399", "Out of Stock"])
+                                    .header(vec!["Product", "Price", "Stock"])
+                                    .row(vec!["Laptop", "$999", "In Stock"])
+                                    .row(vec!["Phone", "$599", "Low Stock"])
+                                    .row(vec!["Tablet", "$399", "Out of Stock"])
                                     .into_any_element(),
                             ),
                             single_example(
                                 "Striped",
-                                Table::new()
+                                Table::new(3)
                                     .width(px(400.))
                                     .striped()
-                                    .header(["Product", "Price", "Stock"])
-                                    .row(["Laptop", "$999", "In Stock"])
-                                    .row(["Phone", "$599", "Low Stock"])
-                                    .row(["Tablet", "$399", "Out of Stock"])
-                                    .row(["Headphones", "$199", "In Stock"])
+                                    .header(vec!["Product", "Price", "Stock"])
+                                    .row(vec!["Laptop", "$999", "In Stock"])
+                                    .row(vec!["Phone", "$599", "Low Stock"])
+                                    .row(vec!["Tablet", "$399", "Out of Stock"])
+                                    .row(vec!["Headphones", "$199", "In Stock"])
                                     .into_any_element(),
                             ),
                         ],
@@ -1082,10 +1352,10 @@ impl Component for Table<3> {
                         "Mixed Content Table",
                         vec![single_example(
                             "Table with Elements",
-                            Table::new()
+                            Table::new(5)
                                 .width(px(840.))
-                                .header(["Status", "Name", "Priority", "Deadline", "Action"])
-                                .row([
+                                .header(vec!["Status", "Name", "Priority", "Deadline", "Action"])
+                                .row(vec![
                                     Indicator::dot().color(Color::Success).into_any_element(),
                                     "Project A".into_any_element(),
                                     "High".into_any_element(),
@@ -1095,7 +1365,7 @@ impl Component for Table<3> {
                                         .full_width()
                                         .into_any_element(),
                                 ])
-                                .row([
+                                .row(vec![
                                     Indicator::dot().color(Color::Warning).into_any_element(),
                                     "Project B".into_any_element(),
                                     "Medium".into_any_element(),
@@ -1105,7 +1375,7 @@ impl Component for Table<3> {
                                         .full_width()
                                         .into_any_element(),
                                 ])
-                                .row([
+                                .row(vec![
                                     Indicator::dot().color(Color::Error).into_any_element(),
                                     "Project C".into_any_element(),
                                     "Low".into_any_element(),
@@ -1132,31 +1402,35 @@ mod test {
         a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
     }
 
-    fn cols_to_str<const COLS: usize>(cols: &[f32; COLS], total_size: f32) -> String {
-        cols.map(|f| "*".repeat(f32::round(f * total_size) as usize))
+    fn cols_to_str(cols: &[f32], total_size: f32) -> String {
+        cols.iter()
+            .map(|f| "*".repeat(f32::round(f * total_size) as usize))
+            .collect::<Vec<String>>()
             .join("|")
     }
 
-    fn parse_resize_behavior<const COLS: usize>(
+    fn parse_resize_behavior(
         input: &str,
         total_size: f32,
-    ) -> [TableResizeBehavior; COLS] {
-        let mut resize_behavior = [TableResizeBehavior::None; COLS];
-        let mut max_index = 0;
-        for (index, col) in input.split('|').enumerate() {
+        expected_cols: usize,
+    ) -> Vec<TableResizeBehavior> {
+        let mut resize_behavior = Vec::with_capacity(expected_cols);
+        for col in input.split('|') {
             if col.starts_with('X') || col.is_empty() {
-                resize_behavior[index] = TableResizeBehavior::None;
+                resize_behavior.push(TableResizeBehavior::None);
             } else if col.starts_with('*') {
-                resize_behavior[index] =
-                    TableResizeBehavior::MinSize(col.len() as f32 / total_size);
+                resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size));
             } else {
                 panic!("invalid test input: unrecognized resize behavior: {}", col);
             }
-            max_index = index;
         }
 
-        if max_index + 1 != COLS {
-            panic!("invalid test input: too many columns");
+        if resize_behavior.len() != expected_cols {
+            panic!(
+                "invalid test input: expected {} columns, got {}",
+                expected_cols,
+                resize_behavior.len()
+            );
         }
         resize_behavior
     }