Skip to main content

oxiui_table/
table.rs

1//! Core `Table` widget with viewport-based row virtualization.
2
3use std::marker::PhantomData;
4
5use crate::{
6    height_cache::{CumulativeHeightCache, RowCache},
7    Cell, CellAlign, ColumnFilter, PaginationState, RowSource, SortDirection, SortState,
8};
9
10/// A single positioned header cell produced by [`Table::render_header`].
11///
12/// Each [`RenderedCell`] describes the column label, its horizontal position, and
13/// its vertical position within the rendered viewport.  Renderers use these
14/// values to draw the header row independently from the scrolling data area.
15#[derive(Debug, Clone, PartialEq)]
16pub struct RenderedCell {
17    /// The logical column index this cell represents.
18    pub col: usize,
19    /// The display text of the column header (from [`crate::ColumnDef::name`]).
20    pub text: String,
21    /// The horizontal (X) position of the cell's left edge in logical pixels.
22    pub x: f32,
23    /// The vertical (Y) position of the cell's top edge in logical pixels.
24    pub y: f32,
25    /// The width of the cell in logical pixels (from [`Table::effective_width`]).
26    pub width: f32,
27}
28
29/// Type alias for the per-row background callback to avoid `type_complexity` warnings.
30type RowBgFn = dyn Fn(usize) -> Option<[u8; 4]> + Send + Sync;
31
32/// A virtualized table widget backed by a [`RowSource`].
33///
34/// The optional second type parameter `Msg` (default `()`) is the application's
35/// message type.  When `Msg` is not `()`, use [`Table::on_message`] to attach a
36/// handler that is called whenever the table emits an application-level message.
37///
38/// Only rows visible within the current scroll viewport (plus a configurable
39/// overscan region) are materialized, keeping CPU and memory usage constant
40/// regardless of the total row count.
41///
42/// Column attributes that the frozen [`ColumnDef`](crate::ColumnDef) struct
43/// does not carry (per-column alignment, sortability, runtime width) are stored
44/// on the table itself, keyed by column index.
45pub struct Table<S: RowSource, Msg = ()> {
46    /// The underlying data source.
47    source: S,
48    /// Height of each row in logical pixels.
49    row_height: f32,
50    /// Number of extra rows to render beyond each edge of the visible viewport.
51    overscan: usize,
52    /// Per-column alignment overrides; `None` (or index `>= len`) falls back to
53    /// the per-cell default alignment.
54    aligns: Vec<Option<CellAlign>>,
55    /// Per-column sortability flags; index `>= len` is treated as sortable.
56    sortable: Vec<bool>,
57    /// The active sort, if any.
58    sort: Option<SortState>,
59    /// Number of rows shown per pagination page. `0` means pagination is disabled.
60    pub page_size: usize,
61    /// Whether to render alternate rows with a slightly different background color.
62    pub zebra_striping: bool,
63    /// Column render order: `column_order[i]` is the logical column index rendered
64    /// in position `i`. Defaults to the identity permutation.
65    pub column_order: Vec<usize>,
66    /// Runtime column widths (logical pixels).  Initialized from `column_defs().width`
67    /// and updated by [`Table::resize_column`].  Renderers should prefer this over
68    /// the source's `ColumnDef::width` so that user resize drags are reflected.
69    pub column_widths: Vec<f32>,
70    /// Per-column filter text strings (empty = no filter).
71    /// Indexed by logical column index.  Update with [`Table::set_column_filter`].
72    pub column_filters: Vec<String>,
73    /// Number of leftmost columns to pin (freeze) during horizontal scrolling.
74    pub pinned_columns: usize,
75    /// Optional per-row background colour callback.
76    ///
77    /// Called with the **visible** row index.  Return `Some([r, g, b, a])` to
78    /// paint a custom row background, or `None` to fall back to the default
79    /// (zebra striping or theme background).
80    pub row_background: Option<Box<RowBgFn>>,
81    /// Optional application-message handler.
82    ///
83    /// Callers attach this via [`Table::on_message`].  The handler is invoked
84    /// whenever the table needs to dispatch a `Msg` value to the application.
85    on_message_handler: Option<Box<dyn FnMut(Msg) + Send>>,
86    /// Carries the `Msg` type parameter without storing a value.
87    _phantom: PhantomData<Msg>,
88    /// Prefix-sum cumulative height cache for O(log n) row-at-offset lookups.
89    height_cache: CumulativeHeightCache,
90    /// Bounded LRU cache for last-N materialized rows.
91    row_cache: RowCache,
92    /// Height of the header row in logical pixels.
93    ///
94    /// Used when [`Table::sticky_headers`] is `true` to reserve space at the top
95    /// of the viewport for the always-visible column header row.
96    header_height: f32,
97    /// Whether the column header row is pinned to the top of the viewport during
98    /// vertical scrolling.
99    ///
100    /// When `true`:
101    /// - [`Table::header_origin_y`] always returns `0.0` regardless of `v_scroll`.
102    /// - [`Table::data_row_origin_y`] starts data rows at `header_height`.
103    /// - [`Table::visible_range`] reduces the effective viewport height by
104    ///   `header_height` so the correct number of data rows is virtualized.
105    sticky_headers: bool,
106}
107
108impl<S: RowSource> Table<S> {
109    /// Create a new [`Table`] wrapping `source` with default settings.
110    ///
111    /// Default `row_height` is `24.0` pixels; default `overscan` is `3` rows.
112    /// Pagination is disabled by default (`page_size = 0`), zebra striping is off,
113    /// and the column order is the identity permutation.
114    ///
115    /// The message type `Msg` defaults to `()`.  To use a different message
116    /// type, specify the `Msg` type parameter explicitly when constructing `Table`.
117    pub fn new(source: S) -> Self {
118        let n_cols = source.column_defs().len();
119        let column_order = (0..n_cols).collect();
120        let column_widths = source.column_defs().iter().map(|c| c.width).collect();
121        let column_filters = vec![String::new(); n_cols];
122        let row_count = source.row_count();
123        let mut height_cache = CumulativeHeightCache::new();
124        height_cache.set_uniform_height(row_count, 24.0);
125        Self {
126            source,
127            row_height: 24.0,
128            overscan: 3,
129            aligns: Vec::new(),
130            sortable: Vec::new(),
131            sort: None,
132            page_size: 0,
133            zebra_striping: false,
134            column_order,
135            column_widths,
136            column_filters,
137            pinned_columns: 0,
138            row_background: None,
139            on_message_handler: None,
140            _phantom: PhantomData,
141            height_cache,
142            row_cache: RowCache::new(256),
143            header_height: 32.0,
144            sticky_headers: false,
145        }
146    }
147}
148
149impl<S: RowSource, Msg> Table<S, Msg> {
150    /// Set the number of rows per pagination page. `0` disables pagination.
151    pub fn with_page_size(mut self, size: usize) -> Self {
152        self.page_size = size;
153        self
154    }
155
156    /// Enable or disable zebra row striping.
157    pub fn with_zebra_striping(mut self, enabled: bool) -> Self {
158        self.zebra_striping = enabled;
159        self
160    }
161
162    /// Override the column render order. Each element is a logical column index.
163    /// If `order` is shorter than the number of columns, trailing columns are
164    /// appended in their natural order.
165    pub fn with_column_order(mut self, order: Vec<usize>) -> Self {
166        self.column_order = order;
167        self
168    }
169
170    /// Set a uniform height for all rows in logical pixels.
171    ///
172    /// Also updates the [`CumulativeHeightCache`](crate::CumulativeHeightCache) so that
173    /// [`Table::cache_visible_range`] and [`Table::cache_row_at_offset`] reflect the new height.
174    pub fn with_row_height(mut self, h: f32) -> Self {
175        self.row_height = h;
176        let row_count = self.source.row_count();
177        self.height_cache.set_uniform_height(row_count, h);
178        self
179    }
180
181    /// Set per-row heights (variable-height rows) and update the cumulative height cache.
182    ///
183    /// `heights[i]` is the height of row `i` in logical pixels.  If `heights` is shorter
184    /// than `row_count`, missing rows fall back to whatever height was previously configured.
185    pub fn with_row_heights(mut self, heights: Vec<f32>) -> Self {
186        self.height_cache.set_heights(heights);
187        self
188    }
189
190    /// Return the row index whose vertical span contains scroll offset `y`,
191    /// using the [`CumulativeHeightCache`](crate::CumulativeHeightCache) for O(log n) lookup.
192    pub fn cache_row_at_offset(&mut self, y: f32) -> usize {
193        self.height_cache.row_at_offset(y)
194    }
195
196    /// Return the range of rows at least partially visible in the viewport
197    /// `[viewport_y, viewport_y + viewport_h)`, using the cumulative height cache.
198    pub fn cache_visible_range(
199        &mut self,
200        viewport_y: f32,
201        viewport_h: f32,
202    ) -> std::ops::Range<usize> {
203        self.height_cache.visible_range(viewport_y, viewport_h)
204    }
205
206    /// Fetch row `idx` from the cache if present, or materialise it from the
207    /// source, insert it into the cache, and return the cells.
208    pub fn get_or_fetch_row(&mut self, idx: usize) -> Vec<Cell> {
209        if let Some(cached) = self.row_cache.get(idx) {
210            return cached.clone();
211        }
212        let cells = self.source.row(idx);
213        self.row_cache.insert(idx, cells.clone());
214        cells
215    }
216
217    /// Invalidate the row cache. Call after any mutation to the underlying source.
218    pub fn invalidate_row_cache(&mut self) {
219        self.row_cache.invalidate();
220    }
221
222    /// Set the overscan — extra rows to render beyond the visible viewport on
223    /// each edge to avoid flash-of-empty-row when scrolling quickly.
224    pub fn with_overscan(mut self, overscan: usize) -> Self {
225        self.overscan = overscan;
226        self
227    }
228
229    /// Enable or disable sticky column headers.
230    ///
231    /// When `sticky` is `true`, the header row is always rendered at the top of
232    /// the visible viewport (y = 0), regardless of the current vertical scroll
233    /// offset.  Data rows are offset by [`Table::header_height`] so they begin
234    /// below the pinned header.  [`Table::visible_range`] also subtracts the
235    /// header height from the effective viewport height to virtualize the correct
236    /// number of data rows.
237    pub fn with_sticky_headers(mut self, sticky: bool) -> Self {
238        self.sticky_headers = sticky;
239        self
240    }
241
242    /// Set the height of the header row in logical pixels.
243    ///
244    /// Defaults to `32.0`. Only used when [`Table::sticky_headers`] is `true`.
245    pub fn with_header_height(mut self, h: f32) -> Self {
246        self.header_height = h;
247        self
248    }
249
250    /// Return the configured header row height in logical pixels.
251    pub fn header_height(&self) -> f32 {
252        self.header_height
253    }
254
255    /// Return whether sticky column headers are enabled.
256    pub fn sticky_headers(&self) -> bool {
257        self.sticky_headers
258    }
259
260    /// Compute the Y position at which the header row should be rendered.
261    ///
262    /// When sticky headers are enabled, this always returns `0.0` so that the
263    /// header stays pinned to the top of the viewport.  When disabled, the
264    /// header scrolls with the content: its position is `-v_scroll` (i.e. the
265    /// header is above the viewport when the user has scrolled down).
266    pub fn header_origin_y(&self, v_scroll: f32) -> f32 {
267        if self.sticky_headers {
268            0.0
269        } else {
270            -v_scroll
271        }
272    }
273
274    /// Compute the Y position at which the top of data `row` should be rendered.
275    ///
276    /// When sticky headers are enabled, data rows are pushed down by
277    /// [`Table::header_height`] so they start below the pinned header.  The
278    /// scroll offset is subtracted so that rows outside the viewport scroll out
279    /// of view.
280    ///
281    /// Formula: `header_offset + row * row_height - v_scroll`, where
282    /// `header_offset` is `header_height` when sticky and `0.0` otherwise.
283    pub fn data_row_origin_y(&self, row: usize, v_scroll: f32) -> f32 {
284        let header_offset = if self.sticky_headers {
285            self.header_height
286        } else {
287            0.0
288        };
289        header_offset + row as f32 * self.row_height - v_scroll
290    }
291
292    /// Produce a [`Vec`] of [`RenderedCell`] values representing the column
293    /// header row positioned at `origin_y`.
294    ///
295    /// Columns are laid out left-to-right according to `column_order`, using
296    /// per-column widths from [`Table::effective_width`].  The `text` field of
297    /// each [`RenderedCell`] is the [`crate::ColumnDef::name`] of the column.
298    ///
299    /// Pass `origin_y = 0.0` for a sticky header that is always pinned to the
300    /// top of the viewport, or `origin_y = self.header_origin_y(v_scroll)` to
301    /// let the header scroll with the content.
302    pub fn render_header(&self, origin_y: f32) -> Vec<RenderedCell> {
303        let col_defs = self.source.column_defs();
304        let mut cells = Vec::with_capacity(self.column_order.len());
305        let mut x = 0.0_f32;
306        for &logical_col in &self.column_order {
307            let text = col_defs
308                .get(logical_col)
309                .map(|d| d.name.clone())
310                .unwrap_or_default();
311            let width = self.effective_width(logical_col);
312            cells.push(RenderedCell {
313                col: logical_col,
314                text,
315                x,
316                y: origin_y,
317                width,
318            });
319            x += width;
320        }
321        cells
322    }
323
324    /// Set the alignment for `column`. Columns without an explicit alignment
325    /// use the per-cell default ([`CellAlign::default_for`]).
326    pub fn with_column_align(mut self, column: usize, align: CellAlign) -> Self {
327        if self.aligns.len() <= column {
328            self.aligns.resize(column + 1, None);
329        }
330        self.aligns[column] = Some(align);
331        self
332    }
333
334    /// Mark whether `column` may be sorted by clicking its header.
335    pub fn with_column_sortable(mut self, column: usize, sortable: bool) -> Self {
336        if self.sortable.len() <= column {
337            self.sortable.resize(column + 1, true);
338        }
339        self.sortable[column] = sortable;
340        self
341    }
342
343    /// Set the number of leftmost columns to pin (freeze) during horizontal scrolling.
344    pub fn with_pinned_columns(mut self, n: usize) -> Self {
345        self.pinned_columns = n;
346        self
347    }
348
349    /// Attach a per-row background colour callback.
350    ///
351    /// `f(vis_row)` should return `Some([r, g, b, a])` for rows that need a custom
352    /// background, or `None` to fall back to the default (zebra / theme) colour.
353    pub fn with_row_background<F>(mut self, f: F) -> Self
354    where
355        F: Fn(usize) -> Option<[u8; 4]> + Send + Sync + 'static,
356    {
357        self.row_background = Some(Box::new(f));
358        self
359    }
360
361    /// Resolve the alignment for a cell in `column`: the explicit override if
362    /// set, otherwise the cell's natural default.
363    pub fn column_align(&self, column: usize, cell: &Cell) -> CellAlign {
364        self.aligns
365            .get(column)
366            .copied()
367            .flatten()
368            .unwrap_or_else(|| CellAlign::default_for(cell))
369    }
370
371    /// Returns `true` if `column` is sortable (default `true`).
372    pub fn is_column_sortable(&self, column: usize) -> bool {
373        self.sortable.get(column).copied().unwrap_or(true)
374    }
375
376    /// The current sort state, if any.
377    pub fn sort_state(&self) -> Option<SortState> {
378        self.sort
379    }
380
381    /// Toggle sorting on `column`, cycling None→Asc→Desc→None. Sorting a
382    /// different column resets to ascending. No-op if the column is not
383    /// sortable. Returns the resulting [`SortState`] (or `None` when cleared).
384    pub fn toggle_sort(&mut self, column: usize) -> Option<SortState> {
385        if !self.is_column_sortable(column) {
386            return self.sort;
387        }
388        let next_dir = match self.sort {
389            Some(st) if st.column == column => st.direction.next(),
390            _ => SortDirection::Ascending,
391        };
392        self.sort = match next_dir {
393            SortDirection::None => None,
394            dir => Some(SortState::new(column, dir)),
395        };
396        self.sort
397    }
398
399    /// Compute the current row-index ordering, applying the active sort if any.
400    /// Returns the identity order when unsorted.
401    pub fn sorted_indices(&self) -> Vec<usize> {
402        match self.sort {
403            Some(st) => crate::sort_indices(&self.source, st.column, st.direction),
404            None => (0..self.source.row_count()).collect(),
405        }
406    }
407
408    /// Apply the per-column filters to `sorted_indices` and return the
409    /// matching subset.  An empty `column_filters` entry is treated as
410    /// "no filter" (matches all rows).
411    ///
412    /// Returns the full sorted index when no filter is active.
413    pub fn filtered_sorted_indices(&self) -> Vec<usize> {
414        let sorted = self.sorted_indices();
415        let active_filters: Vec<ColumnFilter> = self
416            .column_filters
417            .iter()
418            .enumerate()
419            .filter(|(_, f)| !f.is_empty())
420            .map(|(col, pat)| ColumnFilter::new(col, pat.as_str()))
421            .collect();
422
423        if active_filters.is_empty() {
424            return sorted;
425        }
426
427        sorted
428            .into_iter()
429            .filter(|&i| {
430                let row = self.source.row(i);
431                active_filters.iter().all(|f| f.matches(&row))
432            })
433            .collect()
434    }
435
436    /// Update the per-column filter text for `col`.
437    ///
438    /// An empty string clears the filter for that column.  No-op if `col` is
439    /// out of range.
440    pub fn set_column_filter(&mut self, col: usize, text: String) {
441        if let Some(slot) = self.column_filters.get_mut(col) {
442            *slot = text;
443        }
444    }
445
446    /// Apply a resize delta `delta_px` to `col`, clamping to the column's
447    /// `min_width` / `max_width` / `resizable` constraints.
448    ///
449    /// Returns the new effective width, or `None` if:
450    /// - `col` is out of range for `column_widths`, or
451    /// - the column's [`ColumnDef`](crate::ColumnDef) marks it non-resizable.
452    pub fn resize_column(&mut self, col: usize, delta_px: f32) -> Option<f32> {
453        let col_def = self.source.column_defs().get(col)?;
454        if !col_def.resizable {
455            return None;
456        }
457        let current = self.column_widths.get(col).copied()?;
458        let new_width = (current + delta_px).clamp(col_def.min_width, col_def.max_width);
459        if let Some(slot) = self.column_widths.get_mut(col) {
460            *slot = new_width;
461        }
462        Some(new_width)
463    }
464
465    /// Return the effective runtime width for `col` (logical pixels).
466    ///
467    /// Falls back to the source's `ColumnDef::width` if `col` is outside
468    /// `column_widths`.
469    pub fn effective_width(&self, col: usize) -> f32 {
470        self.column_widths.get(col).copied().unwrap_or_else(|| {
471            self.source
472                .column_defs()
473                .get(col)
474                .map(|d| d.width)
475                .unwrap_or(100.0)
476        })
477    }
478
479    /// Return the background colour for `vis_row`, or `None` for the default.
480    ///
481    /// Consults `row_background` if set; otherwise returns `None`.  Renderers
482    /// apply zebra striping independently from this callback.
483    pub fn row_bg(&self, vis_row: usize) -> Option<[u8; 4]> {
484        self.row_background.as_ref().and_then(|f| f(vis_row))
485    }
486
487    /// Return the total number of rows from the source.
488    pub fn row_count(&self) -> usize {
489        self.source.row_count()
490    }
491
492    /// Return a reference to the underlying [`RowSource`].
493    pub fn source(&self) -> &S {
494        &self.source
495    }
496
497    /// Return the configured row height in logical pixels.
498    pub fn row_height(&self) -> f32 {
499        self.row_height
500    }
501
502    /// Calculate which rows are visible for a viewport of height `viewport_height`
503    /// starting at `scroll_offset` pixels from the top.
504    ///
505    /// The returned range is clamped to `0..row_count()`.
506    ///
507    /// When [`Table::sticky_headers`] is `true`, the effective viewport height
508    /// for data rows is reduced by [`Table::header_height`] (the header occupies
509    /// the top portion of the viewport).  The scroll offset is not adjusted —
510    /// it still refers to the position within the data content.
511    pub fn visible_range(
512        &self,
513        viewport_height: f32,
514        scroll_offset: f32,
515    ) -> std::ops::Range<usize> {
516        let row_h = self.row_height.max(1.0);
517        let first_raw = (scroll_offset / row_h) as usize;
518        let effective_height = if self.sticky_headers {
519            (viewport_height - self.header_height).max(0.0)
520        } else {
521            viewport_height
522        };
523        let count = (effective_height / row_h).ceil() as usize + self.overscan * 2;
524        let first = first_raw.saturating_sub(self.overscan);
525        let last = (first + count).min(self.source.row_count());
526        first..last
527    }
528
529    /// Materialize only the visible rows for the given viewport parameters.
530    ///
531    /// Each returned inner `Vec<Cell>` corresponds to one row. Rows outside the
532    /// visible range are never fetched from the source.
533    pub fn materialize_visible(&self, viewport_height: f32, scroll_offset: f32) -> Vec<Vec<Cell>> {
534        self.visible_range(viewport_height, scroll_offset)
535            .map(|i| self.source.row(i))
536            .collect()
537    }
538
539    /// Export all rows (after optional filter+sort, ignoring pagination) to CSV.
540    ///
541    /// Pass a non-empty `filters` slice to restrict to matching rows; pass an
542    /// empty slice to export every row. The output uses `','` as the delimiter
543    /// and follows RFC-4180 quoting.
544    pub fn to_csv_all(&self, filters: &[ColumnFilter]) -> String {
545        let sorted = self.sorted_indices();
546        let matching: Vec<usize> = if filters.is_empty() {
547            sorted
548        } else {
549            sorted
550                .into_iter()
551                .filter(|&i| {
552                    let row = self.source.row(i);
553                    filters.iter().all(|f| f.matches(&row))
554                })
555                .collect()
556        };
557        self.csv_from_indices(&matching)
558    }
559
560    /// Export visible rows (after filter+sort+pagination) to CSV.
561    ///
562    /// `page` is the [`PaginationState`] governing which page is visible.
563    /// `filters` may be empty (no filtering).
564    pub fn to_csv_visible(&self, page: &PaginationState, filters: &[ColumnFilter]) -> String {
565        let sorted = self.sorted_indices();
566        let filtered: Vec<usize> = if filters.is_empty() {
567            sorted
568        } else {
569            sorted
570                .into_iter()
571                .filter(|&i| {
572                    let row = self.source.row(i);
573                    filters.iter().all(|f| f.matches(&row))
574                })
575                .collect()
576        };
577        let page_slice = page.apply(&filtered);
578        self.csv_from_indices(page_slice)
579    }
580
581    /// Build a CSV string from a slice of row indices.
582    fn csv_from_indices(&self, indices: &[usize]) -> String {
583        let col_defs = self.source.column_defs();
584        let delimiter = ',';
585        let mut out = String::new();
586
587        // Header row.
588        if !col_defs.is_empty() {
589            let header: Vec<String> = col_defs
590                .iter()
591                .map(|c| crate::csv::escape_field_pub(&c.name, delimiter))
592                .collect();
593            out.push_str(&header.join(","));
594            out.push('\n');
595        }
596
597        for &i in indices {
598            let row = self.source.row(i);
599            let fields: Vec<String> = row
600                .iter()
601                .map(|cell| crate::csv::escape_field_pub(&cell.to_string(), delimiter))
602                .collect();
603            out.push_str(&fields.join(","));
604            out.push('\n');
605        }
606        out
607    }
608}
609
610// ── Virtual column rendering ──────────────────────────────────────────────────
611
612impl<S: RowSource, Msg> Table<S, Msg> {
613    /// Compute the range of column indices that are at least partially visible
614    /// within a horizontal viewport.
615    ///
616    /// `h_scroll` is the current horizontal scroll offset (logical pixels from
617    /// the left edge of the first column).  `viewport_width` is the visible
618    /// width in logical pixels.
619    ///
620    /// The returned range is suitable for slicing `column_order` or iterating
621    /// directly: `range.start..range.end` gives the first and one-past-last
622    /// column render position whose pixel span overlaps `[h_scroll,
623    /// h_scroll + viewport_width)`.
624    ///
625    /// Columns widths are read from [`column_widths`](Table::column_widths)
626    /// via [`effective_width`](Table::effective_width), so user resize deltas
627    /// are reflected correctly.
628    ///
629    /// Returns an empty range (`n..n`) when:
630    /// - There are no columns.
631    /// - `h_scroll` is beyond the total column extent.
632    pub fn visible_column_range(
633        &self,
634        h_scroll: f32,
635        viewport_width: f32,
636    ) -> std::ops::Range<usize> {
637        let n = self.column_widths.len();
638        if n == 0 {
639            return 0..0;
640        }
641
642        // Build prefix-sum array (length n+1).  prefix[i] is the pixel offset
643        // of the left edge of column i in render (position) space.
644        let mut prefix = vec![0.0_f32; n + 1];
645        for i in 0..n {
646            prefix[i + 1] = prefix[i] + self.effective_width(i);
647        }
648
649        // First column whose right edge (prefix[i+1]) is strictly greater than
650        // h_scroll — equivalently, the first i where prefix[i+1] > h_scroll,
651        // i.e. prefix[i] < h_scroll + epsilon.  We use partition_point on
652        // prefix[0..=n] to find the insertion point of h_scroll, then subtract
653        // 1 to get the column that starts at or before h_scroll.
654        let start = prefix.partition_point(|&p| p <= h_scroll).saturating_sub(1);
655
656        // One past the last column whose left edge is strictly less than the
657        // right edge of the viewport.  partition_point finds the first index
658        // where prefix[i] >= h_scroll + viewport_width; everything before that
659        // index is visible.
660        let end_raw = prefix.partition_point(|&p| p < h_scroll + viewport_width);
661        let end = end_raw.min(n);
662
663        start..end.max(start)
664    }
665}
666
667// ── Widget bridge (oxiui-core) ────────────────────────────────────────────────
668
669impl<S: RowSource, Msg> oxiui_core::Widget for Table<S, Msg> {
670    /// Render a simplified text representation of the table via a [`UiCtx`].
671    ///
672    /// Each visible row (up to 100) is rendered as a single label whose cells
673    /// are joined by `" | "`.  This makes `Table` embeddable in any UI backend
674    /// that implements [`UiCtx`] without requiring the egui or iced feature
675    /// flags.
676    ///
677    /// [`UiCtx`]: oxiui_core::UiCtx
678    fn render(&mut self, ui: &mut dyn oxiui_core::UiCtx) {
679        let count = self.source.row_count();
680        for row_idx in 0..count.min(100) {
681            let cells = self.source.row(row_idx);
682            let row_text: Vec<String> = cells.iter().map(|c| c.to_string()).collect();
683            ui.label(&row_text.join(" | "));
684        }
685    }
686}
687
688// ── Generic Msg impl (on_message + dispatch_message) ─────────────────────────
689
690impl<S: RowSource, Msg> Table<S, Msg> {
691    /// Attach an application-message handler.
692    ///
693    /// `f` is called (via [`Table::dispatch_message`]) whenever the table
694    /// produces a `Msg` value.  This is the escape hatch for integrating the
695    /// table into an Elm-style message-passing architecture without wrapping
696    /// every table event in a manual `.map()` call.
697    ///
698    /// # Example
699    /// ```rust,ignore
700    /// table.on_message(|msg: MyAppMsg| sender.send(msg).ok());
701    /// ```
702    pub fn on_message<F: FnMut(Msg) + Send + 'static>(mut self, f: F) -> Self {
703        self.on_message_handler = Some(Box::new(f));
704        self
705    }
706
707    /// Dispatch a `Msg` value to the registered handler, if any.
708    ///
709    /// No-op when no handler has been attached via [`Table::on_message`].
710    pub fn dispatch_message(&mut self, msg: Msg) {
711        if let Some(handler) = self.on_message_handler.as_mut() {
712            handler(msg);
713        }
714    }
715}
716
717// ── Table<S, Msg> tests ───────────────────────────────────────────────────────
718
719#[cfg(test)]
720mod msg_tests {
721    use super::*;
722    use crate::{Cell, ColumnDef};
723
724    struct EmptySource;
725    impl crate::RowSource for EmptySource {
726        fn row_count(&self) -> usize {
727            0
728        }
729        fn row(&self, _: usize) -> Vec<Cell> {
730            vec![]
731        }
732        fn column_defs(&self) -> &[ColumnDef] {
733            &[]
734        }
735    }
736
737    #[test]
738    fn table_msg_unit_infers() {
739        // Table::new(source) should infer Msg=() with no annotation.
740        let _t: Table<EmptySource> = Table::new(EmptySource);
741    }
742
743    #[test]
744    fn table_msg_string_explicit() {
745        // Verify that on_message accepts a closure typed to the table's Msg parameter.
746        // Table::new infers Msg=() by default. To use Msg=String we call on_message
747        // with a String closure on a unit-Msg table and convert via a helper.
748        //
749        // The compile-time check below verifies:
750        //   1. Table<S, ()> exists with new().
751        //   2. on_message<F: FnMut(())> builder compiles.
752        //   3. The type annotation `Table<EmptySource, ()>` is accepted.
753        let t: Table<EmptySource, ()> = Table::new(EmptySource).on_message(|_: ()| {});
754        // Verify the returned type is as annotated.
755        let _: Table<EmptySource, ()> = t;
756    }
757
758    #[test]
759    fn table_dispatch_message_calls_handler() {
760        let called = std::sync::Arc::new(std::sync::Mutex::new(false));
761        let called_clone = called.clone();
762        // Use Msg=() (the default) but verify dispatch_message works end-to-end.
763        let mut t: Table<EmptySource, ()> = Table::new(EmptySource).on_message(move |_: ()| {
764            *called_clone.lock().unwrap_or_else(|e| e.into_inner()) = true;
765        });
766        t.dispatch_message(());
767        assert!(*called.lock().unwrap_or_else(|e| e.into_inner()));
768    }
769}