Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 90 additions & 4 deletions crates/moon-ui-components/src/moon/data_table/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>,
);
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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}
143 changes: 143 additions & 0 deletions crates/moon-ui-components/src/moon/data_table/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>,
) -> 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<gpui::Pixels> {
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);
}
Loading