diff --git a/crates/moon-ui-components/src/moon/data_table/header.rs b/crates/moon-ui-components/src/moon/data_table/header.rs index 34603b2..a471af1 100644 --- a/crates/moon-ui-components/src/moon/data_table/header.rs +++ b/crates/moon-ui-components/src/moon/data_table/header.rs @@ -74,6 +74,15 @@ pub(super) fn render_header( style.header_separator_alpha, )); + // Which column's resize strip the pointer is over. GPUI applies hover styles at paint time + // only, so the mark's hover form (taller and wider) cannot come from `hover`/`group_hover`; + // the strip's `on_hover` writes this flag and the re-render lays the mark out anew. + let resize_hover = window.use_keyed_state( + ElementId::from(SharedString::from(format!("{id}:resize-hover"))), + cx, + |_, _| None::, + ); + let hovered_resize_key = resize_hover.read(cx).clone(); let sort_column = state.read(cx).sort_column.clone(); let sort_ascending = state.read(cx).sort_ascending; let all_keys = columns @@ -123,7 +132,20 @@ pub(super) fn render_header( }, 1.0, )) - .child(label) + .child( + div() + .debug_selector({ + let selector = format!("{id}:header-title:{key_string}"); + move || selector + }) + // A narrowed column clips its title before the right padding, so the + // resize mark in that padding never paints over the text. + .min_w(px(0.0)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(label), + ) .when_some(column.tooltip.clone(), |cell, tooltip| { cell.tooltip(move |_window, cx| { cx.new(|_| MoonTooltipView::new(tooltip.clone())).into() @@ -242,16 +264,61 @@ pub(super) fn render_header( let state_for_move = state.clone(); let state_for_reset = state.clone(); let reset_key = key_string.clone(); + let strip_id = SharedString::from(format!("{id}:resize:{key_string}")); + let strip_hovered = hovered_resize_key.as_ref() == Some(&key_string); + let hover_flag = resize_hover.clone(); + let hover_key = key_string.clone(); + // The resting mark tells the user where the divider is before they find it by hover: + // a short hairline in the header's own muted ink, centred in the hit strip. Hovering + // the strip grows it into a full-height accent bar. + let mark = div() + .debug_selector({ + let selector = format!("{id}:resize-mark:{key_string}"); + move || selector + }) + .map(|mark| { + if strip_hovered { + mark.w(px(tokens.ui(2.0))) + .h_full() + .bg(rgba_from(p.accent, 1.0)) + } else { + mark.w(px(tokens.ui(1.0))).h(relative(0.5)).bg(rgba_from( + style.header_text, + resize_mark_alpha(style.header_bg), + )) + } + }); cell = cell.child( div() - .id(ElementId::from(SharedString::from(format!( - "{id}:resize:{key_string}" - )))) + .id(ElementId::from(strip_id)) + .debug_selector({ + let selector = format!("{id}:resize:{key_string}"); + move || selector + }) .absolute() .right(px(0.0)) .top(px(0.0)) .bottom(px(0.0)) .w(px(tokens.ui(6.0))) + .flex() + .items_center() + .justify_center() + .child(mark) + .on_hover(move |hovered, _, cx| { + hover_flag.update(cx, |current, cx| { + let next = if *hovered { + Some(hover_key.clone()) + } else if current.as_ref() == Some(&hover_key) { + None + } else { + return; + }; + if *current != next { + *current = next; + cx.notify(); + } + }); + }) .cursor(CursorStyle::ResizeColumn) .hover(|this| this.bg(rgba_from(p.accent, 0.14))) // Double-clicking a divider restores automatic width. A plain double-click @@ -314,3 +381,22 @@ pub(super) fn render_header( header } + +/// Opacity of the resting resize mark over a header of the given background. +/// +/// The mark is painted in the header's muted text ink. On a near-black header that ink is dim, +/// so it needs more opacity to be found at a glance; on mid-grey (Graphite) and light headers the +/// same ink already contrasts well and a lighter mark keeps it quieter than the column titles. +/// +/// Args: +/// header_bg: Header background as `0xRRGGBB`. +/// +/// Returns: +/// The alpha applied to the muted ink for the resting mark. +pub(super) fn resize_mark_alpha(header_bg: u32) -> f32 { + let r = ((header_bg >> 16) & 0xFF) as f32; + let g = ((header_bg >> 8) & 0xFF) as f32; + let b = (header_bg & 0xFF) as f32; + let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + if luma < 48.0 { 0.75 } else { 0.55 } +} 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 88c37c4..481e716 100644 --- a/crates/moon-ui-components/src/moon/data_table/tests.rs +++ b/crates/moon-ui-components/src/moon/data_table/tests.rs @@ -481,3 +481,146 @@ fn tables_without_a_drawn_horizontal_track_keep_rows_flush_in_both_themes( } } } + +/// Stable identity of the header-edge probe table. +const HEADER_EDGE_TABLE_ID: &str = "data-table-header-edge"; + +/// Keys of the header-edge probe columns, in visual order. +const HEADER_EDGE_KEYS: [&str; 3] = ["first", "second", "last"]; + +/// Render a three-column table whose every column keeps the default resizable flag; the last one +/// is narrowed to the minimum under a long title. +struct HeaderEdgeHarness; + +impl gpui::Render for HeaderEdgeHarness { + /// Render the header-edge probe table. + /// + /// Args: + /// _window: Test window hosting the table. + /// _cx: Test view context. + /// + /// Returns: + /// A fixed-size table with three resizable columns. + fn render( + &mut self, + _window: &mut gpui::Window, + _cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::{ParentElement as _, Styled as _}; + + gpui::div().size_full().child( + MoonDataTable::new(HEADER_EDGE_TABLE_ID, 2, |ix, _, _| { + MoonDataRow::new([ + MoonDataCell::text(format!("a-{ix}")), + MoonDataCell::text(format!("b-{ix}")), + MoonDataCell::text(format!("c-{ix}")), + ]) + }) + .bounds(MoonRect::new(0.0, 0.0, 300.0, 120.0)) + .columns([ + col(HEADER_EDGE_KEYS[0], 100.0), + col(HEADER_EDGE_KEYS[1], 100.0), + // The narrowest width a drag allows, with a title far wider than it. + MoonDataTableColumn::new(HEADER_EDGE_KEYS[2], "PROFIT USDT TOTAL", 40.0), + ]) + .width_policy(MoonDataTableWidthPolicy::Preserve), + ) + } +} + +/// Read one header probe's laid-out bounds. +/// +/// Args: +/// visual: Test window context after layout. +/// part: Probe part name (`resize`, `resize-mark` or `header-title`). +/// key: Column key. +/// +/// Returns: +/// The probe's bounds; panics when it was not painted. +fn header_probe( + visual: &mut gpui::VisualTestContext, + part: &str, + key: &str, +) -> gpui::Bounds { + let selector = Box::leak(format!("{HEADER_EDGE_TABLE_ID}:{part}:{key}").into_boxed_str()); + visual + .debug_bounds(selector) + .unwrap_or_else(|| panic!("{selector} must be painted")) +} + +/// Drive a few frames so hover and layout settle. +fn settle(visual: &mut gpui::VisualTestContext) { + for _ in 0..4 { + visual.update(|window, _| window.refresh()); + visual.run_until_parked(); + } +} + +/// Catches dropping the resting resize mark from `data_table/header.rs:render_header` (issue +/// #1144: traders could not find a column divider without hunting for it blind), letting it +/// cover the column title (also when a long title sits in a minimum-width column), or losing its +/// full-height hover form. Runs in dark, light and Graphite. +#[gpui::test] +fn every_resizable_column_shows_a_resting_mark_that_grows_on_hover(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + ] { + cx.update(|cx| MoonTheme::global_mut(cx).palette = palette); + let (rest_w, hover_w) = cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + (gpui::px(tokens.ui(1.0)), gpui::px(tokens.ui(2.0))) + }); + let window = cx.add_window(|_, _| HeaderEdgeHarness); + let mut visual = gpui::VisualTestContext::from_window(window.into(), cx); + settle(&mut visual); + + for key in HEADER_EDGE_KEYS { + let strip = header_probe(&mut visual, "resize", key); + let mark = header_probe(&mut visual, "resize-mark", key); + let title = header_probe(&mut visual, "header-title", key); + + assert_eq!( + mark.size.width, rest_w, + "{key} resting width in {palette:?}" + ); + assert!( + (mark.size.height - strip.size.height * 0.5).abs() < gpui::px(0.5), + "{key} resting mark must be half the header height in {palette:?}: {mark:?} in {strip:?}" + ); + assert!( + (mark.center().x - strip.center().x).abs() < gpui::px(0.5) + && (mark.center().y - strip.center().y).abs() < gpui::px(0.5), + "{key} resting mark must be centred in its strip in {palette:?}" + ); + assert!( + mark.left() >= title.right(), + "{key} mark must not overlap the title in {palette:?}: {mark:?} vs {title:?}" + ); + } + + let last = HEADER_EDGE_KEYS[2]; + let strip = header_probe(&mut visual, "resize", last); + visual.simulate_mouse_move(strip.center(), None, Modifiers::none()); + settle(&mut visual); + let hovered = header_probe(&mut visual, "resize-mark", last); + assert_eq!(hovered.size.width, hover_w, "hover width in {palette:?}"); + assert_eq!( + hovered.size.height, strip.size.height, + "hovered mark must span the full header in {palette:?}" + ); + } +} + +/// Catches a single opacity for the resting mark: the muted ink must be denser on the near-black +/// dark header to read at all, and lighter on Graphite and light headers so it stays quieter than +/// the titles. +#[test] +fn resize_mark_alpha_is_denser_only_on_the_near_black_header() { + use super::header::resize_mark_alpha; + assert_eq!(resize_mark_alpha(MoonPalette::TERMINAL.table_head), 0.75); + assert_eq!(resize_mark_alpha(MoonPalette::GRAPHITE.table_head), 0.55); + assert_eq!(resize_mark_alpha(MoonPalette::LIGHT.table_head), 0.55); +}