chore: audit components

This commit is contained in:
2026-11-10 15:06:16 +07:00
parent af2177c6c8
commit 03369f501e
92 changed files with 2744 additions and 1947 deletions
+68 -62
View File
@@ -15,7 +15,9 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<ListItem>>;
/// One row of a [`List`].
#[must_use]
pub struct ListItem {
key: ElementId,
label: SharedString,
icon: Option<Icon>,
detail: Option<SharedString>,
@@ -23,12 +25,14 @@ pub struct ListItem {
}
impl ListItem {
/// Creates a row with this label.
pub fn new<L>(label: L) -> Self
/// Creates a row shown as `label`, identified by `key`.
pub fn new<K, L>(key: K, label: L) -> Self
where
K: Into<ElementId>,
L: Into<SharedString>,
{
Self {
key: key.into(),
label: label.into(),
icon: None,
detail: None,
@@ -36,7 +40,7 @@ impl ListItem {
}
}
/// Shows an icon leading the label, a role or any custom [`IconNamed`].
/// Shows an icon leading the label, a role or any custom [`gpui_native_icons::IconNamed`].
pub fn icon<I>(mut self, icon: I) -> Self
where
I: Into<Icon>,
@@ -63,6 +67,7 @@ impl ListItem {
/// A single-selection list.
#[derive(IntoElement)]
#[must_use]
pub struct List {
id: ElementId,
selected: Option<usize>,
@@ -101,7 +106,7 @@ impl List {
self
}
/// Sets the rows, built fresh each render.
/// Sets the rows, the closure runs during render and must not mutate entities or notify.
pub fn items<F>(mut self, items: F) -> Self
where
F: Fn(&mut Window, &mut App) -> Vec<ListItem> + 'static,
@@ -146,11 +151,10 @@ impl RenderOnce for List {
.iter()
.enumerate()
.map(|(index, item)| {
render_row(
item.render_row(
cx,
index,
items.len(),
item,
self.selected == Some(index),
&self.on_change,
&focus,
@@ -187,65 +191,67 @@ impl RenderOnce for List {
}
}
fn render_row(
cx: &App,
index: usize,
count: usize,
item: &ListItem,
selected: bool,
on_change: &Option<ChangeHandler>,
focus: &FocusHandle,
) -> AnyElement {
let theme = cx.theme();
let label_color = selectable_label_color(cx, selected, item.disabled);
let disabled = item.disabled;
impl ListItem {
fn render_row(
&self,
cx: &App,
index: usize,
count: usize,
selected: bool,
on_change: &Option<ChangeHandler>,
focus: &FocusHandle,
) -> AnyElement {
let theme = cx.theme();
let label_color = selectable_label_color(cx, selected, self.disabled);
let disabled = self.disabled;
div()
.id(("list-item", index))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius.sm)
.role(Role::ListItem)
.aria_selected(selected)
.aria_position_in_set(index + 1)
.aria_size_of_set(count)
.map(|row| paint_selectable_row(cx, row, selected, disabled))
.when(!disabled, |row| {
row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
div()
.id((self.key.clone(), "list-item"))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius.sm)
.role(Role::ListItem)
.aria_selected(selected)
.aria_position_in_set(index + 1)
.aria_size_of_set(count)
.map(|row| paint_selectable_row(cx, row, selected, disabled))
.when(!disabled, |row| {
row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
}
}
}
})
})
})
.when_some(item.icon.clone(), |row, icon| {
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
})
.child(
div()
.flex_1()
.text_color(label_color)
.child(item.label.clone()),
)
.when_some(item.detail.clone(), |row, detail| {
row.child(
.when_some(self.icon.clone(), |row, icon| {
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
})
.child(
div()
.flex_none()
.text_sm()
.text_color(if selected {
theme.colors.primary_foreground
} else {
theme.colors.muted_foreground
})
.child(detail),
.flex_1()
.text_color(label_color)
.child(self.label.clone()),
)
})
.into_any_element()
.when_some(self.detail.clone(), |row, detail| {
row.child(
div()
.flex_none()
.text_sm()
.text_color(if selected {
theme.colors.primary_foreground
} else {
theme.colors.muted_foreground
})
.child(detail),
)
})
.into_any_element()
}
}
+100 -85
View File
@@ -18,11 +18,13 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
type EntriesFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<SidebarEntry>>;
/// One entry of a [`Sidebar`], a section header or a selectable row.
#[must_use]
pub enum SidebarEntry {
/// A non-selectable section label.
Header { label: SharedString },
/// A selectable row.
Row {
key: ElementId,
label: SharedString,
detail: Option<SharedString>,
prefix: Option<AnyElement>,
@@ -42,12 +44,14 @@ impl SidebarEntry {
}
}
/// Creates a selectable row.
pub fn row<L>(label: L) -> Self
/// Creates a selectable row shown as `label`, identified by `key`.
pub fn row<K, L>(key: K, label: L) -> Self
where
K: Into<ElementId>,
L: Into<SharedString>,
{
Self::Row {
key: key.into(),
label: label.into(),
detail: None,
prefix: None,
@@ -107,6 +111,7 @@ impl SidebarEntry {
/// A sidebar.
#[derive(IntoElement)]
#[must_use]
pub struct Sidebar {
id: ElementId,
selected: Option<usize>,
@@ -158,7 +163,7 @@ impl Sidebar {
self
}
/// Sets the entries, built fresh each render.
/// Sets the entries, the closure runs during render and must not mutate entities or notify.
pub fn entries<F>(mut self, entries: F) -> Self
where
F: Fn(&mut Window, &mut App) -> Vec<SidebarEntry> + 'static,
@@ -222,17 +227,19 @@ impl RenderOnce for Sidebar {
.enumerate()
.map(|(index, entry)| {
let is_row = matches!(entry, SidebarEntry::Row { .. });
if is_row {
row_position += 1;
}
let (position_in_set, size_of_set) = if is_row {
(row_position, row_count)
} else {
(0, 0)
};
render_entry(
entry.render_entry(
cx,
entry,
index,
position_in_set,
size_of_set,
@@ -282,91 +289,99 @@ impl RenderOnce for Sidebar {
}
}
#[allow(clippy::too_many_arguments)]
fn render_entry(
cx: &App,
entry: SidebarEntry,
index: usize,
position_in_set: usize,
size_of_set: usize,
selected: bool,
material: bool,
on_change: &Option<ChangeHandler>,
focus: &FocusHandle,
) -> AnyElement {
let theme = cx.theme();
impl SidebarEntry {
#[allow(clippy::too_many_arguments)]
fn render_entry(
self,
cx: &App,
index: usize,
position_in_set: usize,
size_of_set: usize,
selected: bool,
material: bool,
on_change: &Option<ChangeHandler>,
focus: &FocusHandle,
) -> AnyElement {
let theme = cx.theme();
match entry {
SidebarEntry::Header { label } => div()
.px_2()
.pt_2()
.pb_1()
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(theme.colors.muted_foreground)
.child(label)
.into_any_element(),
SidebarEntry::Row {
label,
detail,
prefix,
suffix,
disabled: row_disabled,
} => {
let label_color = selectable_label_color(cx, selected, row_disabled);
match self {
SidebarEntry::Header { label } => {
// The id is required for the role to surface in the a11y tree.
div()
.id(("sidebar-header".into(), label.clone()))
.px_2()
.pt_2()
.pb_1()
.text_sm()
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(theme.colors.muted_foreground)
.role(Role::Heading)
.child(label)
.into_any_element()
}
SidebarEntry::Row {
key,
label,
detail,
prefix,
suffix,
disabled: row_disabled,
} => {
let label_color = selectable_label_color(cx, selected, row_disabled);
div()
.id(("sidebar-row", index))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius.sm)
.role(Role::ListItem)
.aria_selected(selected)
.aria_position_in_set(position_in_set)
.aria_size_of_set(size_of_set)
.map(|row| {
if material && !row_disabled {
if selected {
row.bg(theme.material.selection)
} else {
row.hover(|row| row.bg(theme.material.hover))
}
} else {
paint_selectable_row(cx, row, selected, row_disabled)
}
})
.when(!row_disabled, |row| {
row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
div()
.id((key.clone(), "sidebar-row"))
.flex()
.items_center()
.gap_2()
.px_2()
.py_1()
.rounded(theme.radius.sm)
.role(Role::ListItem)
.aria_selected(selected)
.aria_position_in_set(position_in_set)
.aria_size_of_set(size_of_set)
.map(|row| {
if material && !row_disabled {
if selected {
row.bg(theme.material.selection)
} else {
row.hover(|row| row.bg(theme.material.hover))
}
} else {
paint_selectable_row(cx, row, selected, row_disabled)
}
})
})
.when_some(prefix, |row, prefix| row.child(prefix))
.child(div().flex_1().text_color(label_color).child(label))
.when_some(detail, |row, detail| {
row.child(
div()
.flex_none()
.text_sm()
.text_color(if selected {
theme.colors.primary_foreground
} else {
theme.colors.muted_foreground
})
.child(detail),
)
})
.when_some(suffix, |row, suffix| row.child(suffix))
.into_any_element()
.when(!row_disabled, |row| {
row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
}
}
})
})
.when_some(prefix, |row, prefix| row.child(prefix))
.child(div().flex_1().text_color(label_color).child(label))
.when_some(detail, |row, detail| {
row.child(
div()
.flex_none()
.text_sm()
.text_color(if selected {
theme.colors.primary_foreground
} else {
theme.colors.muted_foreground
})
.child(detail),
)
})
.when_some(suffix, |row, suffix| row.child(suffix))
.into_any_element()
}
}
}
}
+139 -137
View File
@@ -31,6 +31,7 @@ pub enum TabsVariant {
}
/// One tab of a [`Tabs`] strip.
#[must_use]
pub struct Tab {
/// The stable identity for element state; must survive reordering.
key: ElementId,
@@ -102,6 +103,7 @@ impl FluentBuilder for Tab {}
/// A tab strip.
#[derive(IntoElement)]
#[must_use]
pub struct Tabs {
id: ElementId,
selected: usize,
@@ -129,7 +131,7 @@ impl Tabs {
}
}
/// Sets the current selection.
/// Sets the current selection, as an index into the `tabs` vec — not a tab key.
pub fn selected(mut self, selected: usize) -> Self {
self.selected = selected;
self
@@ -141,7 +143,7 @@ impl Tabs {
self
}
/// Reports the next selected tab, from clicks and keys alike.
/// Reports the next selected tab, as an index into the `tabs` vec — not a tab key; indices shift on reorder.
pub fn on_change<F>(mut self, handler: F) -> Self
where
F: Fn(usize, &mut Window, &mut App) + 'static,
@@ -150,7 +152,7 @@ impl Tabs {
self
}
/// Reports a closed tab by index.
/// Reports a closed tab, as an index into the `tabs` vec — not a tab key; indices shift on reorder.
pub fn on_close<F>(mut self, handler: F) -> Self
where
F: Fn(usize, &mut Window, &mut App) + 'static,
@@ -159,7 +161,7 @@ impl Tabs {
self
}
/// Sets the tabs, built fresh each render.
/// Sets the tabs; the closure runs during render and must not mutate entities or notify.
pub fn tabs<F>(mut self, tabs: F) -> Self
where
F: Fn(&mut Window, &mut App) -> Vec<Tab> + 'static,
@@ -204,10 +206,9 @@ impl RenderOnce for Tabs {
.into_iter()
.enumerate()
.map(|(index, tab)| {
render_tab(
tab.render_tab(
&self.id,
cx,
tab,
index,
self.selected,
self.variant,
@@ -257,145 +258,146 @@ impl RenderOnce for Tabs {
}
}
#[allow(clippy::too_many_arguments)]
fn render_tab(
tabs_id: &ElementId,
cx: &App,
tab: Tab,
index: usize,
selected_index: usize,
variant: TabsVariant,
on_change: &Option<ChangeHandler>,
on_close: &Option<CloseHandler>,
focus: &gpui::FocusHandle,
) -> AnyElement {
let mut tab = tab;
let selected = index == selected_index;
impl Tab {
#[allow(clippy::too_many_arguments)]
fn render_tab(
mut self,
tabs_id: &ElementId,
cx: &App,
index: usize,
selected_index: usize,
variant: TabsVariant,
on_change: &Option<ChangeHandler>,
on_close: &Option<CloseHandler>,
focus: &gpui::FocusHandle,
) -> AnyElement {
let selected = index == selected_index;
let theme = cx.theme();
let metrics = theme.sizes.resolve(Size::default());
let small = theme.sizes.small;
let theme = cx.theme();
let metrics = theme.sizes.resolve(Size::default());
let small = theme.sizes.small;
let label_color = match variant {
TabsVariant::Underline => {
if tab.disabled {
theme.colors.muted_foreground
} else if selected {
theme.ramps.primary.base
} else {
theme.colors.foreground
let label_color = match variant {
TabsVariant::Underline => {
if self.disabled {
theme.colors.muted_foreground
} else if selected {
theme.ramps.primary.base
} else {
theme.colors.foreground
}
}
}
TabsVariant::Pill => {
if tab.disabled {
theme.colors.muted_foreground
} else {
theme.colors.foreground
TabsVariant::Pill => {
if self.disabled {
theme.colors.muted_foreground
} else {
theme.colors.foreground
}
}
}
};
};
let strip_tab = div()
.id((tab.key.clone(), "tab"))
.debug_selector({
let tabs_id = tabs_id.clone();
let label = tab.label.clone();
move || format!("{tabs_id}-tab-{}", label)
})
.flex()
.items_center()
.gap_1p5()
.role(Role::Tab)
.aria_selected(selected)
.text_color(label_color)
.when(!tab.disabled && !selected, |strip_tab| {
strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted))
})
.map(|strip_tab| match variant {
TabsVariant::Underline => strip_tab
.px_2()
.py_1()
.rounded_tl(theme.radius.sm)
.rounded_tr(theme.radius.sm)
.border_b_2()
.border_color(gpui::transparent_black())
.when(selected, |strip_tab| {
strip_tab.border_color(theme.ramps.primary.base)
}),
TabsVariant::Pill => strip_tab
.h(small.control_height + metrics.plate_inset * 2.0)
.px(metrics.dense_padding_x)
.pr(metrics.plate_inset)
.rounded(theme.radius.control)
.when(selected, |strip_tab| {
strip_tab
.bg(theme.colors.muted)
.hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
}),
})
.when_some(tab.lead.take(), |strip_tab, lead| strip_tab.child(lead))
.child(tab.label.clone())
.when(variant == TabsVariant::Pill, |strip_tab| {
strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5))
})
.when(tab.busy && !tab.disabled, |strip_tab| {
strip_tab.child(
Spinner::new((tab.key.clone(), "tab-busy"))
.small()
.accessibility_label("Loading"),
)
})
.when(
tab.closeable && on_close.is_some() && !tab.disabled,
|strip_tab| strip_tab.child(render_tab_close(tabs_id, &tab, index, on_close)),
)
.when(!tab.disabled, |strip_tab| {
strip_tab.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
let close = (self.closeable && on_close.is_some() && !self.disabled)
.then(|| self.render_tab_close(tabs_id, index, on_close));
let strip_tab = div()
.id((self.key.clone(), "tab"))
.debug_selector({
let tabs_id = tabs_id.clone();
let label = self.label.clone();
move || format!("{tabs_id}-tab-{}", label)
})
.flex()
.items_center()
.gap_1p5()
.role(Role::Tab)
.aria_selected(selected)
.text_color(label_color)
.when(!self.disabled && !selected, |strip_tab| {
strip_tab.hover(|strip_tab| strip_tab.bg(theme.colors.muted))
})
.map(|strip_tab| match variant {
TabsVariant::Underline => strip_tab
.px_2()
.py_1()
.rounded_tl(theme.radius.sm)
.rounded_tr(theme.radius.sm)
.border_b_2()
.border_color(gpui::transparent_black())
.when(selected, |strip_tab| {
strip_tab.border_color(theme.ramps.primary.base)
}),
TabsVariant::Pill => strip_tab
.h(small.control_height + metrics.plate_inset * 2.0)
.px(metrics.dense_padding_x)
.pr(metrics.plate_inset)
.rounded(theme.radius.control)
.when(selected, |strip_tab| {
strip_tab
.bg(theme.colors.muted)
.hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
}),
})
.when_some(self.lead.take(), |strip_tab, lead| strip_tab.child(lead))
.child(self.label.clone())
.when(variant == TabsVariant::Pill, |strip_tab| {
strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5))
})
.when(self.busy && !self.disabled, |strip_tab| {
strip_tab.child(
Spinner::new((self.key.clone(), "tab-busy"))
.small()
.accessibility_label("Loading"),
)
})
.when_some(close, |strip_tab, close| strip_tab.child(close))
.when(!self.disabled, |strip_tab| {
strip_tab.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if index != selected_index
&& let Some(on_change) = &on_change
{
on_change(index, window, cx);
}
}
})
});
match self.wrap {
Some(wrap) => wrap(strip_tab),
None => strip_tab.into_any_element(),
}
}
fn render_tab_close(
&self,
tabs_id: &ElementId,
index: usize,
on_close: &Option<CloseHandler>,
) -> AnyElement {
Button::new((self.key.clone(), "tab-close"))
.icon(IconRole::Close)
.variant(ButtonVariant::Ghost)
.small()
.tab_stop(false)
.debug_selector({
let tabs_id = tabs_id.clone();
let label = self.label.clone();
move || format!("{tabs_id}-close-{}", label)
})
.tooltip(Tooltip::text("Close"))
.accessibility_label(SharedString::from(format!("Close {}", self.label)))
.on_click({
let on_close = on_close.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if index != selected_index
&& let Some(on_change) = &on_change
{
on_change(index, window, cx);
cx.stop_propagation();
if let Some(on_close) = &on_close {
on_close(index, window, cx);
}
}
})
});
match tab.wrap {
Some(wrap) => wrap(strip_tab),
None => strip_tab.into_any_element(),
.into_any_element()
}
}
fn render_tab_close(
tabs_id: &ElementId,
tab: &Tab,
index: usize,
on_close: &Option<CloseHandler>,
) -> AnyElement {
Button::new((tab.key.clone(), "tab-close"))
.icon(IconRole::Close)
.variant(ButtonVariant::Ghost)
.small()
.tab_stop(false)
.debug_selector({
let tabs_id = tabs_id.clone();
let label = tab.label.clone();
move || format!("{tabs_id}-close-{}", label)
})
.tooltip(Tooltip::text("Close"))
.accessibility_label(SharedString::from(format!("Close {}", tab.label)))
.on_click({
let on_close = on_close.clone();
move |_, window, cx| {
cx.stop_propagation();
if let Some(on_close) = &on_close {
on_close(index, window, cx);
}
}
})
.into_any_element()
}
+175 -148
View File
@@ -18,7 +18,9 @@ type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<TreeItem>>;
/// Tree item.
#[derive(Clone)]
#[must_use]
pub struct TreeItem {
key: ElementId,
label: SharedString,
icon: Option<Icon>,
depth: usize,
@@ -28,12 +30,14 @@ pub struct TreeItem {
}
impl TreeItem {
/// Creates a leaf row with this label at the root depth.
pub fn new<L>(label: L) -> Self
/// Creates a leaf row shown as `label` at the root depth, identified by `key`.
pub fn new<K, L>(key: K, label: L) -> Self
where
K: Into<ElementId>,
L: Into<SharedString>,
{
Self {
key: key.into(),
label: label.into(),
icon: None,
depth: 0,
@@ -79,6 +83,7 @@ impl TreeItem {
/// A tree.
#[derive(IntoElement)]
#[must_use]
pub struct Tree {
id: ElementId,
selected: Option<usize>,
@@ -128,7 +133,7 @@ impl Tree {
self
}
/// Sets the visible rows, built fresh each render.
/// Sets the visible rows; the closure runs during render and must not mutate entities or notify.
pub fn items<F>(mut self, items: F) -> Self
where
F: Fn(&mut Window, &mut App) -> Vec<TreeItem> + 'static,
@@ -145,6 +150,45 @@ impl Tree {
self.a11y_label = Some(label.into());
self
}
/// Maps each visible row to its 1-based position-in-set and set size among its siblings.
fn sibling_sets(items: &[TreeItem]) -> Vec<(usize, usize)> {
let mut sets = vec![(0usize, 0usize); items.len()];
// Open sibling runs along the ancestry path, deeper runs above shallower ones.
let mut runs: Vec<(usize, Vec<usize>)> = Vec::new();
for (index, item) in items.iter().enumerate() {
while runs
.last()
.is_some_and(|(run_depth, _)| *run_depth > item.depth)
{
if let Some((_, pending)) = runs.pop() {
Self::stamp_sibling_run(&mut sets, pending);
}
}
match runs.last_mut() {
// A row at the running depth extends the open sibling run.
Some((run_depth, pending)) if *run_depth == item.depth => pending.push(index),
_ => runs.push((item.depth, vec![index])),
}
}
for (_, pending) in runs {
Self::stamp_sibling_run(&mut sets, pending);
}
sets
}
fn stamp_sibling_run(sets: &mut [(usize, usize)], pending: Vec<usize>) {
let size_of_set = pending.len();
for (position, index) in pending.into_iter().enumerate() {
if let Some(set) = sets.get_mut(index) {
*set = (position + 1, size_of_set);
}
}
}
}
impl RenderOnce for Tree {
@@ -169,46 +213,15 @@ impl RenderOnce for Tree {
.clone()
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
let sibling_sets: Vec<(usize, usize)> = {
let mut sets = vec![(0usize, 0usize); items.len()];
let mut start = 0usize;
while start < items.len() {
let depth = items[start].depth;
let mut end = start + 1;
while end < items.len() && items[end].depth > depth {
end += 1;
}
let mut position = 0usize;
for index in start..end {
if items[index].depth == depth {
position += 1;
sets[index] = (position, 0);
}
}
for index in start..end {
if items[index].depth == depth {
sets[index].1 = position;
}
}
start = end;
}
sets
};
let sibling_sets = Self::sibling_sets(&items);
let rows: Vec<AnyElement> = items
.iter()
.enumerate()
.map(|(index, _item)| {
.map(|(index, item)| {
let (position_in_set, size_of_set) = sibling_sets[index];
render_row(
item.render_row(
cx,
&items[index],
index,
position_in_set,
size_of_set,
@@ -266,121 +279,135 @@ impl RenderOnce for Tree {
}
}
#[allow(clippy::too_many_arguments)]
fn render_row(
cx: &App,
item: &TreeItem,
index: usize,
position_in_set: usize,
size_of_set: usize,
selected: bool,
on_change: &Option<ChangeHandler>,
on_disclosure: &Option<DisclosureHandler>,
focus: &FocusHandle,
) -> AnyElement {
let theme = cx.theme();
let label_color = selectable_label_color(cx, selected, item.disabled);
let disabled = item.disabled;
// One indent step per depth level, on the theme's horizontal padding.
let indent = theme.sizes.medium.padding_x * (item.depth as f32 + 1.0);
impl TreeItem {
#[allow(clippy::too_many_arguments)]
fn render_row(
&self,
cx: &App,
index: usize,
position_in_set: usize,
size_of_set: usize,
selected: bool,
on_change: &Option<ChangeHandler>,
on_disclosure: &Option<DisclosureHandler>,
focus: &FocusHandle,
) -> AnyElement {
let theme = cx.theme();
let label_color = selectable_label_color(cx, selected, self.disabled);
let disabled = self.disabled;
// usize can exceed f32's exact integer range; bound it via u32 before casting.
let depth = u32::try_from(self.depth).unwrap_or(u32::MAX) as f32;
// One indent step per depth level, on the theme's horizontal padding.
let indent = theme.sizes.medium.padding_x * (depth + 1.0);
div()
.id(("tree-item", index))
.flex()
.items_center()
.gap_2()
.pl(indent)
.pr_2()
.py_1()
.rounded(theme.radius.sm)
.role(Role::TreeItem)
.aria_selected(selected)
.aria_level(item.depth + 1)
.aria_position_in_set(position_in_set)
.aria_size_of_set(size_of_set)
.when(item.expandable, |row| row.aria_expanded(item.expanded))
.map(|row| paint_selectable_row(cx, row, selected, disabled))
.when(!disabled, |row| {
row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
div()
.id((self.key.clone(), "tree-item"))
.flex()
.items_center()
.gap_2()
.pl(indent)
.pr_2()
.py_1()
.rounded(theme.radius.sm)
.role(Role::TreeItem)
.aria_selected(selected)
.aria_level(self.depth + 1)
.aria_position_in_set(position_in_set)
.aria_size_of_set(size_of_set)
.when(self.expandable, |row| row.aria_expanded(self.expanded))
.map(|row| paint_selectable_row(cx, row, selected, disabled))
.when(!disabled, |row| {
row.cursor_pointer().on_click({
let on_change = on_change.clone();
let focus = focus.clone();
move |_, window, cx| {
window.focus(&focus, cx);
if let Some(on_change) = &on_change {
on_change(Some(index), window, cx);
}
}
}
})
})
})
.child(render_disclosure(
cx,
item,
index,
disabled,
selected,
on_disclosure,
))
.when_some(item.icon.clone(), |row, icon| {
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
})
.child(
div()
.flex_1()
.text_color(label_color)
.child(item.label.clone()),
)
.into_any_element()
}
/// The disclosure slot.
fn render_disclosure(
cx: &App,
item: &TreeItem,
index: usize,
disabled: bool,
selected: bool,
on_disclosure: &Option<DisclosureHandler>,
) -> AnyElement {
let theme = cx.theme();
let glyph_color = if disabled {
theme.colors.muted_foreground
} else if selected {
theme.colors.primary_foreground
} else {
theme.colors.muted_foreground
};
let cell = div()
.flex_none()
.size(theme.sizes.medium.icon_size)
.flex()
.items_center()
.justify_center()
.child(
icon(if item.expanded {
IconRole::ChevronDown
} else {
IconRole::ChevronRight
})
.size(theme.sizes.medium.dense_icon_size)
.color(glyph_color),
);
if item.expandable && !disabled {
cell.id(("tree-disclosure", index))
.cursor_pointer()
.on_click({
let on_disclosure = on_disclosure.clone();
move |_, window, cx| {
if let Some(on_disclosure) = &on_disclosure {
on_disclosure(index, window, cx);
}
// The toggle is not a selection: keep it off the row.
cx.stop_propagation();
}
.child(self.render_disclosure(cx, index, selected, on_disclosure))
.when_some(self.icon.clone(), |row, icon| {
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color))
})
.child(
div()
.flex_1()
.text_color(label_color)
.child(self.label.clone()),
)
.into_any_element()
} else {
cell.into_any_element()
}
fn render_disclosure(
&self,
cx: &App,
index: usize,
selected: bool,
on_disclosure: &Option<DisclosureHandler>,
) -> AnyElement {
let theme = cx.theme();
let glyph_color = if self.disabled || !selected {
theme.colors.muted_foreground
} else {
theme.colors.primary_foreground
};
let cell = div()
.flex_none()
.size(theme.sizes.medium.icon_size)
.flex()
.items_center()
.justify_center()
.child(
icon(if self.expanded {
IconRole::ChevronDown
} else {
IconRole::ChevronRight
})
.size(theme.sizes.medium.dense_icon_size)
.color(glyph_color),
);
if self.expandable && !self.disabled {
cell.id((self.key.clone(), "tree-disclosure"))
.cursor_pointer()
.on_click({
let on_disclosure = on_disclosure.clone();
move |_, window, cx| {
if let Some(on_disclosure) = &on_disclosure {
on_disclosure(index, window, cx);
}
// The toggle is not a selection: keep it off the row.
cx.stop_propagation();
}
})
.into_any_element()
} else {
cell.into_any_element()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(depth: usize) -> TreeItem {
TreeItem::new("item", "Item").depth(depth)
}
#[test]
fn flat_rows_number_one_of_three() {
let sets = Tree::sibling_sets(&[item(0), item(0), item(0)]);
assert_eq!(sets, vec![(1, 3), (2, 3), (3, 3)]);
}
#[test]
fn nested_rows_number_per_level() {
let sets = Tree::sibling_sets(&[item(0), item(1), item(2), item(1), item(0)]);
assert_eq!(sets, vec![(1, 2), (1, 2), (1, 1), (2, 2), (2, 2)]);
}
}
+4 -4
View File
@@ -28,10 +28,10 @@ impl Render for Harness {
})
.items(|_, _| {
vec![
ListItem::new("One"),
ListItem::new("Two"),
ListItem::new("Unavailable").disabled(true),
ListItem::new("Three"),
ListItem::new("one", "One"),
ListItem::new("two", "Two"),
ListItem::new("unavailable", "Unavailable").disabled(true),
ListItem::new("three", "Three"),
]
}),
)
+5 -5
View File
@@ -30,11 +30,11 @@ impl Render for Harness {
})
.entries(|_, _| {
vec![
SidebarEntry::header("Favorites"), // 0: never selectable
SidebarEntry::row("Documents"), // 1
SidebarEntry::row("Locked").disabled(true), // 2
SidebarEntry::header("Locations"), // 3: never selectable
SidebarEntry::row("Trash"), // 4
SidebarEntry::header("Favorites"), // 0: never selectable
SidebarEntry::row("documents", "Documents"), // 1
SidebarEntry::row("locked", "Locked").disabled(true), // 2
SidebarEntry::header("Locations"), // 3: never selectable
SidebarEntry::row("trash", "Trash"), // 4
]
}),
)
+66
View File
@@ -8,6 +8,7 @@ use gpui_native_data::tabs::{Tab, Tabs};
use gpui_native_theme::Theme;
type Changes = Rc<RefCell<Vec<usize>>>;
type Closes = Rc<RefCell<Vec<usize>>>;
struct Harness {
selected: usize,
@@ -64,6 +65,55 @@ fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
}
struct CloseHarness {
changes: Changes,
closes: Closes,
}
impl Render for CloseHarness {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let changes = self.changes.clone();
let closes = self.closes.clone();
let view = cx.entity();
let close_view = view.clone();
div().size_full().child(
Tabs::new("close-tabs")
.on_change(move |next, _, cx| {
changes.borrow_mut().push(next);
view.update(cx, |_, cx| cx.notify());
})
.on_close(move |index, _, cx| {
closes.borrow_mut().push(index);
close_view.update(cx, |_, cx| cx.notify());
})
.tabs(|_, _| {
vec![
Tab::new("one", "One").closeable(true),
Tab::new("two", "Two"),
]
}),
)
}
}
fn close_tabs_window() -> (TestAppContext, VisualTestContext, Changes, Closes) {
let mut cx = TestAppContext::single();
cx.update(Theme::init);
let changes = Rc::new(RefCell::new(Vec::new()));
let closes = Rc::new(RefCell::new(Vec::new()));
let harness_changes = changes.clone();
let harness_closes = closes.clone();
let window = cx.add_window(move |_, _| CloseHarness {
changes: harness_changes,
closes: harness_closes,
});
let mut visual = VisualTestContext::from_window(window.into(), &cx);
visual.update(|window, cx| {
window.draw(cx).clear(cx);
});
(cx, visual, changes, closes)
}
#[test]
fn clicking_a_tab_selects_it_and_hands_the_strip_the_keyboard() {
let (_cx, mut visual, changes) = tabs_window();
@@ -100,3 +150,19 @@ fn arrow_keys_skip_disabled_tabs_and_never_wrap() {
visual.simulate_keystrokes("end"); // End
assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]);
}
#[test]
fn clicking_the_close_control_reports_a_close_without_selecting() {
let (_cx, mut visual, changes, closes) = close_tabs_window();
// Only the closeable tab ships a close control.
let selector: &'static str = Box::leak(String::from("close-tabs-close-One").into_boxed_str());
let close_bounds = visual
.debug_bounds(selector)
.expect("closeable tab renders a close control");
assert!(visual.debug_bounds("close-tabs-close-Two").is_none());
click(&mut visual, close_bounds.center());
assert_eq!(*closes.borrow(), vec![0]);
assert!(changes.borrow().is_empty(), "a close is not a selection");
}
+9 -5
View File
@@ -38,14 +38,18 @@ impl Render for Harness {
})
.items(|_, _| {
vec![
TreeItem::new("Root")
TreeItem::new("root", "Root")
.depth(0)
.expandable(true)
.expanded(true),
TreeItem::new("Child").depth(1),
TreeItem::new("Collapsed").depth(0).expandable(true),
TreeItem::new("Untouchable").depth(1).disabled(true),
TreeItem::new("Last").depth(0),
TreeItem::new("child", "Child").depth(1),
TreeItem::new("collapsed", "Collapsed")
.depth(0)
.expandable(true),
TreeItem::new("untouchable", "Untouchable")
.depth(1)
.disabled(true),
TreeItem::new("last", "Last").depth(0),
]
}),
)