Add resize handles, and the use_keyed_state API to handle hovers

This commit is contained in:
Mikayla Maki 2025-07-18 00:15:26 -07:00
parent 7c1040bc93
commit 89a05abf78
4 changed files with 119 additions and 35 deletions

1
Cargo.lock generated
View file

@ -14756,6 +14756,7 @@ dependencies = [
"fs", "fs",
"fuzzy", "fuzzy",
"gpui", "gpui",
"itertools 0.14.0",
"language", "language",
"log", "log",
"menu", "menu",

View file

@ -23,6 +23,7 @@ feature_flags.workspace = true
fs.workspace = true fs.workspace = true
fuzzy.workspace = true fuzzy.workspace = true
gpui.workspace = true gpui.workspace = true
itertools.workspace = true
language.workspace = true language.workspace = true
log.workspace = true log.workspace = true
menu.workspace = true menu.workspace = true

View file

@ -1594,15 +1594,14 @@ impl Render for KeymapEditor {
.collect() .collect()
}), }),
) )
.map_row( .map_row(cx.processor(
cx.processor(|this, (row_index, row): (usize, Div), _window, cx| { |this, (row_index, row): (usize, Stateful<Div>), _window, cx| {
let is_conflict = this.has_conflict(row_index); let is_conflict = this.has_conflict(row_index);
let is_selected = this.selected_index == Some(row_index); let is_selected = this.selected_index == Some(row_index);
let row_id = row_group_id(row_index); let row_id = row_group_id(row_index);
let row = row let row = row
.id(row_id.clone())
.on_any_mouse_down(cx.listener( .on_any_mouse_down(cx.listener(
move |this, move |this,
mouse_down_event: &gpui::MouseDownEvent, mouse_down_event: &gpui::MouseDownEvent,
@ -1636,11 +1635,12 @@ impl Render for KeymapEditor {
}) })
.when(is_selected, |row| { .when(is_selected, |row| {
row.border_color(cx.theme().colors().panel_focused_border) row.border_color(cx.theme().colors().panel_focused_border)
.border_2()
}); });
row.into_any_element() row.into_any_element()
}), },
), )),
) )
.on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| { .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| {
// This ensures that the menu is not dismissed in cases where scroll events // This ensures that the menu is not dismissed in cases where scroll events

View file

@ -3,14 +3,16 @@ use std::{ops::Range, rc::Rc, time::Duration};
use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide}; use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
use gpui::{ use gpui::{
AppContext, Axis, Context, Entity, FocusHandle, Length, ListHorizontalSizingBehavior, AppContext, Axis, Context, Entity, FocusHandle, Length, ListHorizontalSizingBehavior,
ListSizingBehavior, MouseButton, Point, Task, UniformListScrollHandle, WeakEntity, ListSizingBehavior, MouseButton, Point, Stateful, Task, UniformListScrollHandle, WeakEntity,
transparent_black, uniform_list, transparent_black, uniform_list,
}; };
use itertools::intersperse_with;
use settings::Settings as _; use settings::Settings as _;
use ui::{ use ui::{
ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component, ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator, ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce, InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _, Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _,
StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex, StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
}; };
@ -191,6 +193,67 @@ impl TableInteractionState {
} }
} }
fn render_resize_handles<const COLS: usize>(
&self,
column_widths: &[Length; COLS],
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let spacers = column_widths
.iter()
.map(|width| base_cell_style(Some(*width)));
let mut column_ix = 0;
let dividers = intersperse_with(spacers, || {
let hovered =
window
.use_keyed_state(("resize-hover", column_ix as u32), cx, |_window, _cx| false);
let div = div()
.relative()
.top_0()
.w_0p5()
.h_full()
.bg(cx.theme().colors().border_variant.opacity(0.5))
.when(*hovered.read(cx), |div| {
div.bg(cx.theme().colors().border_focused)
})
.child(
div()
.id(("column-resize-handle", column_ix as u32))
.absolute()
.left_neg_0p5()
.w_1p5()
.h_full()
.on_hover(move |&was_hovered, _, cx| {
hovered.update(cx, |hovered, _| {
*hovered = was_hovered;
})
})
.cursor_col_resize()
.on_mouse_down(MouseButton::Left, {
let column_idx = column_ix;
move |_event, _window, _cx| {
// TODO: Emit resize event to parent
eprintln!("Start resizing column {}", column_idx);
}
}),
);
column_ix += 1;
div
});
div()
.id("id")
.h_flex()
.absolute()
.w_full()
.inset_0()
.children(dividers)
.into_any_element()
}
fn render_vertical_scrollbar_track( fn render_vertical_scrollbar_track(
this: &Entity<Self>, this: &Entity<Self>,
parent: Div, parent: Div,
@ -385,7 +448,7 @@ pub struct Table<const COLS: usize = 3> {
impl<const COLS: usize> Table<COLS> { impl<const COLS: usize> Table<COLS> {
/// number of headers provided. /// number of headers provided.
pub fn new() -> Self { pub fn new() -> Self {
Table { Self {
striped: false, striped: false,
width: None, width: None,
headers: None, headers: None,
@ -459,9 +522,14 @@ impl<const COLS: usize> Table<COLS> {
self self
} }
pub fn resizable_columns(mut self) -> Self {
self.resizable_columns = true;
self
}
pub fn map_row( pub fn map_row(
mut self, mut self,
callback: impl Fn((usize, Div), &mut Window, &mut App) -> AnyElement + 'static, callback: impl Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement + 'static,
) -> Self { ) -> Self {
self.map_row = Some(Rc::new(callback)); self.map_row = Some(Rc::new(callback));
self self
@ -477,18 +545,21 @@ impl<const COLS: usize> Table<COLS> {
} }
} }
fn base_cell_style(width: Option<Length>, cx: &App) -> Div { fn base_cell_style(width: Option<Length>) -> Div {
div() div()
.px_1p5() .px_1p5()
.when_some(width, |this, width| this.w(width)) .when_some(width, |this, width| this.w(width))
.when(width.is_none(), |this| this.flex_1()) .when(width.is_none(), |this| this.flex_1())
.justify_start() .justify_start()
.text_ui(cx)
.whitespace_nowrap() .whitespace_nowrap()
.text_ellipsis() .text_ellipsis()
.overflow_hidden() .overflow_hidden()
} }
fn base_cell_style_text(width: Option<Length>, cx: &App) -> Div {
base_cell_style(width).text_ui(cx)
}
pub fn render_row<const COLS: usize>( pub fn render_row<const COLS: usize>(
row_index: usize, row_index: usize,
items: [impl IntoElement; COLS], items: [impl IntoElement; COLS],
@ -507,33 +578,33 @@ pub fn render_row<const COLS: usize>(
.column_widths .column_widths
.map_or([None; COLS], |widths| widths.map(Some)); .map_or([None; COLS], |widths| widths.map(Some));
let row = div().w_full().child( let mut row = h_flex()
h_flex() .h_full()
.id("table_row") .id(("table_row", row_index))
.w_full() .w_full()
.justify_between() .justify_between()
.px_1p5() .when_some(bg, |row, bg| row.bg(bg))
.py_1() .when(!is_striped, |row| {
.when_some(bg, |row, bg| row.bg(bg)) row.border_b_1()
.when(!is_striped, |row| { .border_color(transparent_black())
row.border_b_1() .when(!is_last, |row| row.border_color(cx.theme().colors().border))
.border_color(transparent_black()) });
.when(!is_last, |row| row.border_color(cx.theme().colors().border))
}) row = row.children(
.children( items
items .map(IntoElement::into_any_element)
.map(IntoElement::into_any_element) .into_iter()
.into_iter() .zip(column_widths)
.zip(column_widths) .map(|(cell, width)| base_cell_style_text(width, cx).px_1p5().py_1().child(cell)),
.map(|(cell, width)| base_cell_style(width, cx).child(cell)),
),
); );
if let Some(map_row) = table_context.map_row { let row = if let Some(map_row) = table_context.map_row {
map_row((row_index, row), window, cx) map_row((row_index, row), window, cx)
} else { } else {
row.into_any_element() row.into_any_element()
} };
div().h_full().w_full().child(row).into_any_element()
} }
pub fn render_header<const COLS: usize>( pub fn render_header<const COLS: usize>(
@ -557,7 +628,7 @@ pub fn render_header<const COLS: usize>(
headers headers
.into_iter() .into_iter()
.zip(column_widths) .zip(column_widths)
.map(|(h, width)| base_cell_style(width, cx).child(h)), .map(|(h, width)| base_cell_style_text(width, cx).child(h)),
) )
} }
@ -566,7 +637,7 @@ pub struct TableRenderContext<const COLS: usize> {
pub striped: bool, pub striped: bool,
pub total_row_count: usize, pub total_row_count: usize,
pub column_widths: Option<[Length; COLS]>, pub column_widths: Option<[Length; COLS]>,
pub map_row: Option<Rc<dyn Fn((usize, Div), &mut Window, &mut App) -> AnyElement>>, pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
} }
impl<const COLS: usize> TableRenderContext<COLS> { impl<const COLS: usize> TableRenderContext<COLS> {
@ -660,6 +731,17 @@ impl<const COLS: usize> RenderOnce for Table<COLS> {
), ),
), ),
}) })
.when_some(
self.column_widths
.as_ref()
.zip(interaction_state.as_ref())
.filter(|_| self.resizable_columns),
|parent, (column_widths, state)| {
parent.child(state.update(cx, |state, cx| {
state.render_resize_handles(column_widths, window, cx)
}))
},
)
.when_some(interaction_state.as_ref(), |this, interaction_state| { .when_some(interaction_state.as_ref(), |this, interaction_state| {
this.map(|this| { this.map(|this| {
TableInteractionState::render_vertical_scrollbar_track( TableInteractionState::render_vertical_scrollbar_track(