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}