From b3e2b1b99406e8f3156946cc850185abc2f62b89 Mon Sep 17 00:00:00 2001 From: kirillDevPro <113171057+kirillDevPro@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:55:17 +0200 Subject: [PATCH] feat(data-table): lay one element across a whole row Every table cell is `overflow_hidden`, which is what keeps a long value out of its neighbour's column. It also means a row that wants to say ONE thing across its full width cannot say it through a cell: a section heading put in the leftmost column is cut at that column's edge however much empty room the rest of the row has, and the final render widths are resolved inside the table from a measured viewport, so no caller-side offset can fix it either. `MoonTableRow::banner` / `MoonDataRow::banner` take one element and paint it across the row, after the cells and outside their clipping. Painting last decides what the eye sees, never what the mouse reaches: the wrapper is deliberately not occluded, so a cell underneath stays clickable. The consequence to design around is the opposite of occlusion - a handler on the banner and one on a cell beneath it both fire for a single click, and a caller that wants the banner to win says so itself. `has_banner` is a crate-internal predicate rather than an exposed field: the forward crosses a module boundary and nothing below it is observable without a rendering harness, so the conversion would otherwise be untestable, while a public field would let any module move the element out of a row it does not own. --- .../moon-ui-components/src/moon/data_table.rs | 38 ++++++++++- .../src/moon/data_table/tests.rs | 23 ++++++- crates/moon-ui-components/src/moon/table.rs | 65 +++++++++++++++++++ docs/component-api-baseline.json | 8 +++ 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/crates/moon-ui-components/src/moon/data_table.rs b/crates/moon-ui-components/src/moon/data_table.rs index 8333a14..e59be0a 100644 --- a/crates/moon-ui-components/src/moon/data_table.rs +++ b/crates/moon-ui-components/src/moon/data_table.rs @@ -231,9 +231,17 @@ impl MoonDataCell { } } +/// One row handed to a [`MoonDataTable`] row builder. +/// +/// CONSTRUCT IT THROUGH [`MoonDataRow::new`] AND THE BUILDERS, never a struct literal. `cells` and +/// `selected` are `pub` for the table's own internals to read back, which reads like an invitation +/// to build one field-by-field; it is not, and `banner` is deliberately private to say so. A +/// literal would break on the next field added here whatever that field's visibility is, so the +/// builder path is the only construction contract this type has ever been able to keep. pub struct MoonDataRow { pub cells: Vec, pub selected: bool, + banner: Option, } impl MoonDataRow { @@ -241,18 +249,44 @@ impl MoonDataRow { Self { cells: cells.into_iter().collect(), selected: false, + banner: None, } } + /// Lay one element across the whole row, above the cells and outside their clipping. + /// + /// Forwards to [`MoonTableRow::banner`], which carries the contract: a section heading or any + /// other row-wide statement, on a row whose cells are decorative. The cells are still emitted + /// — they keep the column geometry and any background the row paints — the banner simply + /// covers them. + /// + /// Args: + /// banner: The element to lay across the row. + /// + /// Returns: + /// The row, carrying the banner. + pub fn banner(mut self, banner: impl IntoElement) -> Self { + self.banner = Some(banner.into_any_element()); + self + } + pub fn selected(mut self, selected: bool) -> Self { self.selected = selected; self } fn as_table_row(self) -> MoonTableRow { - MoonTableRow::new() + // A plain `match`, NOT `FluentBuilder::when_some`: that combinator is blanket-implemented + // only for `IntoElement`, and `MoonTableRow` is a plain builder struct that is not an + // element. Reaching for it here fails to compile with E0599 "trait bounds were not + // satisfied" -- measured, after a review suggested exactly that swap. + let row = MoonTableRow::new() .selected(self.selected) - .cells(self.cells.into_iter().map(|cell| cell.cell)) + .cells(self.cells.into_iter().map(|cell| cell.cell)); + match self.banner { + Some(banner) => row.banner(banner), + None => row, + } } } diff --git a/crates/moon-ui-components/src/moon/data_table/tests.rs b/crates/moon-ui-components/src/moon/data_table/tests.rs index 4815938..104b7c3 100644 --- a/crates/moon-ui-components/src/moon/data_table/tests.rs +++ b/crates/moon-ui-components/src/moon/data_table/tests.rs @@ -3,10 +3,10 @@ // Do not use `super::*`: the glob would import the `gpui::test` macro, causing `#[test]` to // recursively expand into itself. use super::{ - MIN_COLUMN_WIDTH, MoonDataTable, MoonDataTableColumn, MoonDataTableWidthPolicy, - is_select_all_shortcut, + MIN_COLUMN_WIDTH, MoonDataCell, MoonDataRow, MoonDataTable, MoonDataTableColumn, + MoonDataTableWidthPolicy, is_select_all_shortcut, }; -use gpui::Modifiers; +use gpui::{Modifiers, div}; /// Build a test column with matching key and label. fn col(key: &str, width: f32) -> MoonDataTableColumn { @@ -246,3 +246,20 @@ fn select_all_shortcut_requires_the_exact_platform_secondary_modifier() { assert!(!is_select_all_shortcut("a", with_shift)); assert!(!is_select_all_shortcut("a", with_alt)); } + +/// Catches dropping the banner forward in `data_table.rs:MoonDataRow::as_table_row`, which would +/// render an exchange section heading as an empty grey stripe without its logo, name, or count. +#[test] +fn data_row_conversion_preserves_banner_presence() { + let row_with_banner = MoonDataRow::new([MoonDataCell::text("placeholder")]).banner(div()); + let row_without_banner = MoonDataRow::new([MoonDataCell::text("placeholder")]); + + assert!( + row_with_banner.as_table_row().has_banner(), + "a banner supplied through MoonDataRow must reach MoonTableRow" + ); + assert!( + !row_without_banner.as_table_row().has_banner(), + "a MoonDataRow without a banner must not create one during conversion" + ); +} diff --git a/crates/moon-ui-components/src/moon/table.rs b/crates/moon-ui-components/src/moon/table.rs index 895b244..83f1851 100644 --- a/crates/moon-ui-components/src/moon/table.rs +++ b/crates/moon-ui-components/src/moon/table.rs @@ -137,6 +137,7 @@ pub struct MoonTableRow { cells: Vec, selected: bool, text_alpha: f32, + banner: Option, } impl Default for MoonTableRow { @@ -151,9 +152,39 @@ impl MoonTableRow { cells: Vec::new(), selected: false, text_alpha: 1.0, + banner: None, } } + /// Lay one element across the WHOLE row, above the cells and outside their clipping. + /// + /// Every cell is `overflow_hidden`, which is what keeps a long value from spilling into its + /// neighbour — and is also why a row that wants to say ONE thing across its full width cannot + /// say it through a cell: a section heading put in the leftmost cell is cut at that column's + /// edge, however much empty room the rest of the row has. The banner is the escape hatch, and + /// it is deliberately the only one: widening a cell's clipping would let ordinary values + /// overlap. + /// + /// It is painted AFTER the cells, so it sits ON TOP of them VISUALLY. It does NOT take their + /// pointer events: GPUI stops hit-testing at a hitbox only when that hitbox is + /// `HitboxBehavior::BlockMouse` (`window.rs::hit_test` breaks on exactly that), and only + /// `occlude()` / `occlude_mouse()` set it. This wrapper calls neither, so a cell underneath + /// stays clickable and a banner is safe on a row whose cells are interactive. + /// + /// The consequence to design around is the OPPOSITE of occlusion: put a click handler on the + /// banner AND on a cell beneath it and BOTH fire for one click. A caller that wants the banner + /// to win calls `cx.stop_propagation()` in its own handler, or `occlude()`s its own element. + /// + /// Args: + /// banner: The element to lay across the row. + /// + /// Returns: + /// The row, carrying the banner. + pub fn banner(mut self, banner: impl IntoElement) -> Self { + self.banner = Some(banner.into_any_element()); + self + } + pub fn cell(mut self, cell: MoonTableCell) -> Self { self.cells.push(cell); self @@ -173,6 +204,23 @@ impl MoonTableRow { self.text_alpha = text_alpha; self } + + /// Whether a banner was laid across this row. + /// + /// `pub(crate)` and a PREDICATE rather than a field: `MoonDataRow::as_table_row` forwards the + /// banner across a module boundary, and nothing downstream of that forward is observable + /// without a rendering harness — so the forward itself is untestable unless the receiving side + /// can be asked. Exposing the field instead would let any module in the crate MOVE the element + /// out of a row it does not own; a bool answers the only question a caller has. + /// + /// `allow(dead_code)` OUTSIDE a test build, and only there: the crate's own callers are all in + /// `#[cfg(test)] mod tests`, which is stripped before dead-code analysis runs, so an ordinary + /// `cargo build` would warn about a method that is doing its job. The attribute is conditional + /// rather than blanket so that a REAL orphaning — the tests going away — still warns. + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn has_banner(&self) -> bool { + self.banner.is_some() + } } #[derive(Clone, Copy, Debug)] @@ -290,6 +338,23 @@ impl MoonTable { row_el = row_el.child(decorate_cell(column_ix, cell)); } + // LAST, so it paints over the cells rather than under them, and absolute so it spans the + // row instead of joining the cell flex line. The row itself is `relative` and carries no + // `overflow_hidden`, which is the whole reason a banner can reach past a column edge. + // + // Deliberately NOT `occlude()`d — see the builder's doc. Painting last decides what the + // eye sees, never what the mouse reaches. + if let Some(banner) = row.banner { + row_el = row_el.child( + div() + .absolute() + .left(px(0.0)) + .top(px(0.0)) + .size_full() + .child(banner), + ); + } + row_el } diff --git a/docs/component-api-baseline.json b/docs/component-api-baseline.json index 4b4704f..437cebd 100644 --- a/docs/component-api-baseline.json +++ b/docs/component-api-baseline.json @@ -721,6 +721,10 @@ "file": "crates/moon-ui-components/src/moon/data_table.rs", "signature": "pub fn background_policy(mut self, policy: MoonBackgroundPolicy) -> Self" }, + { + "file": "crates/moon-ui-components/src/moon/data_table.rs", + "signature": "pub fn banner(mut self, banner: impl IntoElement) -> Self" + }, { "file": "crates/moon-ui-components/src/moon/data_table.rs", "signature": "pub fn bounds(mut self, bounds: MoonRect) -> Self" @@ -3909,6 +3913,10 @@ "file": "crates/moon-ui-components/src/moon/table.rs", "signature": "pub fn align(mut self, align: MoonTableAlign) -> Self" }, + { + "file": "crates/moon-ui-components/src/moon/table.rs", + "signature": "pub fn banner(mut self, banner: impl IntoElement) -> Self" + }, { "file": "crates/moon-ui-components/src/moon/table.rs", "signature": "pub fn cell(mut self, cell: MoonTableCell) -> Self"