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"