feat: replace sidebar row icon with prefix and suffix slots

This commit is contained in:
2026-11-10 07:43:30 +07:00
parent 322f037289
commit fbc4f24d16
4 changed files with 88 additions and 64 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ GPUI is a UI framework which also provides primitives for state and concurrency
## Rendering and state ## Rendering and state
- `Render` builds an element tree from entity state; `RenderOnce` (with `#[derive(IntoElement)]`) is for value-like components whose `render` takes `self` and `&mut App`. - `Render` builds an element tree from entity state; `RenderOnce` (with `#[derive(IntoElement)]`) is for value-like components whose `render` takes `self` and `&mut App`.
- Keep `render` declarative; use `.when(...)` / `.when_some(...)` for small conditional refinements. - Keep `render` declarative: build elements as one chained expression, using `.when(...)` / `.when_some(...)` / `.map(...)` for conditional refinements instead of `let mut` element variables reassigned under `if` / `if let`.
- Controlled components: the owner passes the current value, callbacks report intent, the owner updates state and calls `cx.notify()`. No second source of truth. - Controlled components: the owner passes the current value, callbacks report intent, the owner updates state and calls `cx.notify()`. No second source of truth.
- Call `cx.notify()` after a mutation that changes rendering; notify the narrowest owning entity. Never notify from `render`. - Call `cx.notify()` after a mutation that changes rendering; notify the narrowest owning entity. Never notify from `render`.
- `render` is side-effect free: no `cx.notify()`, no `entity.update(...)`, no writing derived text or state back into other entities during render. A write there forces another frame, and a write that emits an event re-enters the event handler — an unbounded redraw loop. Reconcile state in event handlers instead, with silent setters (`set_value_silent`-style) so a control's own write does not re-enter the events it mirrors. - `render` is side-effect free: no `cx.notify()`, no `entity.update(...)`, no writing derived text or state back into other entities during render. A write there forces another frame, and a write that emits an event re-enters the event handler — an unbounded redraw loop. Reconcile state in event handlers instead, with silent setters (`set_value_silent`-style) so a control's own write does not re-enter the events it mirrors.
+64 -58
View File
@@ -10,7 +10,6 @@ use gpui::{
Styled as _, Window, div, Styled as _, Window, div,
}; };
use gpui_native_core::scroll_area::ScrollArea; use gpui_native_core::scroll_area::ScrollArea;
use gpui_native_icons::Icon;
use gpui_native_theme::ActiveTheme as _; use gpui_native_theme::ActiveTheme as _;
use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color}; use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color};
@@ -19,15 +18,15 @@ type ChangeHandler = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
type EntriesFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<SidebarEntry>>; type EntriesFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<SidebarEntry>>;
/// One entry of a [`Sidebar`], a section header or a selectable row. /// One entry of a [`Sidebar`], a section header or a selectable row.
#[derive(Clone)]
pub enum SidebarEntry { pub enum SidebarEntry {
/// A non-selectable section label. /// A non-selectable section label.
Header { label: SharedString }, Header { label: SharedString },
/// A selectable row. /// A selectable row.
Row { Row {
label: SharedString, label: SharedString,
icon: Option<Icon>,
detail: Option<SharedString>, detail: Option<SharedString>,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
disabled: bool, disabled: bool,
}, },
} }
@@ -50,23 +49,13 @@ impl SidebarEntry {
{ {
Self::Row { Self::Row {
label: label.into(), label: label.into(),
icon: None,
detail: None, detail: None,
prefix: None,
suffix: None,
disabled: false, disabled: false,
} }
} }
/// Shows an icon leading the label, a role or any custom [`IconNamed`].
pub fn icon<I>(mut self, icon: I) -> Self
where
I: Into<Icon>,
{
if let Self::Row { icon: slot, .. } = &mut self {
*slot = Some(icon.into());
}
self
}
/// Shows muted trailing text. /// Shows muted trailing text.
pub fn detail<D>(mut self, detail: D) -> Self pub fn detail<D>(mut self, detail: D) -> Self
where where
@@ -81,6 +70,28 @@ impl SidebarEntry {
self self
} }
/// Shows an element leading the row.
pub fn prefix<E>(mut self, prefix: E) -> Self
where
E: IntoElement,
{
if let Self::Row { prefix: slot, .. } = &mut self {
*slot = Some(prefix.into_any_element());
}
self
}
/// Shows an element trailing the row.
pub fn suffix<E>(mut self, suffix: E) -> Self
where
E: IntoElement,
{
if let Self::Row { suffix: slot, .. } = &mut self {
*slot = Some(suffix.into_any_element());
}
self
}
/// Renders the row dimmed; clicks and keyboard skip it. /// Renders the row dimmed; clicks and keyboard skip it.
pub fn disabled(mut self, disabled: bool) -> Self { pub fn disabled(mut self, disabled: bool) -> Self {
if let Self::Row { if let Self::Row {
@@ -207,7 +218,7 @@ impl RenderOnce for Sidebar {
let mut row_position = 0usize; let mut row_position = 0usize;
let children: Vec<AnyElement> = entries let children: Vec<AnyElement> = entries
.iter() .into_iter()
.enumerate() .enumerate()
.map(|(index, entry)| { .map(|(index, entry)| {
let is_row = matches!(entry, SidebarEntry::Row { .. }); let is_row = matches!(entry, SidebarEntry::Row { .. });
@@ -274,7 +285,7 @@ impl RenderOnce for Sidebar {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn render_entry( fn render_entry(
cx: &App, cx: &App,
entry: &SidebarEntry, entry: SidebarEntry,
index: usize, index: usize,
position_in_set: usize, position_in_set: usize,
size_of_set: usize, size_of_set: usize,
@@ -293,17 +304,18 @@ fn render_entry(
.text_sm() .text_sm()
.font_weight(gpui::FontWeight::MEDIUM) .font_weight(gpui::FontWeight::MEDIUM)
.text_color(theme.colors.muted_foreground) .text_color(theme.colors.muted_foreground)
.child(label.clone()) .child(label)
.into_any_element(), .into_any_element(),
SidebarEntry::Row { SidebarEntry::Row {
label, label,
icon: row_icon,
detail, detail,
prefix,
suffix,
disabled: row_disabled, disabled: row_disabled,
} => { } => {
let label_color = selectable_label_color(cx, selected, *row_disabled); let label_color = selectable_label_color(cx, selected, row_disabled);
let mut row = div() div()
.id(("sidebar-row", index)) .id(("sidebar-row", index))
.flex() .flex()
.items_center() .items_center()
@@ -316,51 +328,45 @@ fn render_entry(
.aria_position_in_set(position_in_set) .aria_position_in_set(position_in_set)
.aria_size_of_set(size_of_set) .aria_size_of_set(size_of_set)
.map(|row| { .map(|row| {
if material && !*row_disabled { if material && !row_disabled {
if selected { if selected {
row.bg(theme.material.selection) row.bg(theme.material.selection)
} else { } else {
row.hover(|row| row.bg(theme.material.hover)) row.hover(|row| row.bg(theme.material.hover))
} }
} else { } else {
paint_selectable_row(cx, row, selected, *row_disabled) paint_selectable_row(cx, row, selected, row_disabled)
} }
}); })
.when(!row_disabled, |row| {
if !*row_disabled { row.cursor_pointer().on_click({
row = row.cursor_pointer().on_click({ let on_change = on_change.clone();
let on_change = on_change.clone(); let focus = focus.clone();
let focus = focus.clone(); move |_, window, cx| {
move |_, window, cx| { window.focus(&focus, cx);
window.focus(&focus, cx); if let Some(on_change) = &on_change {
if let Some(on_change) = &on_change { on_change(Some(index), window, cx);
on_change(Some(index), window, cx); }
} }
} })
}); })
} .when_some(prefix, |row, prefix| row.child(prefix))
.child(div().flex_1().text_color(label_color).child(label))
row = row.when_some(row_icon.clone(), |row, icon| { .when_some(detail, |row, detail| {
row.child(icon.size(theme.sizes.medium.icon_size).color(label_color)) row.child(
}); div()
.flex_none()
row = row.child(div().flex_1().text_color(label_color).child(label.clone())); .text_sm()
.text_color(if selected {
if let Some(detail) = detail { theme.colors.primary_foreground
row = row.child( } else {
div() theme.colors.muted_foreground
.flex_none() })
.text_sm() .child(detail),
.text_color(if selected { )
theme.colors.primary_foreground })
} else { .when_some(suffix, |row, suffix| row.child(suffix))
theme.colors.muted_foreground .into_any_element()
})
.child(detail.clone()),
);
}
row.into_any_element()
} }
} }
} }
+7
View File
@@ -9,6 +9,13 @@ is the first downstream consumer; its screens drive the order.
## Next ## Next
- [ ] **Declarative render audit** — run the AGENTS.md render rule over the
other landed components: replace `let mut` element variables reassigned
under `if` / `if let` with one chained expression using `.when(...)` /
`.when_some(...)` / `.map(...)`. The sidebar row is the model; sweep
`crates/core`, `crates/data`, `crates/editor`, `crates/layout`, and
re-run tests plus a gallery pass after each crate.
## Queue — to follow ## Queue — to follow
Platform metric adoption continues one component at a time; each stop goes Platform metric adoption continues one component at a time; each stop goes
+16 -5
View File
@@ -3739,16 +3739,27 @@ impl Render for SidebarDemo {
cx.notify(); cx.notify();
}); });
}) })
.entries(|_, _| { .entries(|_, cx| {
let theme = cx.theme();
let row_icon = || {
icon(IconRole::Folder).size(theme.sizes.medium.icon_size)
};
vec![ vec![
SidebarEntry::header("Favorites"), SidebarEntry::header("Favorites"),
SidebarEntry::row("Documents").icon(IconRole::Folder), SidebarEntry::row("Documents").prefix(row_icon()),
SidebarEntry::row("Pictures") SidebarEntry::row("Pictures")
.icon(IconRole::Folder) .prefix(row_icon())
.detail("24"), .detail("24")
.suffix(
Badge::new("new").variant(BadgeVariant::Accent),
),
SidebarEntry::row("Archive").disabled(true), SidebarEntry::row("Archive").disabled(true),
SidebarEntry::header("Locations"), SidebarEntry::header("Locations"),
SidebarEntry::row("iCloud").icon(IconRole::Folder), SidebarEntry::row("iCloud").prefix(row_icon()).suffix(
StatusDot::new("sidebar-demo-icloud-status")
.small()
.variant(StatusDotVariant::Success),
),
SidebarEntry::row("Trash"), SidebarEntry::row("Trash"),
] ]
}), }),