Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:47:29.535Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

list_item.rs

577 lines · 22.6 KB · rust
1use std::sync::Arc;
2
3use component::{Component, ComponentScope, example_group_with_title, single_example};
4use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, Role, px};
5use smallvec::SmallVec;
6
7use crate::{Disclosure, prelude::*};
8
9#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
10pub enum ListItemSpacing {
11    #[default]
12    Dense,
13    ExtraDense,
14    Sparse,
15}
16
17#[derive(Default)]
18enum EndSlotVisibility {
19    #[default]
20    Always,
21    OnHover,
22    SwapOnHover(AnyElement),
23}
24
25#[derive(IntoElement, RegisterComponent)]
26pub struct ListItem {
27    id: ElementId,
28    group_name: Option<SharedString>,
29    disabled: bool,
30    selected: bool,
31    spacing: ListItemSpacing,
32    indent_level: usize,
33    indent_step_size: Pixels,
34    /// A slot for content that appears before the children, like an icon or avatar.
35    start_slot: Option<AnyElement>,
36    /// A slot for content that appears after the children, usually on the other side of the header.
37    /// This might be a button, a disclosure arrow, a face pile, etc.
38    end_slot: Option<AnyElement>,
39    end_slot_visibility: EndSlotVisibility,
40    toggle: Option<bool>,
41    inset: bool,
42    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
43    on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
44    on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
45    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
46    on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>>,
47    children: SmallVec<[AnyElement; 2]>,
48    selectable: bool,
49    always_show_disclosure_icon: bool,
50    outlined: bool,
51    rounded: bool,
52    overflow_x: bool,
53    focused: Option<bool>,
54    docked_right: bool,
55    height: Option<DefiniteLength>,
56    aria_role: Option<Role>,
57    aria_label: Option<SharedString>,
58    aria_keyshortcuts: Option<SharedString>,
59    aria_checked: Option<bool>,
60    aria_active_descendant: bool,
61}
62
63impl ListItem {
64    pub fn new(id: impl Into<ElementId>) -> Self {
65        Self {
66            id: id.into(),
67            group_name: None,
68            disabled: false,
69            selected: false,
70            spacing: ListItemSpacing::Dense,
71            indent_level: 0,
72            indent_step_size: px(12.),
73            start_slot: None,
74            end_slot: None,
75            end_slot_visibility: EndSlotVisibility::default(),
76            toggle: None,
77            inset: false,
78            on_click: None,
79            on_secondary_mouse_down: None,
80            on_toggle: None,
81            on_hover: None,
82            tooltip: None,
83            children: SmallVec::new(),
84            selectable: true,
85            always_show_disclosure_icon: false,
86            outlined: false,
87            rounded: false,
88            overflow_x: false,
89            focused: None,
90            docked_right: false,
91            height: None,
92            aria_role: None,
93            aria_label: None,
94            aria_keyshortcuts: None,
95            aria_checked: None,
96            aria_active_descendant: false,
97        }
98    }
99
100    /// Sets the accessible role reported to assistive technology (e.g.
101    /// [`Role::MenuItem`] when this item is part of a menu). When unset, the
102    /// item is not reported as a distinct node, matching list defaults.
103    pub fn aria_role(mut self, role: Role) -> Self {
104        self.aria_role = Some(role);
105        self
106    }
107
108    /// Sets the label announced by assistive technology. List items render
109    /// arbitrary children, so this should be set when a meaningful textual
110    /// label exists.
111    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
112        self.aria_label = Some(label.into());
113        self
114    }
115
116    /// Sets the keyboard shortcut announced by assistive technology for this
117    /// item, e.g. a menu item's accelerator. Requires [`Self::aria_role`] to be
118    /// set. Use a human-friendly display string (the accelerator shown to
119    /// sighted users), e.g. `"Ctrl-S"`.
120    pub fn aria_keyshortcuts(mut self, keyshortcuts: impl Into<SharedString>) -> Self {
121        self.aria_keyshortcuts = Some(keyshortcuts.into());
122        self
123    }
124
125    /// Sets the checked state reported to assistive technology, independent of
126    /// the visual disclosure [`Self::toggle`]. Use this for checkable items
127    /// such as a [`Role::MenuItemCheckBox`] entry so screen readers announce
128    /// "checked"/"not checked" accurately.
129    pub fn aria_checked(mut self, checked: bool) -> Self {
130        self.aria_checked = Some(checked);
131        self
132    }
133
134    /// Reports this item as the accessibility focus, overriding the element
135    /// that holds real keyboard focus. Requires [`Self::aria_role`] to be set.
136    ///
137    /// Use this on the selected item of a composite widget (e.g. a menu) that
138    /// keeps keyboard focus on its container. It is honored only while the
139    /// container actually holds focus, so it is safe to set unconditionally on
140    /// the selected item. See [`StatefulInteractiveElement::aria_active_descendant`].
141    pub fn aria_active_descendant(mut self) -> Self {
142        self.aria_active_descendant = true;
143        self
144    }
145
146    pub fn group_name(mut self, group_name: impl Into<SharedString>) -> Self {
147        self.group_name = Some(group_name.into());
148        self
149    }
150
151    pub fn spacing(mut self, spacing: ListItemSpacing) -> Self {
152        self.spacing = spacing;
153        self
154    }
155
156    pub fn selectable(mut self, has_hover: bool) -> Self {
157        self.selectable = has_hover;
158        self
159    }
160
161    pub fn always_show_disclosure_icon(mut self, show: bool) -> Self {
162        self.always_show_disclosure_icon = show;
163        self
164    }
165
166    pub fn on_click(
167        mut self,
168        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
169    ) -> Self {
170        self.on_click = Some(Box::new(handler));
171        self
172    }
173
174    pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
175        self.on_hover = Some(Box::new(handler));
176        self
177    }
178
179    pub fn on_secondary_mouse_down(
180        mut self,
181        handler: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
182    ) -> Self {
183        self.on_secondary_mouse_down = Some(Box::new(handler));
184        self
185    }
186
187    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
188        self.tooltip = Some(Box::new(tooltip));
189        self
190    }
191
192    pub fn inset(mut self, inset: bool) -> Self {
193        self.inset = inset;
194        self
195    }
196
197    pub fn indent_level(mut self, indent_level: usize) -> Self {
198        self.indent_level = indent_level;
199        self
200    }
201
202    pub fn indent_step_size(mut self, indent_step_size: Pixels) -> Self {
203        self.indent_step_size = indent_step_size;
204        self
205    }
206
207    pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
208        self.toggle = toggle.into();
209        self
210    }
211
212    pub fn on_toggle(
213        mut self,
214        on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
215    ) -> Self {
216        self.on_toggle = Some(Arc::new(on_toggle));
217        self
218    }
219
220    pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
221        self.start_slot = start_slot.into().map(IntoElement::into_any_element);
222        self
223    }
224
225    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
226        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
227        self
228    }
229
230    pub fn end_slot_on_hover<E: IntoElement>(mut self, end_slot_on_hover: E) -> Self {
231        self.end_slot_visibility =
232            EndSlotVisibility::SwapOnHover(end_slot_on_hover.into_any_element());
233        self
234    }
235
236    pub fn show_end_slot_on_hover(mut self) -> Self {
237        self.end_slot_visibility = EndSlotVisibility::OnHover;
238        self
239    }
240
241    pub fn outlined(mut self) -> Self {
242        self.outlined = true;
243        self
244    }
245
246    pub fn rounded(mut self) -> Self {
247        self.rounded = true;
248        self
249    }
250
251    pub fn overflow_x(mut self) -> Self {
252        self.overflow_x = true;
253        self
254    }
255
256    pub fn focused(mut self, focused: bool) -> Self {
257        self.focused = Some(focused);
258        self
259    }
260
261    pub fn docked_right(mut self, docked_right: bool) -> Self {
262        self.docked_right = docked_right;
263        self
264    }
265
266    pub fn height(mut self, height: impl Into<DefiniteLength>) -> Self {
267        self.height = Some(height.into());
268        self
269    }
270}
271
272impl Disableable for ListItem {
273    fn disabled(mut self, disabled: bool) -> Self {
274        self.disabled = disabled;
275        self
276    }
277}
278
279impl Toggleable for ListItem {
280    fn toggle_state(mut self, selected: bool) -> Self {
281        self.selected = selected;
282        self
283    }
284}
285
286impl ParentElement for ListItem {
287    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
288        self.children.extend(elements)
289    }
290}
291
292impl RenderOnce for ListItem {
293    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
294        h_flex()
295            .id(self.id)
296            .when_some(self.group_name, |this, group| this.group(group))
297            .w_full()
298            .when_some(self.height, |this, height| this.h(height))
299            .relative()
300            // When an item is inset draw the indent spacing outside of the item
301            .when(self.inset, |this| {
302                this.ml(self.indent_level as f32 * self.indent_step_size)
303                    .px(DynamicSpacing::Base04.rems(cx))
304            })
305            .when(!self.inset, |this| {
306                this.when_some(self.focused, |this, focused| {
307                    if focused && !self.disabled {
308                        this.border_1()
309                            .when(self.docked_right, |this| this.border_r_2())
310                            .border_color(cx.theme().colors().border_focused)
311                    } else {
312                        this.border_1()
313                    }
314                })
315                .when(self.selectable && !self.disabled, |this| {
316                    this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
317                        .active(|style| style.bg(cx.theme().colors().ghost_element_active))
318                        .when(self.outlined, |this| this.rounded_sm())
319                        .when(self.selected, |this| {
320                            this.bg(cx.theme().colors().ghost_element_selected)
321                        })
322                })
323            })
324            .when(self.rounded, |this| this.rounded_sm())
325            .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover))
326            .child(
327                h_flex()
328                    .id("inner_list_item")
329                    // The accessible role/label live here, alongside the click
330                    // handler, so assistive technology reports one actionable
331                    // node (e.g. a menu item) rather than an inert container.
332                    .when_some(self.aria_role, |this, role| this.role(role))
333                    .when(
334                        self.aria_role.is_some() && self.aria_active_descendant,
335                        |this| this.aria_active_descendant(),
336                    )
337                    .when_some(self.aria_label, |this, label| this.aria_label(label))
338                    .when_some(self.aria_keyshortcuts, |this, keyshortcuts| {
339                        this.aria_keyshortcuts(keyshortcuts)
340                    })
341                    .when(self.aria_role.is_some(), |this| {
342                        this.aria_selected(self.selected).when_some(
343                            self.aria_checked.or(self.toggle),
344                            |this, toggled| {
345                                this.aria_toggled(if toggled {
346                                    gpui::Toggled::True
347                                } else {
348                                    gpui::Toggled::False
349                                })
350                            },
351                        )
352                    })
353                    .group("list_item")
354                    .w_full()
355                    .relative()
356                    .gap_1()
357                    .px(DynamicSpacing::Base06.rems(cx))
358                    .map(|this| match self.spacing {
359                        ListItemSpacing::Dense => this,
360                        ListItemSpacing::ExtraDense => this.py_neg_px(),
361                        ListItemSpacing::Sparse => this.py_1(),
362                    })
363                    .when(self.inset, |this| {
364                        this.when_some(self.focused, |this, focused| {
365                            if focused && !self.disabled {
366                                this.border_1()
367                                    .border_color(cx.theme().colors().border_focused)
368                            } else {
369                                this.border_1()
370                            }
371                        })
372                        .when(self.selectable && !self.disabled, |this| {
373                            this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
374                                .active(|style| style.bg(cx.theme().colors().ghost_element_active))
375                                .when(self.selected, |this| {
376                                    this.bg(cx.theme().colors().ghost_element_selected)
377                                })
378                        })
379                    })
380                    .when_some(
381                        self.on_click.filter(|_| !self.disabled),
382                        |this, on_click| this.cursor_pointer().on_click(on_click),
383                    )
384                    .when(self.outlined, |this| {
385                        this.border_1()
386                            .border_color(cx.theme().colors().border)
387                            .rounded_sm()
388                            .overflow_hidden()
389                    })
390                    .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
391                        this.on_mouse_down(MouseButton::Right, move |event, window, cx| {
392                            (on_mouse_down)(event, window, cx)
393                        })
394                    })
395                    .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
396                    .map(|this| {
397                        if self.inset {
398                            this.rounded_sm()
399                        } else {
400                            // When an item is not inset draw the indent spacing inside of the item
401                            this.ml(self.indent_level as f32 * self.indent_step_size)
402                        }
403                    })
404                    .children(self.toggle.map(|is_open| {
405                        div()
406                            .flex()
407                            .absolute()
408                            .left(rems(-1.))
409                            .when(is_open && !self.always_show_disclosure_icon, |this| {
410                                this.visible_on_hover("")
411                            })
412                            .child(
413                                Disclosure::new("toggle", is_open)
414                                    .on_toggle_expanded(self.on_toggle),
415                            )
416                    }))
417                    .child(
418                        h_flex()
419                            .flex_grow_1()
420                            .flex_shrink_0()
421                            .flex_basis(relative(0.25))
422                            .gap(DynamicSpacing::Base06.rems(cx))
423                            .map(|list_content| {
424                                if self.overflow_x {
425                                    list_content
426                                } else {
427                                    list_content.overflow_hidden()
428                                }
429                            })
430                            .children(self.start_slot)
431                            .children(self.children),
432                    )
433                    .when(self.end_slot.is_some(), |this| this.justify_between())
434                    .map(|this| match self.end_slot_visibility {
435                        EndSlotVisibility::Always if let Some(end_slot) = self.end_slot => {
436                            this.child(h_flex().flex_shrink_1().overflow_hidden().child(end_slot))
437                        }
438                        EndSlotVisibility::OnHover if let Some(end_slot) = self.end_slot => this
439                            .child(
440                                h_flex()
441                                    .flex_shrink_1()
442                                    .overflow_hidden()
443                                    .visible_on_hover("list_item")
444                                    .child(end_slot),
445                            ),
446                        EndSlotVisibility::SwapOnHover(hover_slot) => this.child(
447                            h_flex()
448                                .relative()
449                                .flex_shrink_1()
450                                .child(h_flex().visible_on_hover("list_item").child(hover_slot))
451                                .when_some(self.end_slot, |this, end_slot| {
452                                    this.child(
453                                        h_flex()
454                                            .absolute()
455                                            .inset_0()
456                                            .justify_end()
457                                            .overflow_hidden()
458                                            .group_hover("list_item", |this| this.invisible())
459                                            .child(end_slot),
460                                    )
461                                }),
462                        ),
463                        _ => this,
464                    }),
465            )
466    }
467}
468
469impl Component for ListItem {
470    fn scope() -> ComponentScope {
471        ComponentScope::DataDisplay
472    }
473
474    fn description() -> &'static str {
475        "A flexible list item component with support for icons, actions, \
476        disclosure toggles, and hierarchical display."
477    }
478
479    fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
480        v_flex()
481            .gap_6()
482            .children(vec![
483                example_group_with_title(
484                    "Basic List Items",
485                    vec![
486                        single_example(
487                            "Simple",
488                            ListItem::new("simple")
489                                .child(Label::new("Simple list item"))
490                                .into_any_element(),
491                        ),
492                        single_example(
493                            "With Icon",
494                            ListItem::new("with_icon")
495                                .start_slot(Icon::new(IconName::File))
496                                .child(Label::new("List item with icon"))
497                                .into_any_element(),
498                        ),
499                        single_example(
500                            "Selected",
501                            ListItem::new("selected")
502                                .toggle_state(true)
503                                .start_slot(Icon::new(IconName::Check))
504                                .child(Label::new("Selected item"))
505                                .into_any_element(),
506                        ),
507                    ],
508                ),
509                example_group_with_title(
510                    "List Item Spacing",
511                    vec![
512                        single_example(
513                            "Dense",
514                            ListItem::new("dense")
515                                .spacing(ListItemSpacing::Dense)
516                                .child(Label::new("Dense spacing"))
517                                .into_any_element(),
518                        ),
519                        single_example(
520                            "Extra Dense",
521                            ListItem::new("extra_dense")
522                                .spacing(ListItemSpacing::ExtraDense)
523                                .child(Label::new("Extra dense spacing"))
524                                .into_any_element(),
525                        ),
526                        single_example(
527                            "Sparse",
528                            ListItem::new("sparse")
529                                .spacing(ListItemSpacing::Sparse)
530                                .child(Label::new("Sparse spacing"))
531                                .into_any_element(),
532                        ),
533                    ],
534                ),
535                example_group_with_title(
536                    "With Slots",
537                    vec![
538                        single_example(
539                            "End Slot",
540                            ListItem::new("end_slot")
541                                .child(Label::new("Item with end slot"))
542                                .end_slot(Icon::new(IconName::ChevronRight))
543                                .into_any_element(),
544                        ),
545                        single_example(
546                            "With Toggle",
547                            ListItem::new("with_toggle")
548                                .toggle(Some(true))
549                                .child(Label::new("Expandable item"))
550                                .into_any_element(),
551                        ),
552                    ],
553                ),
554                example_group_with_title(
555                    "States",
556                    vec![
557                        single_example(
558                            "Disabled",
559                            ListItem::new("disabled")
560                                .disabled(true)
561                                .child(Label::new("Disabled item"))
562                                .into_any_element(),
563                        ),
564                        single_example(
565                            "Non-selectable",
566                            ListItem::new("non_selectable")
567                                .selectable(false)
568                                .child(Label::new("Non-selectable item"))
569                                .into_any_element(),
570                        ),
571                    ],
572                ),
573            ])
574            .into_any_element()
575    }
576}
577
Served at tenant.openagents/omega Member data and write actions are omitted.