Merge branch 'main' into additional-keybinding-icons

This commit is contained in:
Nate Butler 2024-01-03 16:49:34 -05:00
commit f39bc6e132
1388 changed files with 44138 additions and 329825 deletions

View file

@ -0,0 +1,7 @@
use gpui::{ClickEvent, WindowContext};
/// A trait for elements that can be clicked.
pub trait Clickable {
/// Sets the click handler that will fire whenever the element is clicked.
fn on_click(self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self;
}

View file

@ -0,0 +1,43 @@
mod avatar;
mod button;
mod checkbox;
mod context_menu;
mod disclosure;
mod divider;
mod icon;
mod indicator;
mod keybinding;
mod label;
mod list;
mod popover;
mod popover_menu;
mod right_click_menu;
mod stack;
mod tab;
mod tab_bar;
mod tooltip;
#[cfg(feature = "stories")]
mod stories;
pub use avatar::*;
pub use button::*;
pub use checkbox::*;
pub use context_menu::*;
pub use disclosure::*;
pub use divider::*;
pub use icon::*;
pub use indicator::*;
pub use keybinding::*;
pub use label::*;
pub use list::*;
pub use popover::*;
pub use popover_menu::*;
pub use right_click_menu::*;
pub use stack::*;
pub use tab::*;
pub use tab_bar::*;
pub use tooltip::*;
#[cfg(feature = "stories")]
pub use stories::*;

View file

@ -0,0 +1,91 @@
use crate::prelude::*;
use gpui::{img, Hsla, ImageSource, Img, IntoElement, Styled};
#[derive(Debug, Default, PartialEq, Clone)]
pub enum Shape {
#[default]
Circle,
RoundedRectangle,
}
#[derive(IntoElement)]
pub struct Avatar {
image: Img,
border_color: Option<Hsla>,
is_available: Option<bool>,
}
impl RenderOnce for Avatar {
fn render(mut self, cx: &mut WindowContext) -> impl IntoElement {
if self.image.style().corner_radii.top_left.is_none() {
self = self.shape(Shape::Circle);
}
let size = cx.rem_size();
div()
.size(size + px(2.))
.map(|mut div| {
div.style().corner_radii = self.image.style().corner_radii.clone();
div
})
.when_some(self.border_color, |this, color| {
this.border().border_color(color)
})
.child(
self.image
.size(size)
.bg(cx.theme().colors().ghost_element_background),
)
.children(self.is_available.map(|is_free| {
// HACK: non-integer sizes result in oval indicators.
let indicator_size = (size * 0.4).round();
div()
.absolute()
.z_index(1)
.bg(if is_free {
cx.theme().status().created
} else {
cx.theme().status().deleted
})
.size(indicator_size)
.rounded(indicator_size)
.bottom_0()
.right_0()
}))
}
}
impl Avatar {
pub fn new(src: impl Into<ImageSource>) -> Self {
Avatar {
image: img(src),
is_available: None,
border_color: None,
}
}
pub fn shape(mut self, shape: Shape) -> Self {
self.image = match shape {
Shape::Circle => self.image.rounded_full(),
Shape::RoundedRectangle => self.image.rounded_md(),
};
self
}
pub fn grayscale(mut self, grayscale: bool) -> Self {
self.image = self.image.grayscale(grayscale);
self
}
pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
self.border_color = Some(color.into());
self
}
pub fn availability_indicator(mut self, is_available: impl Into<Option<bool>>) -> Self {
self.is_available = is_available.into();
self
}
}

View file

@ -0,0 +1,10 @@
mod button;
pub(self) mod button_icon;
mod button_like;
mod icon_button;
mod toggle_button;
pub use button::*;
pub use button_like::*;
pub use icon_button::*;
pub use toggle_button::*;

View file

@ -0,0 +1,188 @@
use gpui::{AnyView, DefiniteLength};
use crate::{prelude::*, IconPosition};
use crate::{
ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize, Label, LineHeightStyle,
};
use super::button_icon::ButtonIcon;
#[derive(IntoElement)]
pub struct Button {
base: ButtonLike,
label: SharedString,
label_color: Option<Color>,
label_size: Option<LabelSize>,
selected_label: Option<SharedString>,
icon: Option<Icon>,
icon_position: Option<IconPosition>,
icon_size: Option<IconSize>,
icon_color: Option<Color>,
selected_icon: Option<Icon>,
}
impl Button {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
base: ButtonLike::new(id),
label: label.into(),
label_color: None,
label_size: None,
selected_label: None,
icon: None,
icon_position: None,
icon_size: None,
icon_color: None,
selected_icon: None,
}
}
pub fn color(mut self, label_color: impl Into<Option<Color>>) -> Self {
self.label_color = label_color.into();
self
}
pub fn label_size(mut self, label_size: impl Into<Option<LabelSize>>) -> Self {
self.label_size = label_size.into();
self
}
pub fn selected_label<L: Into<SharedString>>(mut self, label: impl Into<Option<L>>) -> Self {
self.selected_label = label.into().map(Into::into);
self
}
pub fn icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
self.icon = icon.into();
self
}
pub fn icon_position(mut self, icon_position: impl Into<Option<IconPosition>>) -> Self {
self.icon_position = icon_position.into();
self
}
pub fn icon_size(mut self, icon_size: impl Into<Option<IconSize>>) -> Self {
self.icon_size = icon_size.into();
self
}
pub fn icon_color(mut self, icon_color: impl Into<Option<Color>>) -> Self {
self.icon_color = icon_color.into();
self
}
pub fn selected_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
self.selected_icon = icon.into();
self
}
}
impl Selectable for Button {
fn selected(mut self, selected: bool) -> Self {
self.base = self.base.selected(selected);
self
}
}
impl Disableable for Button {
fn disabled(mut self, disabled: bool) -> Self {
self.base = self.base.disabled(disabled);
self
}
}
impl Clickable for Button {
fn on_click(
mut self,
handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.base = self.base.on_click(handler);
self
}
}
impl FixedWidth for Button {
fn width(mut self, width: DefiniteLength) -> Self {
self.base = self.base.width(width);
self
}
fn full_width(mut self) -> Self {
self.base = self.base.full_width();
self
}
}
impl ButtonCommon for Button {
fn id(&self) -> &ElementId {
self.base.id()
}
fn style(mut self, style: ButtonStyle) -> Self {
self.base = self.base.style(style);
self
}
fn size(mut self, size: ButtonSize) -> Self {
self.base = self.base.size(size);
self
}
fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self {
self.base = self.base.tooltip(tooltip);
self
}
}
impl RenderOnce for Button {
#[allow(refining_impl_trait)]
fn render(self, _cx: &mut WindowContext) -> ButtonLike {
let is_disabled = self.base.disabled;
let is_selected = self.base.selected;
let label = self
.selected_label
.filter(|_| is_selected)
.unwrap_or(self.label);
let label_color = if is_disabled {
Color::Disabled
} else if is_selected {
Color::Selected
} else {
self.label_color.unwrap_or_default()
};
self.base.child(
h_stack()
.gap_1()
.when(self.icon_position.is_some(), |this| {
this.children(self.icon.map(|icon| {
ButtonIcon::new(icon)
.disabled(is_disabled)
.selected(is_selected)
.selected_icon(self.selected_icon)
.size(self.icon_size)
.color(self.icon_color)
}))
})
.child(
Label::new(label)
.color(label_color)
.size(self.label_size.unwrap_or_default())
.line_height_style(LineHeightStyle::UiLabel),
)
.when(!self.icon_position.is_some(), |this| {
this.children(self.icon.map(|icon| {
ButtonIcon::new(icon)
.disabled(is_disabled)
.selected(is_selected)
.selected_icon(self.selected_icon)
.size(self.icon_size)
.color(self.icon_color)
}))
}),
)
}
}

View file

@ -0,0 +1,82 @@
use crate::{prelude::*, Icon, IconElement, IconSize};
/// An icon that appears within a button.
///
/// Can be used as either an icon alongside a label, like in [`Button`](crate::Button),
/// or as a standalone icon, like in [`IconButton`](crate::IconButton).
#[derive(IntoElement)]
pub(super) struct ButtonIcon {
icon: Icon,
size: IconSize,
color: Color,
disabled: bool,
selected: bool,
selected_icon: Option<Icon>,
}
impl ButtonIcon {
pub fn new(icon: Icon) -> Self {
Self {
icon,
size: IconSize::default(),
color: Color::default(),
disabled: false,
selected: false,
selected_icon: None,
}
}
pub fn size(mut self, size: impl Into<Option<IconSize>>) -> Self {
if let Some(size) = size.into() {
self.size = size;
}
self
}
pub fn color(mut self, color: impl Into<Option<Color>>) -> Self {
if let Some(color) = color.into() {
self.color = color;
}
self
}
pub fn selected_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
self.selected_icon = icon.into();
self
}
}
impl Disableable for ButtonIcon {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for ButtonIcon {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl RenderOnce for ButtonIcon {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
let icon = self
.selected_icon
.filter(|_| self.selected)
.unwrap_or(self.icon);
let icon_color = if self.disabled {
Color::Disabled
} else if self.selected {
Color::Selected
} else {
self.color
};
IconElement::new(icon).size(self.size).color(icon_color)
}
}

View file

@ -0,0 +1,407 @@
use gpui::{relative, DefiniteLength, MouseButton};
use gpui::{rems, transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems};
use smallvec::SmallVec;
use crate::prelude::*;
pub trait ButtonCommon: Clickable + Disableable {
/// A unique element ID to identify the button.
fn id(&self) -> &ElementId;
/// The visual style of the button.
///
/// Most commonly will be [`ButtonStyle::Subtle`], or [`ButtonStyle::Filled`]
/// for an emphasized button.
fn style(self, style: ButtonStyle) -> Self;
/// The size of the button.
///
/// Most buttons will use the default size.
///
/// [`ButtonSize`] can also be used to help build non-button elements
/// that are consistently sized with buttons.
fn size(self, size: ButtonSize) -> Self;
/// The tooltip that shows when a user hovers over the button.
///
/// Nearly all interactable elements should have a tooltip. Some example
/// exceptions might a scroll bar, or a slider.
fn tooltip(self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self;
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum IconPosition {
#[default]
Start,
End,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum ButtonStyle {
/// A filled button with a solid background color. Provides emphasis versus
/// the more common subtle button.
Filled,
/// 🚧 Under construction 🚧
///
/// Used to emphasize a button in some way, like a selected state, or a semantic
/// coloring like an error or success button.
Tinted,
/// The default button style, used for most buttons. Has a transparent background,
/// but has a background color to indicate states like hover and active.
#[default]
Subtle,
/// Used for buttons that only change forground color on hover and active states.
///
/// TODO: Better docs for this.
Transparent,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub(crate) enum ButtonLikeRounding {
All,
Left,
Right,
}
#[derive(Debug, Clone)]
pub(crate) struct ButtonLikeStyles {
pub background: Hsla,
#[allow(unused)]
pub border_color: Hsla,
#[allow(unused)]
pub label_color: Hsla,
#[allow(unused)]
pub icon_color: Hsla,
}
impl ButtonStyle {
pub(crate) fn enabled(self, cx: &mut WindowContext) -> ButtonLikeStyles {
match self {
ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_background,
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Tinted => ButtonLikeStyles {
background: gpui::red(),
border_color: gpui::red(),
label_color: gpui::red(),
icon_color: gpui::red(),
},
ButtonStyle::Subtle => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_background,
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(),
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
}
}
pub(crate) fn hovered(self, cx: &mut WindowContext) -> ButtonLikeStyles {
match self {
ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_hover,
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Tinted => ButtonLikeStyles {
background: gpui::red(),
border_color: gpui::red(),
label_color: gpui::red(),
icon_color: gpui::red(),
},
ButtonStyle::Subtle => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_hover,
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(),
border_color: transparent_black(),
// TODO: These are not great
label_color: Color::Muted.color(cx),
// TODO: These are not great
icon_color: Color::Muted.color(cx),
},
}
}
pub(crate) fn active(self, cx: &mut WindowContext) -> ButtonLikeStyles {
match self {
ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_active,
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Tinted => ButtonLikeStyles {
background: gpui::red(),
border_color: gpui::red(),
label_color: gpui::red(),
icon_color: gpui::red(),
},
ButtonStyle::Subtle => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_active,
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(),
border_color: transparent_black(),
// TODO: These are not great
label_color: Color::Muted.color(cx),
// TODO: These are not great
icon_color: Color::Muted.color(cx),
},
}
}
#[allow(unused)]
pub(crate) fn focused(self, cx: &mut WindowContext) -> ButtonLikeStyles {
match self {
ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_background,
border_color: cx.theme().colors().border_focused,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Tinted => ButtonLikeStyles {
background: gpui::red(),
border_color: gpui::red(),
label_color: gpui::red(),
icon_color: gpui::red(),
},
ButtonStyle::Subtle => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_background,
border_color: cx.theme().colors().border_focused,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border_focused,
label_color: Color::Accent.color(cx),
icon_color: Color::Accent.color(cx),
},
}
}
#[allow(unused)]
pub(crate) fn disabled(self, cx: &mut WindowContext) -> ButtonLikeStyles {
match self {
ButtonStyle::Filled => ButtonLikeStyles {
background: cx.theme().colors().element_disabled,
border_color: cx.theme().colors().border_disabled,
label_color: Color::Disabled.color(cx),
icon_color: Color::Disabled.color(cx),
},
ButtonStyle::Tinted => ButtonLikeStyles {
background: gpui::red(),
border_color: gpui::red(),
label_color: gpui::red(),
icon_color: gpui::red(),
},
ButtonStyle::Subtle => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_disabled,
border_color: cx.theme().colors().border_disabled,
label_color: Color::Disabled.color(cx),
icon_color: Color::Disabled.color(cx),
},
ButtonStyle::Transparent => ButtonLikeStyles {
background: transparent_black(),
border_color: transparent_black(),
label_color: Color::Disabled.color(cx),
icon_color: Color::Disabled.color(cx),
},
}
}
}
/// ButtonSize can also be used to help build non-button elements
/// that are consistently sized with buttons.
#[derive(Default, PartialEq, Clone, Copy)]
pub enum ButtonSize {
Large,
#[default]
Default,
Compact,
None,
}
impl ButtonSize {
fn height(self) -> Rems {
match self {
ButtonSize::Large => rems(32. / 16.),
ButtonSize::Default => rems(22. / 16.),
ButtonSize::Compact => rems(18. / 16.),
ButtonSize::None => rems(16. / 16.),
}
}
}
/// A button-like element that can be used to create a custom button when
/// prebuilt buttons are not sufficient. Use this sparingly, as it is
/// unconstrained and may make the UI feel less consistent.
///
/// This is also used to build the prebuilt buttons.
#[derive(IntoElement)]
pub struct ButtonLike {
base: Div,
id: ElementId,
pub(super) style: ButtonStyle,
pub(super) disabled: bool,
pub(super) selected: bool,
pub(super) width: Option<DefiniteLength>,
size: ButtonSize,
rounding: Option<ButtonLikeRounding>,
tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView>>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl ButtonLike {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
style: ButtonStyle::default(),
disabled: false,
selected: false,
width: None,
size: ButtonSize::Default,
rounding: Some(ButtonLikeRounding::All),
tooltip: None,
children: SmallVec::new(),
on_click: None,
}
}
pub(crate) fn rounding(mut self, rounding: impl Into<Option<ButtonLikeRounding>>) -> Self {
self.rounding = rounding.into();
self
}
}
impl Disableable for ButtonLike {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for ButtonLike {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl Clickable for ButtonLike {
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl FixedWidth for ButtonLike {
fn width(mut self, width: DefiniteLength) -> Self {
self.width = Some(width);
self
}
fn full_width(mut self) -> Self {
self.width = Some(relative(1.));
self
}
}
impl ButtonCommon for ButtonLike {
fn id(&self) -> &ElementId {
&self.id
}
fn style(mut self, style: ButtonStyle) -> Self {
self.style = style;
self
}
fn size(mut self, size: ButtonSize) -> Self {
self.size = size;
self
}
fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self {
self.tooltip = Some(Box::new(tooltip));
self
}
}
impl VisibleOnHover for ButtonLike {
fn visible_on_hover(mut self, group_name: impl Into<SharedString>) -> Self {
self.base = self.base.visible_on_hover(group_name);
self
}
}
impl ParentElement for ButtonLike {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}
impl RenderOnce for ButtonLike {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
self.base
.h_flex()
.id(self.id.clone())
.group("")
.flex_none()
.h(self.size.height())
.when_some(self.width, |this, width| this.w(width).justify_center())
.when_some(self.rounding, |this, rounding| match rounding {
ButtonLikeRounding::All => this.rounded_md(),
ButtonLikeRounding::Left => this.rounded_l_md(),
ButtonLikeRounding::Right => this.rounded_r_md(),
})
.gap_1()
.map(|this| match self.size {
ButtonSize::Large => this.px_2(),
ButtonSize::Default | ButtonSize::Compact => this.px_1(),
ButtonSize::None => this,
})
.bg(self.style.enabled(cx).background)
.when(self.disabled, |this| this.cursor_not_allowed())
.when(!self.disabled, |this| {
this.cursor_pointer()
.hover(|hover| hover.bg(self.style.hovered(cx).background))
.active(|active| active.bg(self.style.active(cx).background))
})
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| {
this.on_mouse_down(MouseButton::Left, |_, cx| cx.prevent_default())
.on_click(move |event, cx| {
cx.stop_propagation();
(on_click)(event, cx)
})
},
)
.when_some(self.tooltip, |this, tooltip| {
this.tooltip(move |cx| tooltip(cx))
})
.children(self.children)
}
}

View file

@ -0,0 +1,122 @@
use gpui::{AnyView, DefiniteLength};
use crate::prelude::*;
use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize};
use super::button_icon::ButtonIcon;
#[derive(IntoElement)]
pub struct IconButton {
base: ButtonLike,
icon: Icon,
icon_size: IconSize,
icon_color: Color,
selected_icon: Option<Icon>,
}
impl IconButton {
pub fn new(id: impl Into<ElementId>, icon: Icon) -> Self {
Self {
base: ButtonLike::new(id),
icon,
icon_size: IconSize::default(),
icon_color: Color::Default,
selected_icon: None,
}
}
pub fn icon_size(mut self, icon_size: IconSize) -> Self {
self.icon_size = icon_size;
self
}
pub fn icon_color(mut self, icon_color: Color) -> Self {
self.icon_color = icon_color;
self
}
pub fn selected_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
self.selected_icon = icon.into();
self
}
}
impl Disableable for IconButton {
fn disabled(mut self, disabled: bool) -> Self {
self.base = self.base.disabled(disabled);
self
}
}
impl Selectable for IconButton {
fn selected(mut self, selected: bool) -> Self {
self.base = self.base.selected(selected);
self
}
}
impl Clickable for IconButton {
fn on_click(
mut self,
handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.base = self.base.on_click(handler);
self
}
}
impl FixedWidth for IconButton {
fn width(mut self, width: DefiniteLength) -> Self {
self.base = self.base.width(width);
self
}
fn full_width(mut self) -> Self {
self.base = self.base.full_width();
self
}
}
impl ButtonCommon for IconButton {
fn id(&self) -> &ElementId {
self.base.id()
}
fn style(mut self, style: ButtonStyle) -> Self {
self.base = self.base.style(style);
self
}
fn size(mut self, size: ButtonSize) -> Self {
self.base = self.base.size(size);
self
}
fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self {
self.base = self.base.tooltip(tooltip);
self
}
}
impl VisibleOnHover for IconButton {
fn visible_on_hover(mut self, group_name: impl Into<SharedString>) -> Self {
self.base = self.base.visible_on_hover(group_name);
self
}
}
impl RenderOnce for IconButton {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
let is_disabled = self.base.disabled;
let is_selected = self.base.selected;
self.base.child(
ButtonIcon::new(self.icon)
.disabled(is_disabled)
.selected(is_selected)
.selected_icon(self.selected_icon)
.size(self.icon_size)
.color(self.icon_color),
)
}
}

View file

@ -0,0 +1,126 @@
use gpui::{AnyView, ClickEvent};
use crate::{prelude::*, ButtonLike, ButtonLikeRounding};
/// The position of a [`ToggleButton`] within a group of buttons.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ToggleButtonPosition {
/// The toggle button is first in the group.
First,
/// The toggle button is in the middle of the group (i.e., it is not the first or last toggle button).
Middle,
/// The toggle button is last in the group.
Last,
}
#[derive(IntoElement)]
pub struct ToggleButton {
base: ButtonLike,
position_in_group: Option<ToggleButtonPosition>,
label: SharedString,
label_color: Option<Color>,
}
impl ToggleButton {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
base: ButtonLike::new(id),
position_in_group: None,
label: label.into(),
label_color: None,
}
}
pub fn color(mut self, label_color: impl Into<Option<Color>>) -> Self {
self.label_color = label_color.into();
self
}
pub fn position_in_group(mut self, position: ToggleButtonPosition) -> Self {
self.position_in_group = Some(position);
self
}
pub fn first(self) -> Self {
self.position_in_group(ToggleButtonPosition::First)
}
pub fn middle(self) -> Self {
self.position_in_group(ToggleButtonPosition::Middle)
}
pub fn last(self) -> Self {
self.position_in_group(ToggleButtonPosition::Last)
}
}
impl Selectable for ToggleButton {
fn selected(mut self, selected: bool) -> Self {
self.base = self.base.selected(selected);
self
}
}
impl Disableable for ToggleButton {
fn disabled(mut self, disabled: bool) -> Self {
self.base = self.base.disabled(disabled);
self
}
}
impl Clickable for ToggleButton {
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
self.base = self.base.on_click(handler);
self
}
}
impl ButtonCommon for ToggleButton {
fn id(&self) -> &ElementId {
self.base.id()
}
fn style(mut self, style: ButtonStyle) -> Self {
self.base = self.base.style(style);
self
}
fn size(mut self, size: ButtonSize) -> Self {
self.base = self.base.size(size);
self
}
fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self {
self.base = self.base.tooltip(tooltip);
self
}
}
impl RenderOnce for ToggleButton {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
let is_disabled = self.base.disabled;
let is_selected = self.base.selected;
let label_color = if is_disabled {
Color::Disabled
} else if is_selected {
Color::Selected
} else {
self.label_color.unwrap_or_default()
};
self.base
.when_some(self.position_in_group, |this, position| match position {
ToggleButtonPosition::First => this.rounding(ButtonLikeRounding::Left),
ToggleButtonPosition::Middle => this.rounding(None),
ToggleButtonPosition::Last => this.rounding(ButtonLikeRounding::Right),
})
.child(
Label::new(self.label)
.color(label_color)
.line_height_style(LineHeightStyle::UiLabel),
)
}
}

View file

@ -0,0 +1,282 @@
use gpui::{div, prelude::*, Element, ElementId, IntoElement, Styled, WindowContext};
use crate::prelude::*;
use crate::{Color, Icon, IconElement, Selection};
pub type CheckHandler = Box<dyn Fn(&Selection, &mut WindowContext) + 'static>;
/// # Checkbox
///
/// Checkboxes are used for multiple choices, not for mutually exclusive choices.
/// Each checkbox works independently from other checkboxes in the list,
/// therefore checking an additional box does not affect any other selections.
#[derive(IntoElement)]
pub struct Checkbox {
id: ElementId,
checked: Selection,
disabled: bool,
on_click: Option<CheckHandler>,
}
impl RenderOnce for Checkbox {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let group_id = format!("checkbox_group_{:?}", self.id);
let icon = match self.checked {
// When selected, we show a checkmark.
Selection::Selected => {
Some(
IconElement::new(Icon::Check)
.size(crate::IconSize::Small)
.color(
// If the checkbox is disabled we change the color of the icon.
if self.disabled {
Color::Disabled
} else {
Color::Selected
},
),
)
}
// In an indeterminate state, we show a dash.
Selection::Indeterminate => {
Some(
IconElement::new(Icon::Dash)
.size(crate::IconSize::Small)
.color(
// If the checkbox is disabled we change the color of the icon.
if self.disabled {
Color::Disabled
} else {
Color::Selected
},
),
)
}
// When unselected, we show nothing.
Selection::Unselected => None,
};
// A checkbox could be in an indeterminate state,
// for example the indeterminate state could represent:
// - a group of options of which only some are selected
// - an enabled option that is no longer available
// - a previously agreed to license that has been updated
//
// For the sake of styles we treat the indeterminate state as selected,
// but it's icon will be different.
let selected =
self.checked == Selection::Selected || self.checked == Selection::Indeterminate;
// We could use something like this to make the checkbox background when selected:
//
// ~~~rust
// ...
// .when(selected, |this| {
// this.bg(cx.theme().colors().element_selected)
// })
// ~~~
//
// But we use a match instead here because the checkbox might be disabled,
// and it could be disabled _while_ it is selected, as well as while it is not selected.
let (bg_color, border_color) = match (self.disabled, selected) {
(true, _) => (
cx.theme().colors().ghost_element_disabled,
cx.theme().colors().border_disabled,
),
(false, true) => (
cx.theme().colors().element_selected,
cx.theme().colors().border,
),
(false, false) => (
cx.theme().colors().element_background,
cx.theme().colors().border,
),
};
div()
.id(self.id)
// Rather than adding `px_1()` to add some space around the checkbox,
// we use a larger parent element to create a slightly larger
// click area for the checkbox.
.size_5()
// Because we've enlarged the click area, we need to create a
// `group` to pass down interactivity events to the checkbox.
.group(group_id.clone())
.child(
div()
.flex()
// This prevent the flex element from growing
// or shrinking in response to any size changes
.flex_none()
// The combo of `justify_center()` and `items_center()`
// is used frequently to center elements in a flex container.
//
// We use this to center the icon in the checkbox.
.justify_center()
.items_center()
.m_1()
.size_4()
.rounded_sm()
.bg(bg_color)
.border()
.border_color(border_color)
// We only want the interactivity states to fire when we
// are in a checkbox that isn't disabled.
.when(!self.disabled, |this| {
// Here instead of `hover()` we use `group_hover()`
// to pass it the group id.
this.group_hover(group_id.clone(), |el| {
el.bg(cx.theme().colors().element_hover)
})
})
.children(icon),
)
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| this.on_click(move |_, cx| on_click(&self.checked.inverse(), cx)),
)
}
}
impl Checkbox {
pub fn new(id: impl Into<ElementId>, checked: Selection) -> Self {
Self {
id: id.into(),
checked,
disabled: false,
on_click: None,
}
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_click(
mut self,
handler: impl 'static + Fn(&Selection, &mut WindowContext) + Send + Sync,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn render(self, cx: &mut WindowContext) -> impl Element {
let group_id = format!("checkbox_group_{:?}", self.id);
let icon = match self.checked {
// When selected, we show a checkmark.
Selection::Selected => {
Some(
IconElement::new(Icon::Check)
.size(crate::IconSize::Small)
.color(
// If the checkbox is disabled we change the color of the icon.
if self.disabled {
Color::Disabled
} else {
Color::Selected
},
),
)
}
// In an indeterminate state, we show a dash.
Selection::Indeterminate => {
Some(
IconElement::new(Icon::Dash)
.size(crate::IconSize::Small)
.color(
// If the checkbox is disabled we change the color of the icon.
if self.disabled {
Color::Disabled
} else {
Color::Selected
},
),
)
}
// When unselected, we show nothing.
Selection::Unselected => None,
};
// A checkbox could be in an indeterminate state,
// for example the indeterminate state could represent:
// - a group of options of which only some are selected
// - an enabled option that is no longer available
// - a previously agreed to license that has been updated
//
// For the sake of styles we treat the indeterminate state as selected,
// but it's icon will be different.
let selected =
self.checked == Selection::Selected || self.checked == Selection::Indeterminate;
// We could use something like this to make the checkbox background when selected:
//
// ~~~rust
// ...
// .when(selected, |this| {
// this.bg(cx.theme().colors().element_selected)
// })
// ~~~
//
// But we use a match instead here because the checkbox might be disabled,
// and it could be disabled _while_ it is selected, as well as while it is not selected.
let (bg_color, border_color) = match (self.disabled, selected) {
(true, _) => (
cx.theme().colors().ghost_element_disabled,
cx.theme().colors().border_disabled,
),
(false, true) => (
cx.theme().colors().element_selected,
cx.theme().colors().border,
),
(false, false) => (
cx.theme().colors().element_background,
cx.theme().colors().border,
),
};
div()
.id(self.id)
// Rather than adding `px_1()` to add some space around the checkbox,
// we use a larger parent element to create a slightly larger
// click area for the checkbox.
.size_5()
// Because we've enlarged the click area, we need to create a
// `group` to pass down interactivity events to the checkbox.
.group(group_id.clone())
.child(
div()
.flex()
// This prevent the flex element from growing
// or shrinking in response to any size changes
.flex_none()
// The combo of `justify_center()` and `items_center()`
// is used frequently to center elements in a flex container.
//
// We use this to center the icon in the checkbox.
.justify_center()
.items_center()
.m_1()
.size_4()
.rounded_sm()
.bg(bg_color)
.border()
.border_color(border_color)
// We only want the interactivity states to fire when we
// are in a checkbox that isn't disabled.
.when(!self.disabled, |this| {
// Here instead of `hover()` we use `group_hover()`
// to pass it the group id.
this.group_hover(group_id.clone(), |el| {
el.bg(cx.theme().colors().element_hover)
})
})
.children(icon),
)
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| this.on_click(move |_, cx| on_click(&self.checked.inverse(), cx)),
)
}
}

View file

@ -0,0 +1,336 @@
use crate::{
h_stack, prelude::*, v_stack, Icon, IconElement, KeyBinding, Label, List, ListItem,
ListSeparator, ListSubHeader,
};
use gpui::{
px, Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
IntoElement, Render, Subscription, View, VisualContext,
};
use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
use std::{rc::Rc, time::Duration};
enum ContextMenuItem {
Separator,
Header(SharedString),
Entry {
label: SharedString,
icon: Option<Icon>,
handler: Rc<dyn Fn(&mut WindowContext)>,
action: Option<Box<dyn Action>>,
},
CustomEntry {
entry_render: Box<dyn Fn(&mut WindowContext) -> AnyElement>,
handler: Rc<dyn Fn(&mut WindowContext)>,
},
}
pub struct ContextMenu {
items: Vec<ContextMenuItem>,
focus_handle: FocusHandle,
selected_index: Option<usize>,
delayed: bool,
clicked: bool,
_on_blur_subscription: Subscription,
}
impl FocusableView for ContextMenu {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for ContextMenu {}
impl ContextMenu {
pub fn build(
cx: &mut WindowContext,
f: impl FnOnce(Self, &mut WindowContext) -> Self,
) -> View<Self> {
cx.new_view(|cx| {
let focus_handle = cx.focus_handle();
let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut ContextMenu, cx| {
this.cancel(&menu::Cancel, cx)
});
f(
Self {
items: Default::default(),
focus_handle,
selected_index: None,
delayed: false,
clicked: false,
_on_blur_subscription,
},
cx,
)
})
}
pub fn header(mut self, title: impl Into<SharedString>) -> Self {
self.items.push(ContextMenuItem::Header(title.into()));
self
}
pub fn separator(mut self) -> Self {
self.items.push(ContextMenuItem::Separator);
self
}
pub fn entry(
mut self,
label: impl Into<SharedString>,
action: Option<Box<dyn Action>>,
handler: impl Fn(&mut WindowContext) + 'static,
) -> Self {
self.items.push(ContextMenuItem::Entry {
label: label.into(),
handler: Rc::new(handler),
icon: None,
action,
});
self
}
pub fn custom_entry(
mut self,
entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static,
handler: impl Fn(&mut WindowContext) + 'static,
) -> Self {
self.items.push(ContextMenuItem::CustomEntry {
entry_render: Box::new(entry_render),
handler: Rc::new(handler),
});
self
}
pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.items.push(ContextMenuItem::Entry {
label: label.into(),
action: Some(action.boxed_clone()),
handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())),
icon: None,
});
self
}
pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.items.push(ContextMenuItem::Entry {
label: label.into(),
action: Some(action.boxed_clone()),
handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())),
icon: Some(Icon::Link),
});
self
}
pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
match self.selected_index.and_then(|ix| self.items.get(ix)) {
Some(
ContextMenuItem::Entry { handler, .. }
| ContextMenuItem::CustomEntry { handler, .. },
) => (handler)(cx),
_ => {}
}
cx.emit(DismissEvent);
}
pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
cx.emit(DismissEvent);
cx.emit(DismissEvent);
}
fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
self.selected_index = self.items.iter().position(|item| item.is_selectable());
cx.notify();
}
pub fn select_last(&mut self) -> Option<usize> {
for (ix, item) in self.items.iter().enumerate().rev() {
if item.is_selectable() {
self.selected_index = Some(ix);
return Some(ix);
}
}
None
}
fn handle_select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
if self.select_last().is_some() {
cx.notify();
}
}
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
if let Some(ix) = self.selected_index {
for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
if item.is_selectable() {
self.selected_index = Some(ix);
cx.notify();
break;
}
}
} else {
self.select_first(&Default::default(), cx);
}
}
pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
if let Some(ix) = self.selected_index {
for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
if item.is_selectable() {
self.selected_index = Some(ix);
cx.notify();
break;
}
}
} else {
self.handle_select_last(&Default::default(), cx);
}
}
pub fn on_action_dispatch(&mut self, dispatched: &Box<dyn Action>, cx: &mut ViewContext<Self>) {
if self.clicked {
cx.propagate();
return;
}
if let Some(ix) = self.items.iter().position(|item| {
if let ContextMenuItem::Entry {
action: Some(action),
..
} = item
{
action.partial_eq(&**dispatched)
} else {
false
}
}) {
self.selected_index = Some(ix);
self.delayed = true;
cx.notify();
let action = dispatched.boxed_clone();
cx.spawn(|this, mut cx| async move {
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
this.update(&mut cx, |this, cx| {
cx.dispatch_action(action);
this.cancel(&menu::Cancel, cx)
})
})
.detach_and_log_err(cx);
} else {
cx.propagate()
}
}
}
impl ContextMenuItem {
fn is_selectable(&self) -> bool {
matches!(self, Self::Entry { .. } | Self::CustomEntry { .. })
}
}
impl Render for ContextMenu {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div().elevation_2(cx).flex().flex_row().child(
v_stack()
.min_w(px(200.))
.track_focus(&self.focus_handle)
.on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx)))
.key_context("menu")
.on_action(cx.listener(ContextMenu::select_first))
.on_action(cx.listener(ContextMenu::handle_select_last))
.on_action(cx.listener(ContextMenu::select_next))
.on_action(cx.listener(ContextMenu::select_prev))
.on_action(cx.listener(ContextMenu::confirm))
.on_action(cx.listener(ContextMenu::cancel))
.when(!self.delayed, |mut el| {
for item in self.items.iter() {
if let ContextMenuItem::Entry {
action: Some(action),
..
} = item
{
el = el.on_boxed_action(
&**action,
cx.listener(ContextMenu::on_action_dispatch),
);
}
}
el
})
.flex_none()
.child(List::new().children(self.items.iter_mut().enumerate().map(
|(ix, item)| match item {
ContextMenuItem::Separator => ListSeparator.into_any_element(),
ContextMenuItem::Header(header) => {
ListSubHeader::new(header.clone()).into_any_element()
}
ContextMenuItem::Entry {
label,
handler,
icon,
action,
} => {
let handler = handler.clone();
let menu = cx.view().downgrade();
let label_element = if let Some(icon) = icon {
h_stack()
.gap_1()
.child(Label::new(label.clone()))
.child(IconElement::new(*icon))
.into_any_element()
} else {
Label::new(label.clone()).into_any_element()
};
ListItem::new(ix)
.inset(true)
.selected(Some(ix) == self.selected_index)
.on_click(move |_, cx| {
handler(cx);
menu.update(cx, |menu, cx| {
menu.clicked = true;
cx.emit(DismissEvent);
})
.ok();
})
.child(
h_stack()
.w_full()
.justify_between()
.child(label_element)
.children(action.as_ref().and_then(|action| {
KeyBinding::for_action(&**action, cx)
.map(|binding| div().ml_1().child(binding))
})),
)
.into_any_element()
}
ContextMenuItem::CustomEntry {
entry_render,
handler,
} => {
let handler = handler.clone();
let menu = cx.view().downgrade();
ListItem::new(ix)
.inset(true)
.selected(Some(ix) == self.selected_index)
.on_click(move |_, cx| {
handler(cx);
menu.update(cx, |menu, cx| {
menu.clicked = true;
cx.emit(DismissEvent);
})
.ok();
})
.child(entry_render(cx))
.into_any_element()
}
},
))),
)
}
}

View file

@ -0,0 +1,45 @@
use gpui::ClickEvent;
use crate::{prelude::*, Color, Icon, IconButton, IconSize};
#[derive(IntoElement)]
pub struct Disclosure {
id: ElementId,
is_open: bool,
on_toggle: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
}
impl Disclosure {
pub fn new(id: impl Into<ElementId>, is_open: bool) -> Self {
Self {
id: id.into(),
is_open,
on_toggle: None,
}
}
pub fn on_toggle(
mut self,
handler: impl Into<Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>>,
) -> Self {
self.on_toggle = handler.into();
self
}
}
impl RenderOnce for Disclosure {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
IconButton::new(
self.id,
match self.is_open {
true => Icon::ChevronDown,
false => Icon::ChevronRight,
},
)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.when_some(self.on_toggle, move |this, on_toggle| {
this.on_click(move |event, cx| on_toggle(event, cx))
})
}
}

View file

@ -0,0 +1,74 @@
use gpui::{Hsla, IntoElement};
use crate::prelude::*;
enum DividerDirection {
Horizontal,
Vertical,
}
#[derive(Default)]
pub enum DividerColor {
Border,
#[default]
BorderVariant,
}
impl DividerColor {
pub fn hsla(self, cx: &WindowContext) -> Hsla {
match self {
DividerColor::Border => cx.theme().colors().border,
DividerColor::BorderVariant => cx.theme().colors().border_variant,
}
}
}
#[derive(IntoElement)]
pub struct Divider {
direction: DividerDirection,
color: DividerColor,
inset: bool,
}
impl RenderOnce for Divider {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
div()
.map(|this| match self.direction {
DividerDirection::Horizontal => {
this.h_px().w_full().when(self.inset, |this| this.mx_1p5())
}
DividerDirection::Vertical => {
this.w_px().h_full().when(self.inset, |this| this.my_1p5())
}
})
.bg(self.color.hsla(cx))
}
}
impl Divider {
pub fn horizontal() -> Self {
Self {
direction: DividerDirection::Horizontal,
color: DividerColor::default(),
inset: false,
}
}
pub fn vertical() -> Self {
Self {
direction: DividerDirection::Vertical,
color: DividerColor::default(),
inset: false,
}
}
pub fn inset(mut self) -> Self {
self.inset = true;
self
}
pub fn color(mut self, color: DividerColor) -> Self {
self.color = color;
self
}
}

View file

@ -0,0 +1,248 @@
use gpui::{rems, svg, IntoElement, Rems};
use strum::EnumIter;
use crate::prelude::*;
#[derive(Default, PartialEq, Copy, Clone)]
pub enum IconSize {
XSmall,
Small,
#[default]
Medium,
}
impl IconSize {
pub fn rems(self) -> Rems {
match self {
IconSize::XSmall => rems(12. / 16.),
IconSize::Small => rems(14. / 16.),
IconSize::Medium => rems(16. / 16.),
}
}
}
#[derive(Debug, PartialEq, Copy, Clone, EnumIter)]
pub enum Icon {
Ai,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpRight,
AtSign,
AudioOff,
AudioOn,
Backspace,
Bell,
BellOff,
BellRing,
Bolt,
CaseSensitive,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Close,
Collab,
Command,
Control,
Copilot,
CopilotDisabled,
CopilotError,
CopilotInit,
Copy,
Dash,
Delete,
Disconnected,
Ellipsis,
Envelope,
Escape,
ExclamationTriangle,
Exit,
ExternalLink,
File,
FileDoc,
FileGeneric,
FileGit,
FileLock,
FileRust,
FileToml,
FileTree,
Filter,
Folder,
FolderOpen,
FolderX,
Github,
Hash,
InlayHint,
Link,
MagicWand,
MagnifyingGlass,
MailOpen,
Maximize,
Menu,
MessageBubbles,
Mic,
MicMute,
Minimize,
Option,
PageDown,
PageUp,
Plus,
Public,
Quote,
Replace,
ReplaceAll,
ReplaceNext,
Return,
Screen,
SelectAll,
Shift,
Snip,
Space,
Split,
Tab,
Terminal,
Update,
WholeWord,
XCircle,
ZedXCopilot,
}
impl Icon {
pub fn path(self) -> &'static str {
match self {
Icon::Ai => "icons/ai.svg",
Icon::ArrowDown => "icons/arrow_down.svg",
Icon::ArrowLeft => "icons/arrow_left.svg",
Icon::ArrowRight => "icons/arrow_right.svg",
Icon::ArrowUp => "icons/arrow_up.svg",
Icon::ArrowUpRight => "icons/arrow_up_right.svg",
Icon::AtSign => "icons/at_sign.svg",
Icon::AudioOff => "icons/speaker_off.svg",
Icon::AudioOn => "icons/speaker-loud.svg",
Icon::Backspace => "icons/backspace.svg",
Icon::Bell => "icons/bell.svg",
Icon::BellOff => "icons/bell_off.svg",
Icon::BellRing => "icons/bell_ring.svg",
Icon::Bolt => "icons/bolt.svg",
Icon::CaseSensitive => "icons/case_insensitive.svg",
Icon::Check => "icons/check.svg",
Icon::ChevronDown => "icons/chevron_down.svg",
Icon::ChevronLeft => "icons/chevron_left.svg",
Icon::ChevronRight => "icons/chevron_right.svg",
Icon::ChevronUp => "icons/chevron_up.svg",
Icon::Close => "icons/x.svg",
Icon::Collab => "icons/user_group_16.svg",
Icon::Command => "icons/command.svg",
Icon::Control => "icons/control.svg",
Icon::Copilot => "icons/copilot.svg",
Icon::CopilotDisabled => "icons/copilot_disabled.svg",
Icon::CopilotError => "icons/copilot_error.svg",
Icon::CopilotInit => "icons/copilot_init.svg",
Icon::Copy => "icons/copy.svg",
Icon::Dash => "icons/dash.svg",
Icon::Delete => "icons/delete.svg",
Icon::Disconnected => "icons/disconnected.svg",
Icon::Ellipsis => "icons/ellipsis.svg",
Icon::Envelope => "icons/feedback.svg",
Icon::Escape => "icons/escape.svg",
Icon::ExclamationTriangle => "icons/warning.svg",
Icon::Exit => "icons/exit.svg",
Icon::ExternalLink => "icons/external_link.svg",
Icon::File => "icons/file.svg",
Icon::FileDoc => "icons/file_icons/book.svg",
Icon::FileGeneric => "icons/file_icons/file.svg",
Icon::FileGit => "icons/file_icons/git.svg",
Icon::FileLock => "icons/file_icons/lock.svg",
Icon::FileRust => "icons/file_icons/rust.svg",
Icon::FileToml => "icons/file_icons/toml.svg",
Icon::FileTree => "icons/project.svg",
Icon::Filter => "icons/filter.svg",
Icon::Folder => "icons/file_icons/folder.svg",
Icon::FolderOpen => "icons/file_icons/folder_open.svg",
Icon::FolderX => "icons/stop_sharing.svg",
Icon::Github => "icons/github.svg",
Icon::Hash => "icons/hash.svg",
Icon::InlayHint => "icons/inlay_hint.svg",
Icon::Link => "icons/link.svg",
Icon::MagicWand => "icons/magic_wand.svg",
Icon::MagnifyingGlass => "icons/magnifying_glass.svg",
Icon::MailOpen => "icons/mail_open.svg",
Icon::Maximize => "icons/maximize.svg",
Icon::Menu => "icons/menu.svg",
Icon::MessageBubbles => "icons/conversations.svg",
Icon::Mic => "icons/mic.svg",
Icon::MicMute => "icons/mic_mute.svg",
Icon::Minimize => "icons/minimize.svg",
Icon::Option => "icons/option.svg",
Icon::PageDown => "icons/page_down.svg",
Icon::PageUp => "icons/page_up.svg",
Icon::Plus => "icons/plus.svg",
Icon::Public => "icons/public.svg",
Icon::Quote => "icons/quote.svg",
Icon::Replace => "icons/replace.svg",
Icon::ReplaceAll => "icons/replace_all.svg",
Icon::ReplaceNext => "icons/replace_next.svg",
Icon::Return => "icons/return.svg",
Icon::Screen => "icons/desktop.svg",
Icon::SelectAll => "icons/select_all.svg",
Icon::Shift => "icons/shift.svg",
Icon::Snip => "icons/snip.svg",
Icon::Space => "icons/space.svg",
Icon::Split => "icons/split.svg",
Icon::Tab => "icons/tab.svg",
Icon::Terminal => "icons/terminal.svg",
Icon::Update => "icons/update.svg",
Icon::WholeWord => "icons/word_search.svg",
Icon::XCircle => "icons/error.svg",
Icon::ZedXCopilot => "icons/zed_x_copilot.svg",
}
}
}
#[derive(IntoElement)]
pub struct IconElement {
path: SharedString,
color: Color,
size: IconSize,
}
impl RenderOnce for IconElement {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
svg()
.size(self.size.rems())
.flex_none()
.path(self.path)
.text_color(self.color.color(cx))
}
}
impl IconElement {
pub fn new(icon: Icon) -> Self {
Self {
path: icon.path().into(),
color: Color::default(),
size: IconSize::default(),
}
}
pub fn from_path(path: impl Into<SharedString>) -> Self {
Self {
path: path.into(),
color: Color::default(),
size: IconSize::default(),
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn size(mut self, size: IconSize) -> Self {
self.size = size;
self
}
}

View file

@ -0,0 +1,58 @@
use gpui::Position;
use crate::prelude::*;
#[derive(Default)]
pub enum IndicatorStyle {
#[default]
Dot,
Bar,
}
#[derive(IntoElement)]
pub struct Indicator {
position: Position,
style: IndicatorStyle,
color: Color,
}
impl Indicator {
pub fn dot() -> Self {
Self {
position: Position::Relative,
style: IndicatorStyle::Dot,
color: Color::Default,
}
}
pub fn bar() -> Self {
Self {
position: Position::Relative,
style: IndicatorStyle::Dot,
color: Color::Default,
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn absolute(mut self) -> Self {
self.position = Position::Absolute;
self
}
}
impl RenderOnce for Indicator {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
div()
.flex_none()
.map(|this| match self.style {
IndicatorStyle::Dot => this.w_1p5().h_1p5().rounded_full(),
IndicatorStyle::Bar => this.w_full().h_1p5().rounded_t_md(),
})
.when(self.position == Position::Absolute, |this| this.absolute())
.bg(self.color.color(cx))
}
}

View file

@ -0,0 +1,131 @@
use crate::{h_stack, prelude::*, Icon, IconElement, IconSize};
use gpui::{relative, rems, Action, FocusHandle, IntoElement, Keystroke};
#[derive(IntoElement, Clone)]
pub struct KeyBinding {
/// A keybinding consists of a key and a set of modifier keys.
/// More then one keybinding produces a chord.
///
/// This should always contain at least one element.
key_binding: gpui::KeyBinding,
}
impl RenderOnce for KeyBinding {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
h_stack()
.flex_none()
.gap_2()
.children(self.key_binding.keystrokes().iter().map(|keystroke| {
let key_icon = Self::icon_for_key(&keystroke);
h_stack()
.flex_none()
.gap_0p5()
.bg(cx.theme().colors().element_background)
.p_0p5()
.rounded_sm()
.when(keystroke.modifiers.function, |el| el.child(Key::new("fn")))
.when(keystroke.modifiers.control, |el| {
el.child(KeyIcon::new(Icon::Control))
})
.when(keystroke.modifiers.alt, |el| {
el.child(KeyIcon::new(Icon::Option))
})
.when(keystroke.modifiers.command, |el| {
el.child(KeyIcon::new(Icon::Command))
})
.when(keystroke.modifiers.shift, |el| {
el.child(KeyIcon::new(Icon::Shift))
})
.when_some(key_icon, |el, icon| el.child(KeyIcon::new(icon)))
.when(key_icon.is_none(), |el| {
el.child(Key::new(keystroke.key.to_uppercase().clone()))
})
}))
}
}
impl KeyBinding {
pub fn for_action(action: &dyn Action, cx: &mut WindowContext) -> Option<Self> {
let key_binding = cx.bindings_for_action(action).last().cloned()?;
Some(Self::new(key_binding))
}
// like for_action(), but lets you specify the context from which keybindings
// are matched.
pub fn for_action_in(
action: &dyn Action,
focus: &FocusHandle,
cx: &mut WindowContext,
) -> Option<Self> {
let key_binding = cx.bindings_for_action_in(action, focus).last().cloned()?;
Some(Self::new(key_binding))
}
fn icon_for_key(keystroke: &Keystroke) -> Option<Icon> {
match keystroke.key.as_str() {
"left" => Some(Icon::ArrowLeft),
"right" => Some(Icon::ArrowRight),
"up" => Some(Icon::ArrowUp),
"down" => Some(Icon::ArrowDown),
"backspace" => Some(Icon::Backspace),
"delete" => Some(Icon::Delete),
_ => None,
}
}
pub fn new(key_binding: gpui::KeyBinding) -> Self {
Self { key_binding }
}
}
#[derive(IntoElement)]
pub struct Key {
key: SharedString,
}
impl RenderOnce for Key {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let single_char = self.key.len() == 1;
div()
.py_0()
.map(|this| {
if single_char {
this.w(rems(14. / 16.)).flex().flex_none().justify_center()
} else {
this.px_0p5()
}
})
.h(rems(14. / 16.))
.text_ui()
.line_height(relative(1.))
.text_color(cx.theme().colors().text)
.child(self.key.clone())
}
}
impl Key {
pub fn new(key: impl Into<SharedString>) -> Self {
Self { key: key.into() }
}
}
#[derive(IntoElement)]
pub struct KeyIcon {
icon: Icon,
}
impl RenderOnce for KeyIcon {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
div()
.w(rems(14. / 16.))
.child(IconElement::new(self.icon).size(IconSize::Small))
}
}
impl KeyIcon {
pub fn new(icon: Icon) -> Self {
Self { icon }
}
}

View file

@ -0,0 +1,7 @@
mod highlighted_label;
mod label;
mod label_like;
pub use highlighted_label::*;
pub use label::*;
pub use label_like::*;

View file

@ -0,0 +1,84 @@
use std::ops::Range;
use gpui::{HighlightStyle, StyledText};
use crate::{prelude::*, LabelCommon, LabelLike, LabelSize, LineHeightStyle};
#[derive(IntoElement)]
pub struct HighlightedLabel {
base: LabelLike,
label: SharedString,
highlight_indices: Vec<usize>,
}
impl HighlightedLabel {
/// Constructs a label with the given characters highlighted.
/// Characters are identified by UTF-8 byte position.
pub fn new(label: impl Into<SharedString>, highlight_indices: Vec<usize>) -> Self {
Self {
base: LabelLike::new(),
label: label.into(),
highlight_indices,
}
}
}
impl LabelCommon for HighlightedLabel {
fn size(mut self, size: LabelSize) -> Self {
self.base = self.base.size(size);
self
}
fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
self.base = self.base.line_height_style(line_height_style);
self
}
fn color(mut self, color: Color) -> Self {
self.base = self.base.color(color);
self
}
fn strikethrough(mut self, strikethrough: bool) -> Self {
self.base = self.base.strikethrough(strikethrough);
self
}
}
impl RenderOnce for HighlightedLabel {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let highlight_color = cx.theme().colors().text_accent;
let mut highlight_indices = self.highlight_indices.iter().copied().peekable();
let mut highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
while let Some(start_ix) = highlight_indices.next() {
let mut end_ix = start_ix;
loop {
end_ix = end_ix + self.label[end_ix..].chars().next().unwrap().len_utf8();
if let Some(&next_ix) = highlight_indices.peek() {
if next_ix == end_ix {
end_ix = next_ix;
highlight_indices.next();
continue;
}
}
break;
}
highlights.push((
start_ix..end_ix,
HighlightStyle {
color: Some(highlight_color),
..Default::default()
},
));
}
let mut text_style = cx.text_style().clone();
text_style.color = self.base.color.color(cx);
LabelLike::new().child(StyledText::new(self.label).with_highlights(&text_style, highlights))
}
}

View file

@ -0,0 +1,46 @@
use gpui::WindowContext;
use crate::{prelude::*, LabelCommon, LabelLike, LabelSize, LineHeightStyle};
#[derive(IntoElement)]
pub struct Label {
base: LabelLike,
label: SharedString,
}
impl Label {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
base: LabelLike::new(),
label: label.into(),
}
}
}
impl LabelCommon for Label {
fn size(mut self, size: LabelSize) -> Self {
self.base = self.base.size(size);
self
}
fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
self.base = self.base.line_height_style(line_height_style);
self
}
fn color(mut self, color: Color) -> Self {
self.base = self.base.color(color);
self
}
fn strikethrough(mut self, strikethrough: bool) -> Self {
self.base = self.base.strikethrough(strikethrough);
self
}
}
impl RenderOnce for Label {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
self.base.child(self.label)
}
}

View file

@ -0,0 +1,102 @@
use gpui::{relative, AnyElement, Styled};
use smallvec::SmallVec;
use crate::prelude::*;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum LabelSize {
#[default]
Default,
Small,
XSmall,
}
#[derive(Default, PartialEq, Copy, Clone)]
pub enum LineHeightStyle {
#[default]
TextLabel,
/// Sets the line height to 1.
UiLabel,
}
pub trait LabelCommon {
fn size(self, size: LabelSize) -> Self;
fn line_height_style(self, line_height_style: LineHeightStyle) -> Self;
fn color(self, color: Color) -> Self;
fn strikethrough(self, strikethrough: bool) -> Self;
}
#[derive(IntoElement)]
pub struct LabelLike {
size: LabelSize,
line_height_style: LineHeightStyle,
pub(crate) color: Color,
strikethrough: bool,
children: SmallVec<[AnyElement; 2]>,
}
impl LabelLike {
pub fn new() -> Self {
Self {
size: LabelSize::Default,
line_height_style: LineHeightStyle::default(),
color: Color::Default,
strikethrough: false,
children: SmallVec::new(),
}
}
}
impl LabelCommon for LabelLike {
fn size(mut self, size: LabelSize) -> Self {
self.size = size;
self
}
fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
self.line_height_style = line_height_style;
self
}
fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
fn strikethrough(mut self, strikethrough: bool) -> Self {
self.strikethrough = strikethrough;
self
}
}
impl ParentElement for LabelLike {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}
impl RenderOnce for LabelLike {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
div()
.when(self.strikethrough, |this| {
this.relative().child(
div()
.absolute()
.top_1_2()
.w_full()
.h_px()
.bg(Color::Hidden.color(cx)),
)
})
.map(|this| match self.size {
LabelSize::Default => this.text_ui(),
LabelSize::Small => this.text_ui_sm(),
LabelSize::XSmall => this.text_ui_xs(),
})
.when(self.line_height_style == LineHeightStyle::UiLabel, |this| {
this.line_height(relative(1.))
})
.text_color(self.color.color(cx))
.children(self.children)
}
}

View file

@ -0,0 +1,11 @@
mod list;
mod list_header;
mod list_item;
mod list_separator;
mod list_sub_header;
pub use list::*;
pub use list_header::*;
pub use list_item::*;
pub use list_separator::*;
pub use list_sub_header::*;

View file

@ -0,0 +1,58 @@
use gpui::AnyElement;
use smallvec::SmallVec;
use crate::{prelude::*, v_stack, Label, ListHeader};
#[derive(IntoElement)]
pub struct List {
/// Message to display when the list is empty
/// Defaults to "No items"
empty_message: SharedString,
header: Option<ListHeader>,
toggle: Option<bool>,
children: SmallVec<[AnyElement; 2]>,
}
impl List {
pub fn new() -> Self {
Self {
empty_message: "No items".into(),
header: None,
toggle: None,
children: SmallVec::new(),
}
}
pub fn empty_message(mut self, empty_message: impl Into<SharedString>) -> Self {
self.empty_message = empty_message.into();
self
}
pub fn header(mut self, header: impl Into<Option<ListHeader>>) -> Self {
self.header = header.into();
self
}
pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
self.toggle = toggle.into();
self
}
}
impl ParentElement for List {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}
impl RenderOnce for List {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
v_stack().w_full().py_1().children(self.header).map(|this| {
match (self.children.is_empty(), self.toggle) {
(false, _) => this.children(self.children),
(true, Some(false)) => this,
(true, _) => this.child(Label::new(self.empty_message.clone()).color(Color::Muted)),
}
})
}
}

View file

@ -0,0 +1,124 @@
use crate::{h_stack, prelude::*, Disclosure, Label};
use gpui::{AnyElement, ClickEvent};
#[derive(IntoElement)]
pub struct ListHeader {
/// The label of the header.
label: SharedString,
/// A slot for content that appears before the label, like an icon or avatar.
start_slot: Option<AnyElement>,
/// A slot for content that appears after the label, usually on the other side of the header.
/// This might be a button, a disclosure arrow, a face pile, etc.
end_slot: Option<AnyElement>,
/// A slot for content that appears on hover after the label
/// It will obscure the `end_slot` when visible.
end_hover_slot: Option<AnyElement>,
toggle: Option<bool>,
on_toggle: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
inset: bool,
selected: bool,
}
impl ListHeader {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
start_slot: None,
end_slot: None,
end_hover_slot: None,
inset: false,
toggle: None,
on_toggle: None,
selected: false,
}
}
pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
self.toggle = toggle.into();
self
}
pub fn on_toggle(
mut self,
on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_toggle = Some(Box::new(on_toggle));
self
}
pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
self.start_slot = start_slot.into().map(IntoElement::into_any_element);
self
}
pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
self.end_slot = end_slot.into().map(IntoElement::into_any_element);
self
}
pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
self
}
pub fn inset(mut self, inset: bool) -> Self {
self.inset = inset;
self
}
}
impl Selectable for ListHeader {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl RenderOnce for ListHeader {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
h_stack()
.id(self.label.clone())
.w_full()
.relative()
.group("list_header")
.child(
div()
.h_7()
.when(self.inset, |this| this.px_2())
.when(self.selected, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
.flex()
.flex_1()
.items_center()
.justify_between()
.w_full()
.gap_1()
.child(
h_stack()
.gap_1()
.children(self.toggle.map(|is_open| {
Disclosure::new("toggle", is_open).on_toggle(self.on_toggle)
}))
.child(
div()
.flex()
.gap_1()
.items_center()
.children(self.start_slot)
.child(Label::new(self.label.clone()).color(Color::Muted)),
),
)
.child(h_stack().children(self.end_slot))
.when_some(self.end_hover_slot, |this, end_hover_slot| {
this.child(
div()
.absolute()
.right_0()
.visible_on_hover("list_header")
.child(end_hover_slot),
)
}),
)
}
}

View file

@ -0,0 +1,254 @@
use gpui::{px, AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels};
use smallvec::SmallVec;
use crate::{prelude::*, Disclosure};
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum ListItemSpacing {
#[default]
Dense,
Sparse,
}
#[derive(IntoElement)]
pub struct ListItem {
id: ElementId,
disabled: bool,
selected: bool,
spacing: ListItemSpacing,
indent_level: usize,
indent_step_size: Pixels,
/// A slot for content that appears before the children, like an icon or avatar.
start_slot: Option<AnyElement>,
/// A slot for content that appears after the children, usually on the other side of the header.
/// This might be a button, a disclosure arrow, a face pile, etc.
end_slot: Option<AnyElement>,
/// A slot for content that appears on hover after the children
/// It will obscure the `end_slot` when visible.
end_hover_slot: Option<AnyElement>,
toggle: Option<bool>,
inset: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
on_toggle: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView + 'static>>,
on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut WindowContext) + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
disabled: false,
selected: false,
spacing: ListItemSpacing::Dense,
indent_level: 0,
indent_step_size: px(12.),
start_slot: None,
end_slot: None,
end_hover_slot: None,
toggle: None,
inset: false,
on_click: None,
on_secondary_mouse_down: None,
on_toggle: None,
tooltip: None,
children: SmallVec::new(),
}
}
pub fn spacing(mut self, spacing: ListItemSpacing) -> Self {
self.spacing = spacing;
self
}
pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn on_secondary_mouse_down(
mut self,
handler: impl Fn(&MouseDownEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_secondary_mouse_down = Some(Box::new(handler));
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self {
self.tooltip = Some(Box::new(tooltip));
self
}
pub fn inset(mut self, inset: bool) -> Self {
self.inset = inset;
self
}
pub fn indent_level(mut self, indent_level: usize) -> Self {
self.indent_level = indent_level;
self
}
pub fn indent_step_size(mut self, indent_step_size: Pixels) -> Self {
self.indent_step_size = indent_step_size;
self
}
pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
self.toggle = toggle.into();
self
}
pub fn on_toggle(
mut self,
on_toggle: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_toggle = Some(Box::new(on_toggle));
self
}
pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
self.start_slot = start_slot.into().map(IntoElement::into_any_element);
self
}
pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
self.end_slot = end_slot.into().map(IntoElement::into_any_element);
self
}
pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
self
}
}
impl Disableable for ListItem {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for ListItem {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl ParentElement for ListItem {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}
impl RenderOnce for ListItem {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
h_stack()
.id(self.id)
.w_full()
.relative()
// When an item is inset draw the indent spacing outside of the item
.when(self.inset, |this| {
this.ml(self.indent_level as f32 * self.indent_step_size)
.px_2()
})
.when(!self.inset, |this| {
this
// TODO: Add focus state
// .when(self.state == InteractionState::Focused, |this| {
// this.border()
// .border_color(cx.theme().colors().border_focused)
// })
.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
.active(|style| style.bg(cx.theme().colors().ghost_element_active))
.when(self.selected, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
})
.child(
h_stack()
.id("inner_list_item")
.w_full()
.relative()
.gap_1()
.px_2()
.map(|this| match self.spacing {
ListItemSpacing::Dense => this,
ListItemSpacing::Sparse => this.py_1(),
})
.group("list_item")
.when(self.inset && !self.disabled, |this| {
this
// TODO: Add focus state
// .when(self.state == InteractionState::Focused, |this| {
// this.border()
// .border_color(cx.theme().colors().border_focused)
// })
.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
.active(|style| style.bg(cx.theme().colors().ghost_element_active))
.when(self.selected, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
})
.when_some(self.on_click, |this, on_click| {
this.cursor_pointer().on_click(on_click)
})
.when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
this.on_mouse_down(MouseButton::Right, move |event, cx| {
(on_mouse_down)(event, cx)
})
})
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
.map(|this| {
if self.inset {
this.rounded_md()
} else {
// When an item is not inset draw the indent spacing inside of the item
this.ml(self.indent_level as f32 * self.indent_step_size)
}
})
.children(self.toggle.map(|is_open| {
div()
.flex()
.absolute()
.left(rems(-1.))
.when(is_open, |this| this.visible_on_hover(""))
.child(Disclosure::new("toggle", is_open).on_toggle(self.on_toggle))
}))
.child(
h_stack()
// HACK: We need to set *any* width value here in order for this container to size correctly.
// Without this the `h_stack` will overflow the parent `inner_list_item`.
.w_px()
.flex_1()
.gap_1()
.children(self.start_slot)
.children(self.children),
)
.when_some(self.end_slot, |this, end_slot| {
this.justify_between().child(
h_stack()
.when(self.end_hover_slot.is_some(), |this| {
this.visible()
.group_hover("list_item", |this| this.invisible())
})
.child(end_slot),
)
})
.when_some(self.end_hover_slot, |this, end_hover_slot| {
this.child(
h_stack()
.h_full()
.absolute()
.right_2()
.top_0()
.visible_on_hover("list_item")
.child(end_hover_slot),
)
}),
)
}
}

View file

@ -0,0 +1,14 @@
use crate::prelude::*;
#[derive(IntoElement)]
pub struct ListSeparator;
impl RenderOnce for ListSeparator {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
div()
.h_px()
.w_full()
.my_1()
.bg(cx.theme().colors().border_variant)
}
}

View file

@ -0,0 +1,52 @@
use crate::prelude::*;
use crate::{h_stack, Icon, IconElement, IconSize, Label};
#[derive(IntoElement)]
pub struct ListSubHeader {
label: SharedString,
start_slot: Option<Icon>,
inset: bool,
}
impl ListSubHeader {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
start_slot: None,
inset: false,
}
}
pub fn left_icon(mut self, left_icon: Option<Icon>) -> Self {
self.start_slot = left_icon;
self
}
}
impl RenderOnce for ListSubHeader {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
h_stack().flex_1().w_full().relative().py_1().child(
div()
.h_6()
.when(self.inset, |this| this.px_2())
.flex()
.flex_1()
.w_full()
.gap_1()
.items_center()
.justify_between()
.child(
div()
.flex()
.gap_1()
.items_center()
.children(self.start_slot.map(|i| {
IconElement::new(i)
.color(Color::Muted)
.size(IconSize::Small)
}))
.child(Label::new(self.label.clone()).color(Color::Muted)),
),
)
}
}

View file

@ -0,0 +1,80 @@
use crate::prelude::*;
use crate::v_stack;
use gpui::{
div, AnyElement, Element, IntoElement, ParentElement, RenderOnce, Styled, WindowContext,
};
use smallvec::SmallVec;
/// A popover is used to display a menu or show some options.
///
/// Clicking the element that launches the popover should not change the current view,
/// and the popover should be statically positioned relative to that element (not the
/// user's mouse.)
///
/// Example: A "new" menu with options like "new file", "new folder", etc,
/// Linear's "Display" menu, a profile menu that appers when you click your avatar.
///
/// Related elements:
///
/// `ContextMenu`:
///
/// Used to display a popover menu that only contains a list of items. Context menus are always
/// launched by secondary clicking on an element. The menu is positioned relative to the user's cursor.
///
/// Example: Right clicking a file in the file tree to get a list of actions, right clicking
/// a tab to in the tab bar to get a list of actions.
///
/// `Dropdown`:
///
/// Used to display a list of options when the user clicks an element. The menu is
/// positioned relative the element that was clicked, and clicking an item in the
/// dropdown should change the value of the element that was clicked.
///
/// Example: A theme select control. Displays "One Dark", clicking it opens a list of themes.
/// When one is selected, the theme select control displays the selected theme.
#[derive(IntoElement)]
pub struct Popover {
children: SmallVec<[AnyElement; 2]>,
aside: Option<AnyElement>,
}
impl RenderOnce for Popover {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
div()
.flex()
.gap_1()
.child(v_stack().elevation_2(cx).px_1().children(self.children))
.when_some(self.aside, |this, aside| {
this.child(
v_stack()
.elevation_2(cx)
.bg(cx.theme().colors().surface_background)
.px_1()
.child(aside),
)
})
}
}
impl Popover {
pub fn new() -> Self {
Self {
children: SmallVec::new(),
aside: None,
}
}
pub fn aside(mut self, aside: impl IntoElement) -> Self
where
Self: Sized,
{
self.aside = Some(aside.into_element().into_any());
self
}
}
impl ParentElement for Popover {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}

View file

@ -0,0 +1,233 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
overlay, point, px, rems, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase,
Element, ElementId, InteractiveBounds, IntoElement, LayoutId, ManagedView, MouseDownEvent,
ParentElement, Pixels, Point, View, VisualContext, WindowContext,
};
use crate::{Clickable, Selectable};
pub trait PopoverTrigger: IntoElement + Clickable + Selectable + 'static {}
impl<T: IntoElement + Clickable + Selectable + 'static> PopoverTrigger for T {}
pub struct PopoverMenu<M: ManagedView> {
id: ElementId,
child_builder: Option<
Box<
dyn FnOnce(
Rc<RefCell<Option<View<M>>>>,
Option<Rc<dyn Fn(&mut WindowContext) -> Option<View<M>> + 'static>>,
) -> AnyElement
+ 'static,
>,
>,
menu_builder: Option<Rc<dyn Fn(&mut WindowContext) -> Option<View<M>> + 'static>>,
anchor: AnchorCorner,
attach: Option<AnchorCorner>,
offset: Option<Point<Pixels>>,
}
impl<M: ManagedView> PopoverMenu<M> {
pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> Option<View<M>> + 'static) -> Self {
self.menu_builder = Some(Rc::new(f));
self
}
pub fn trigger<T: PopoverTrigger>(mut self, t: T) -> Self {
self.child_builder = Some(Box::new(|menu, builder| {
let open = menu.borrow().is_some();
t.selected(open)
.when_some(builder, |el, builder| {
el.on_click({
move |_, cx| {
let Some(new_menu) = (builder)(cx) else {
return;
};
let menu2 = menu.clone();
let previous_focus_handle = cx.focused();
cx.subscribe(&new_menu, move |modal, _: &DismissEvent, cx| {
if modal.focus_handle(cx).contains_focused(cx) {
if previous_focus_handle.is_some() {
cx.focus(&previous_focus_handle.as_ref().unwrap())
}
}
*menu2.borrow_mut() = None;
cx.notify();
})
.detach();
cx.focus_view(&new_menu);
*menu.borrow_mut() = Some(new_menu);
}
})
})
.into_any_element()
}));
self
}
/// anchor defines which corner of the menu to anchor to the attachment point
/// (by default the cursor position, but see attach)
pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
self.anchor = anchor;
self
}
/// attach defines which corner of the handle to attach the menu's anchor to
pub fn attach(mut self, attach: AnchorCorner) -> Self {
self.attach = Some(attach);
self
}
/// offset offsets the position of the content by that many pixels.
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
self.offset = Some(offset);
self
}
fn resolved_attach(&self) -> AnchorCorner {
self.attach.unwrap_or_else(|| match self.anchor {
AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
AnchorCorner::TopRight => AnchorCorner::BottomRight,
AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
AnchorCorner::BottomRight => AnchorCorner::TopRight,
})
}
fn resolved_offset(&self, cx: &WindowContext) -> Point<Pixels> {
self.offset.unwrap_or_else(|| {
// Default offset = 4px padding + 1px border
let offset = rems(5. / 16.) * cx.rem_size();
match self.anchor {
AnchorCorner::TopRight | AnchorCorner::BottomRight => point(offset, px(0.)),
AnchorCorner::TopLeft | AnchorCorner::BottomLeft => point(-offset, px(0.)),
}
})
}
}
pub fn popover_menu<M: ManagedView>(id: impl Into<ElementId>) -> PopoverMenu<M> {
PopoverMenu {
id: id.into(),
child_builder: None,
menu_builder: None,
anchor: AnchorCorner::TopLeft,
attach: None,
offset: None,
}
}
pub struct PopoverMenuState<M> {
child_layout_id: Option<LayoutId>,
child_element: Option<AnyElement>,
child_bounds: Option<Bounds<Pixels>>,
menu_element: Option<AnyElement>,
menu: Rc<RefCell<Option<View<M>>>>,
}
impl<M: ManagedView> Element for PopoverMenu<M> {
type State = PopoverMenuState<M>;
fn request_layout(
&mut self,
element_state: Option<Self::State>,
cx: &mut WindowContext,
) -> (gpui::LayoutId, Self::State) {
let mut menu_layout_id = None;
let (menu, child_bounds) = if let Some(element_state) = element_state {
(element_state.menu, element_state.child_bounds)
} else {
(Rc::default(), None)
};
let menu_element = menu.borrow_mut().as_mut().map(|menu| {
let mut overlay = overlay().snap_to_window().anchor(self.anchor);
if let Some(child_bounds) = child_bounds {
overlay = overlay.position(
self.resolved_attach().corner(child_bounds) + self.resolved_offset(cx),
);
}
let mut element = overlay.child(menu.clone()).into_any();
menu_layout_id = Some(element.request_layout(cx));
element
});
let mut child_element = self
.child_builder
.take()
.map(|child_builder| (child_builder)(menu.clone(), self.menu_builder.clone()));
let child_layout_id = child_element
.as_mut()
.map(|child_element| child_element.request_layout(cx));
let layout_id = cx.request_layout(
&gpui::Style::default(),
menu_layout_id.into_iter().chain(child_layout_id),
);
(
layout_id,
PopoverMenuState {
menu,
child_element,
child_layout_id,
menu_element,
child_bounds,
},
)
}
fn paint(
&mut self,
_: Bounds<gpui::Pixels>,
element_state: &mut Self::State,
cx: &mut WindowContext,
) {
if let Some(mut child) = element_state.child_element.take() {
child.paint(cx);
}
if let Some(child_layout_id) = element_state.child_layout_id.take() {
element_state.child_bounds = Some(cx.layout_bounds(child_layout_id));
}
if let Some(mut menu) = element_state.menu_element.take() {
menu.paint(cx);
if let Some(child_bounds) = element_state.child_bounds {
let interactive_bounds = InteractiveBounds {
bounds: child_bounds,
stacking_order: cx.stacking_order().clone(),
};
// Mouse-downing outside the menu dismisses it, so we don't
// want a click on the toggle to re-open it.
cx.on_mouse_event(move |e: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& interactive_bounds.visibly_contains(&e.position, cx)
{
cx.stop_propagation()
}
})
}
}
}
}
impl<M: ManagedView> IntoElement for PopoverMenu<M> {
type Element = Self;
fn element_id(&self) -> Option<gpui::ElementId> {
Some(self.id.clone())
}
fn into_element(self) -> Self::Element {
self
}
}

View file

@ -0,0 +1,185 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
overlay, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase, Element, ElementId,
IntoElement, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
View, VisualContext, WindowContext,
};
pub struct RightClickMenu<M: ManagedView> {
id: ElementId,
child_builder: Option<Box<dyn FnOnce(bool) -> AnyElement + 'static>>,
menu_builder: Option<Rc<dyn Fn(&mut WindowContext) -> View<M> + 'static>>,
anchor: Option<AnchorCorner>,
attach: Option<AnchorCorner>,
}
impl<M: ManagedView> RightClickMenu<M> {
pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View<M> + 'static) -> Self {
self.menu_builder = Some(Rc::new(f));
self
}
pub fn trigger<E: IntoElement + 'static>(mut self, e: E) -> Self {
self.child_builder = Some(Box::new(move |_| e.into_any_element()));
self
}
/// anchor defines which corner of the menu to anchor to the attachment point
/// (by default the cursor position, but see attach)
pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
self.anchor = Some(anchor);
self
}
/// attach defines which corner of the handle to attach the menu's anchor to
pub fn attach(mut self, attach: AnchorCorner) -> Self {
self.attach = Some(attach);
self
}
}
pub fn right_click_menu<M: ManagedView>(id: impl Into<ElementId>) -> RightClickMenu<M> {
RightClickMenu {
id: id.into(),
child_builder: None,
menu_builder: None,
anchor: None,
attach: None,
}
}
pub struct MenuHandleState<M> {
menu: Rc<RefCell<Option<View<M>>>>,
position: Rc<RefCell<Point<Pixels>>>,
child_layout_id: Option<LayoutId>,
child_element: Option<AnyElement>,
menu_element: Option<AnyElement>,
}
impl<M: ManagedView> Element for RightClickMenu<M> {
type State = MenuHandleState<M>;
fn request_layout(
&mut self,
element_state: Option<Self::State>,
cx: &mut WindowContext,
) -> (gpui::LayoutId, Self::State) {
let (menu, position) = if let Some(element_state) = element_state {
(element_state.menu, element_state.position)
} else {
(Rc::default(), Rc::default())
};
let mut menu_layout_id = None;
let menu_element = menu.borrow_mut().as_mut().map(|menu| {
let mut overlay = overlay().snap_to_window();
if let Some(anchor) = self.anchor {
overlay = overlay.anchor(anchor);
}
overlay = overlay.position(*position.borrow());
let mut element = overlay.child(menu.clone()).into_any();
menu_layout_id = Some(element.request_layout(cx));
element
});
let mut child_element = self
.child_builder
.take()
.map(|child_builder| (child_builder)(menu.borrow().is_some()));
let child_layout_id = child_element
.as_mut()
.map(|child_element| child_element.request_layout(cx));
let layout_id = cx.request_layout(
&gpui::Style::default(),
menu_layout_id.into_iter().chain(child_layout_id),
);
(
layout_id,
MenuHandleState {
menu,
position,
child_element,
child_layout_id,
menu_element,
},
)
}
fn paint(
&mut self,
bounds: Bounds<gpui::Pixels>,
element_state: &mut Self::State,
cx: &mut WindowContext,
) {
if let Some(mut child) = element_state.child_element.take() {
child.paint(cx);
}
if let Some(mut menu) = element_state.menu_element.take() {
menu.paint(cx);
return;
}
let Some(builder) = self.menu_builder.take() else {
return;
};
let menu = element_state.menu.clone();
let position = element_state.position.clone();
let attach = self.attach.clone();
let child_layout_id = element_state.child_layout_id.clone();
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& event.button == MouseButton::Right
&& bounds.contains(&event.position)
{
cx.stop_propagation();
cx.prevent_default();
let new_menu = (builder)(cx);
let menu2 = menu.clone();
let previous_focus_handle = cx.focused();
cx.subscribe(&new_menu, move |modal, _: &DismissEvent, cx| {
if modal.focus_handle(cx).contains_focused(cx) {
if previous_focus_handle.is_some() {
cx.focus(&previous_focus_handle.as_ref().unwrap())
}
}
*menu2.borrow_mut() = None;
cx.notify();
})
.detach();
cx.focus_view(&new_menu);
*menu.borrow_mut() = Some(new_menu);
*position.borrow_mut() = if attach.is_some() && child_layout_id.is_some() {
attach
.unwrap()
.corner(cx.layout_bounds(child_layout_id.unwrap()))
} else {
cx.mouse_position()
};
cx.notify();
}
});
}
}
impl<M: ManagedView> IntoElement for RightClickMenu<M> {
type Element = Self;
fn element_id(&self) -> Option<gpui::ElementId> {
Some(self.id.clone())
}
fn into_element(self) -> Self::Element {
self
}
}

View file

@ -0,0 +1,19 @@
use gpui::{div, Div};
use crate::StyledExt;
/// Horizontally stacks elements.
///
/// Sets `flex()`, `flex_row()`, `items_center()`
#[track_caller]
pub fn h_stack() -> Div {
div().h_flex()
}
/// Vertically stacks elements.
///
/// Sets `flex()`, `flex_col()`
#[track_caller]
pub fn v_stack() -> Div {
div().v_flex()
}

View file

@ -0,0 +1,31 @@
mod avatar;
mod button;
mod checkbox;
mod context_menu;
mod disclosure;
mod icon;
mod icon_button;
mod keybinding;
mod label;
mod list;
mod list_header;
mod list_item;
mod tab;
mod tab_bar;
mod toggle_button;
pub use avatar::*;
pub use button::*;
pub use checkbox::*;
pub use context_menu::*;
pub use disclosure::*;
pub use icon::*;
pub use icon_button::*;
pub use keybinding::*;
pub use label::*;
pub use list::*;
pub use list_header::*;
pub use list_item::*;
pub use tab::*;
pub use tab_bar::*;
pub use toggle_button::*;

View file

@ -0,0 +1,29 @@
use gpui::Render;
use story::Story;
use crate::prelude::*;
use crate::Avatar;
pub struct AvatarStory;
impl Render for AvatarStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<Avatar>())
.child(Story::label("Default"))
.child(Avatar::new(
"https://avatars.githubusercontent.com/u/1714999?v=4",
))
.child(Avatar::new(
"https://avatars.githubusercontent.com/u/326587?v=4",
))
.child(
Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
.availability_indicator(true),
)
.child(
Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
.availability_indicator(false),
)
}
}

View file

@ -0,0 +1,38 @@
use gpui::Render;
use story::Story;
use crate::{prelude::*, Icon};
use crate::{Button, ButtonStyle};
pub struct ButtonStory;
impl Render for ButtonStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<Button>())
.child(Story::label("Default"))
.child(Button::new("default_filled", "Click me"))
.child(Story::label("Selected"))
.child(Button::new("selected_filled", "Click me").selected(true))
.child(Story::label("Selected with `selected_label`"))
.child(
Button::new("selected_label_filled", "Click me")
.selected(true)
.selected_label("I have been selected"),
)
.child(Story::label("With `label_color`"))
.child(Button::new("filled_with_label_color", "Click me").color(Color::Created))
.child(Story::label("With `icon`"))
.child(Button::new("filled_with_icon", "Click me").icon(Icon::FileGit))
.child(Story::label("Selected with `icon`"))
.child(
Button::new("filled_and_selected_with_icon", "Click me")
.selected(true)
.icon(Icon::FileGit),
)
.child(Story::label("Default (Subtle)"))
.child(Button::new("default_subtle", "Click me").style(ButtonStyle::Subtle))
.child(Story::label("Default (Transparent)"))
.child(Button::new("default_transparent", "Click me").style(ButtonStyle::Transparent))
}
}

View file

@ -0,0 +1,47 @@
use gpui::{Render, ViewContext};
use story::Story;
use crate::prelude::*;
use crate::{h_stack, Checkbox};
pub struct CheckboxStory;
impl Render for CheckboxStory {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<Checkbox>())
.child(Story::label("Default"))
.child(
h_stack()
.p_2()
.gap_2()
.rounded_md()
.border()
.border_color(cx.theme().colors().border)
.child(Checkbox::new("checkbox-enabled", Selection::Unselected))
.child(Checkbox::new(
"checkbox-intermediate",
Selection::Indeterminate,
))
.child(Checkbox::new("checkbox-selected", Selection::Selected)),
)
.child(Story::label("Disabled"))
.child(
h_stack()
.p_2()
.gap_2()
.rounded_md()
.border()
.border_color(cx.theme().colors().border)
.child(Checkbox::new("checkbox-disabled", Selection::Unselected).disabled(true))
.child(
Checkbox::new("checkbox-disabled-intermediate", Selection::Indeterminate)
.disabled(true),
)
.child(
Checkbox::new("checkbox-disabled-selected", Selection::Selected)
.disabled(true),
),
)
}
}

View file

@ -0,0 +1,75 @@
use gpui::{actions, AnchorCorner, Render, View};
use story::Story;
use crate::prelude::*;
use crate::{right_click_menu, ContextMenu, Label};
actions!(context_menu, [PrintCurrentDate, PrintBestFood]);
fn build_menu(cx: &mut WindowContext, header: impl Into<SharedString>) -> View<ContextMenu> {
ContextMenu::build(cx, |menu, _| {
menu.header(header)
.separator()
.action("Print current time", Box::new(PrintCurrentDate))
.entry("Print best food", Some(Box::new(PrintBestFood)), |cx| {
cx.dispatch_action(Box::new(PrintBestFood))
})
})
}
pub struct ContextMenuStory;
impl Render for ContextMenuStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.on_action(|_: &PrintCurrentDate, _| {
println!("printing unix time!");
if let Ok(unix_time) = std::time::UNIX_EPOCH.elapsed() {
println!("Current Unix time is {:?}", unix_time.as_secs());
}
})
.on_action(|_: &PrintBestFood, _| {
println!("burrito");
})
.flex()
.flex_row()
.justify_between()
.child(
div()
.flex()
.flex_col()
.justify_between()
.child(
right_click_menu("test2")
.trigger(Label::new("TOP LEFT"))
.menu(move |cx| build_menu(cx, "top left")),
)
.child(
right_click_menu("test1")
.trigger(Label::new("BOTTOM LEFT"))
.anchor(AnchorCorner::BottomLeft)
.attach(AnchorCorner::TopLeft)
.menu(move |cx| build_menu(cx, "bottom left")),
),
)
.child(
div()
.flex()
.flex_col()
.justify_between()
.child(
right_click_menu("test3")
.trigger(Label::new("TOP RIGHT"))
.anchor(AnchorCorner::TopRight)
.menu(move |cx| build_menu(cx, "top right")),
)
.child(
right_click_menu("test4")
.trigger(Label::new("BOTTOM RIGHT"))
.anchor(AnchorCorner::BottomRight)
.attach(AnchorCorner::TopRight)
.menu(move |cx| build_menu(cx, "bottom right")),
),
)
}
}

View file

@ -0,0 +1,18 @@
use gpui::Render;
use story::Story;
use crate::prelude::*;
use crate::Disclosure;
pub struct DisclosureStory;
impl Render for DisclosureStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<Disclosure>())
.child(Story::label("Toggled"))
.child(Disclosure::new("toggled", true))
.child(Story::label("Not Toggled"))
.child(Disclosure::new("not_toggled", false))
}
}

View file

@ -0,0 +1,19 @@
use gpui::Render;
use story::Story;
use strum::IntoEnumIterator;
use crate::prelude::*;
use crate::{Icon, IconElement};
pub struct IconStory;
impl Render for IconStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
let icons = Icon::iter();
Story::container()
.child(Story::title_for::<IconElement>())
.child(Story::label("All Icons"))
.child(div().flex().gap_3().children(icons.map(IconElement::new)))
}
}

View file

@ -0,0 +1,171 @@
use gpui::Render;
use story::{StoryContainer, StoryItem, StorySection};
use crate::{prelude::*, Tooltip};
use crate::{Icon, IconButton};
pub struct IconButtonStory;
impl Render for IconButtonStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
let default_button = StoryItem::new(
"Default",
IconButton::new("default_icon_button", Icon::Hash),
)
.description("Displays an icon button.")
.usage(
r#"
IconButton::new("default_icon_button", Icon::Hash)
"#,
);
let selected_button = StoryItem::new(
"Selected",
IconButton::new("selected_icon_button", Icon::Hash).selected(true),
)
.description("Displays an icon button that is selected.")
.usage(
r#"
IconButton::new("selected_icon_button", Icon::Hash).selected(true)
"#,
);
let selected_with_selected_icon = StoryItem::new(
"Selected with `selected_icon`",
IconButton::new("selected_with_selected_icon_button", Icon::AudioOn)
.selected(true)
.selected_icon(Icon::AudioOff),
)
.description(
"Displays an icon button that is selected and shows a different icon when selected.",
)
.usage(
r#"
IconButton::new("selected_with_selected_icon_button", Icon::AudioOn)
.selected(true)
.selected_icon(Icon::AudioOff)
"#,
);
let disabled_button = StoryItem::new(
"Disabled",
IconButton::new("disabled_icon_button", Icon::Hash).disabled(true),
)
.description("Displays an icon button that is disabled.")
.usage(
r#"
IconButton::new("disabled_icon_button", Icon::Hash).disabled(true)
"#,
);
let with_on_click_button = StoryItem::new(
"With `on_click`",
IconButton::new("with_on_click_button", Icon::Ai).on_click(|_event, _cx| {
println!("Clicked!");
}),
)
.description("Displays an icon button which triggers an event on click.")
.usage(
r#"
IconButton::new("with_on_click_button", Icon::Ai).on_click(|_event, _cx| {
println!("Clicked!");
})
"#,
);
let with_tooltip_button = StoryItem::new(
"With `tooltip`",
IconButton::new("with_tooltip_button", Icon::MessageBubbles)
.tooltip(|cx| Tooltip::text("Open messages", cx)),
)
.description("Displays an icon button that has a tooltip when hovered.")
.usage(
r#"
IconButton::new("with_tooltip_button", Icon::MessageBubbles)
.tooltip(|cx| Tooltip::text("Open messages", cx))
"#,
);
let selected_with_tooltip_button = StoryItem::new(
"Selected with `tooltip`",
IconButton::new("selected_with_tooltip_button", Icon::InlayHint)
.selected(true)
.tooltip(|cx| Tooltip::text("Toggle inlay hints", cx)),
)
.description("Displays a selected icon button with tooltip.")
.usage(
r#"
IconButton::new("selected_with_tooltip_button", Icon::InlayHint)
.selected(true)
.tooltip(|cx| Tooltip::text("Toggle inlay hints", cx))
"#,
);
let buttons = vec![
default_button,
selected_button,
selected_with_selected_icon,
disabled_button,
with_on_click_button,
with_tooltip_button,
selected_with_tooltip_button,
];
StoryContainer::new(
"Icon Button",
"crates/ui2/src/components/stories/icon_button.rs",
)
.children(vec![StorySection::new().children(buttons)])
.into_element()
// Story::container()
// .child(Story::title_for::<IconButton>())
// .child(Story::label("Default"))
// .child(div().w_8().child(IconButton::new("icon_a", Icon::Hash)))
// .child(Story::label("Selected"))
// .child(
// div()
// .w_8()
// .child(IconButton::new("icon_a", Icon::Hash).selected(true)),
// )
// .child(Story::label("Selected with `selected_icon`"))
// .child(
// div().w_8().child(
// IconButton::new("icon_a", Icon::AudioOn)
// .selected(true)
// .selected_icon(Icon::AudioOff),
// ),
// )
// .child(Story::label("Disabled"))
// .child(
// div()
// .w_8()
// .child(IconButton::new("icon_a", Icon::Hash).disabled(true)),
// )
// .child(Story::label("With `on_click`"))
// .child(
// div()
// .w_8()
// .child(
// IconButton::new("with_on_click", Icon::Ai).on_click(|_event, _cx| {
// println!("Clicked!");
// }),
// ),
// )
// .child(Story::label("With `tooltip`"))
// .child(
// div().w_8().child(
// IconButton::new("with_tooltip", Icon::MessageBubbles)
// .tooltip(|cx| Tooltip::text("Open messages", cx)),
// ),
// )
// .child(Story::label("Selected with `tooltip`"))
// .child(
// div().w_8().child(
// IconButton::new("selected_with_tooltip", Icon::InlayHint)
// .selected(true)
// .tooltip(|cx| Tooltip::text("Toggle inlay hints", cx)),
// ),
// )
}
}

View file

@ -0,0 +1,58 @@
use gpui::NoAction;
use gpui::Render;
use itertools::Itertools;
use story::Story;
use crate::prelude::*;
use crate::KeyBinding;
pub struct KeybindingStory;
pub fn binding(key: &str) -> gpui::KeyBinding {
gpui::KeyBinding::new(key, NoAction {}, None)
}
impl Render for KeybindingStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
let all_modifier_permutations = ["ctrl", "alt", "cmd", "shift"].into_iter().permutations(2);
Story::container()
.child(Story::title_for::<KeyBinding>())
.child(Story::label("Single Key"))
.child(KeyBinding::new(binding("Z")))
.child(Story::label("Single Key with Modifier"))
.child(
div()
.flex()
.gap_3()
.child(KeyBinding::new(binding("ctrl-c")))
.child(KeyBinding::new(binding("alt-c")))
.child(KeyBinding::new(binding("cmd-c")))
.child(KeyBinding::new(binding("shift-c"))),
)
.child(Story::label("Single Key with Modifier (Permuted)"))
.child(
div().flex().flex_col().children(
all_modifier_permutations
.chunks(4)
.into_iter()
.map(|chunk| {
div()
.flex()
.gap_4()
.py_3()
.children(chunk.map(|permutation| {
KeyBinding::new(binding(&*(permutation.join("-") + "-x")))
}))
}),
),
)
.child(Story::label("Single Key with All Modifiers"))
.child(KeyBinding::new(binding("ctrl-alt-cmd-shift-z")))
.child(Story::label("Chord"))
.child(KeyBinding::new(binding("a z")))
.child(Story::label("Chord with Modifier"))
.child(KeyBinding::new(binding("ctrl-a shift-z")))
.child(KeyBinding::new(binding("fn-s")))
}
}

View file

@ -0,0 +1,27 @@
use crate::{prelude::*, HighlightedLabel, Label};
use gpui::Render;
use story::Story;
pub struct LabelStory;
impl Render for LabelStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<Label>())
.child(Story::label("Default"))
.child(Label::new("Hello, world!"))
.child(Story::label("Highlighted"))
.child(HighlightedLabel::new(
"Hello, world!",
vec![0, 1, 2, 7, 8, 12],
))
.child(HighlightedLabel::new(
"Héllo, world!",
vec![0, 1, 3, 8, 9, 13],
))
.child(Story::label("Highlighted with `color`"))
.child(
HighlightedLabel::new("Hello, world!", vec![0, 1, 2, 7, 8, 12]).color(Color::Error),
)
}
}

View file

@ -0,0 +1,36 @@
use gpui::Render;
use story::Story;
use crate::{prelude::*, ListHeader, ListSeparator, ListSubHeader};
use crate::{List, ListItem};
pub struct ListStory;
impl Render for ListStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<List>())
.child(Story::label("Default"))
.child(
List::new()
.child(ListItem::new("apple").child("Apple"))
.child(ListItem::new("banana").child("Banana"))
.child(ListItem::new("cherry").child("Cherry")),
)
.child(Story::label("With sections"))
.child(
List::new()
.header(ListHeader::new("Produce"))
.child(ListSubHeader::new("Fruits"))
.child(ListItem::new("apple").child("Apple"))
.child(ListItem::new("banana").child("Banana"))
.child(ListItem::new("cherry").child("Cherry"))
.child(ListSeparator)
.child(ListSubHeader::new("Root Vegetables"))
.child(ListItem::new("carrot").child("Carrot"))
.child(ListItem::new("potato").child("Potato"))
.child(ListSubHeader::new("Leafy Vegetables"))
.child(ListItem::new("kale").child("Kale")),
)
}
}

View file

@ -0,0 +1,31 @@
use gpui::Render;
use story::Story;
use crate::{prelude::*, IconButton};
use crate::{Icon, ListHeader};
pub struct ListHeaderStory;
impl Render for ListHeaderStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<ListHeader>())
.child(Story::label("Default"))
.child(ListHeader::new("Section 1"))
.child(Story::label("With left icon"))
.child(ListHeader::new("Section 2").start_slot(IconElement::new(Icon::Bell)))
.child(Story::label("With left icon and meta"))
.child(
ListHeader::new("Section 3")
.start_slot(IconElement::new(Icon::BellOff))
.end_slot(IconButton::new("action_1", Icon::Bolt)),
)
.child(Story::label("With multiple meta"))
.child(
ListHeader::new("Section 4")
.end_slot(IconButton::new("action_1", Icon::Bolt))
.end_slot(IconButton::new("action_2", Icon::ExclamationTriangle))
.end_slot(IconButton::new("action_3", Icon::Plus)),
)
}
}

View file

@ -0,0 +1,102 @@
use gpui::Render;
use story::Story;
use crate::{prelude::*, Avatar};
use crate::{Icon, ListItem};
pub struct ListItemStory;
impl Render for ListItemStory {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.bg(cx.theme().colors().background)
.child(Story::title_for::<ListItem>())
.child(Story::label("Default"))
.child(ListItem::new("hello_world").child("Hello, world!"))
.child(Story::label("Inset"))
.child(
ListItem::new("inset_list_item")
.inset(true)
.start_slot(
IconElement::new(Icon::Bell)
.size(IconSize::Small)
.color(Color::Muted),
)
.child("Hello, world!")
.end_slot(
IconElement::new(Icon::Bell)
.size(IconSize::Small)
.color(Color::Muted),
),
)
.child(Story::label("With start slot icon"))
.child(
ListItem::new("with start slot_icon")
.child("Hello, world!")
.start_slot(
IconElement::new(Icon::Bell)
.size(IconSize::Small)
.color(Color::Muted),
),
)
.child(Story::label("With start slot avatar"))
.child(
ListItem::new("with_start slot avatar")
.child("Hello, world!")
.start_slot(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1714999?v=4",
))),
)
.child(Story::label("With end slot"))
.child(
ListItem::new("with_left_avatar")
.child("Hello, world!")
.end_slot(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1714999?v=4",
))),
)
.child(Story::label("With end hover slot"))
.child(
ListItem::new("with_end_hover_slot")
.child("Hello, world!")
.end_slot(
h_stack()
.gap_2()
.child(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1789?v=4",
)))
.child(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1789?v=4",
)))
.child(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1789?v=4",
)))
.child(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1789?v=4",
)))
.child(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1789?v=4",
))),
)
.end_hover_slot(Avatar::new(SharedString::from(
"https://avatars.githubusercontent.com/u/1714999?v=4",
))),
)
.child(Story::label("With `on_click`"))
.child(
ListItem::new("with_on_click")
.child("Click me")
.on_click(|_event, _cx| {
println!("Clicked!");
}),
)
.child(Story::label("With `on_secondary_mouse_down`"))
.child(
ListItem::new("with_on_secondary_mouse_down")
.child("Right click me")
.on_secondary_mouse_down(|_event, _cx| {
println!("Right mouse down!");
}),
)
}
}

View file

@ -0,0 +1,112 @@
use std::cmp::Ordering;
use gpui::Render;
use story::Story;
use crate::{prelude::*, TabPosition};
use crate::{Indicator, Tab};
pub struct TabStory;
impl Render for TabStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
Story::container()
.child(Story::title_for::<Tab>())
.child(Story::label("Default"))
.child(h_stack().child(Tab::new("tab_1").child("Tab 1")))
.child(Story::label("With indicator"))
.child(
h_stack().child(
Tab::new("tab_1")
.start_slot(Indicator::dot().color(Color::Warning))
.child("Tab 1"),
),
)
.child(Story::label("With close button"))
.child(
h_stack().child(
Tab::new("tab_1")
.end_slot(
IconButton::new("close_button", Icon::Close)
.icon_color(Color::Muted)
.size(ButtonSize::None)
.icon_size(IconSize::XSmall),
)
.child("Tab 1"),
),
)
.child(Story::label("List of tabs"))
.child(
h_stack()
.child(Tab::new("tab_1").child("Tab 1"))
.child(Tab::new("tab_2").child("Tab 2")),
)
.child(Story::label("List of tabs with first tab selected"))
.child(
h_stack()
.child(
Tab::new("tab_1")
.selected(true)
.position(TabPosition::First)
.child("Tab 1"),
)
.child(
Tab::new("tab_2")
.position(TabPosition::Middle(Ordering::Greater))
.child("Tab 2"),
)
.child(
Tab::new("tab_3")
.position(TabPosition::Middle(Ordering::Greater))
.child("Tab 3"),
)
.child(Tab::new("tab_4").position(TabPosition::Last).child("Tab 4")),
)
.child(Story::label("List of tabs with last tab selected"))
.child(
h_stack()
.child(
Tab::new("tab_1")
.position(TabPosition::First)
.child("Tab 1"),
)
.child(
Tab::new("tab_2")
.position(TabPosition::Middle(Ordering::Less))
.child("Tab 2"),
)
.child(
Tab::new("tab_3")
.position(TabPosition::Middle(Ordering::Less))
.child("Tab 3"),
)
.child(
Tab::new("tab_4")
.position(TabPosition::Last)
.selected(true)
.child("Tab 4"),
),
)
.child(Story::label("List of tabs with second tab selected"))
.child(
h_stack()
.child(
Tab::new("tab_1")
.position(TabPosition::First)
.child("Tab 1"),
)
.child(
Tab::new("tab_2")
.position(TabPosition::Middle(Ordering::Equal))
.selected(true)
.child("Tab 2"),
)
.child(
Tab::new("tab_3")
.position(TabPosition::Middle(Ordering::Greater))
.child("Tab 3"),
)
.child(Tab::new("tab_4").position(TabPosition::Last).child("Tab 4")),
)
}
}

View file

@ -0,0 +1,56 @@
use gpui::Render;
use story::Story;
use crate::{prelude::*, Tab, TabBar, TabPosition};
pub struct TabBarStory;
impl Render for TabBarStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
let tab_count = 20;
let selected_tab_index = 3;
let tabs = (0..tab_count)
.map(|index| {
Tab::new(index)
.selected(index == selected_tab_index)
.position(if index == 0 {
TabPosition::First
} else if index == tab_count - 1 {
TabPosition::Last
} else {
TabPosition::Middle(index.cmp(&selected_tab_index))
})
.child(Label::new(format!("Tab {}", index + 1)).color(
if index == selected_tab_index {
Color::Default
} else {
Color::Muted
},
))
})
.collect::<Vec<_>>();
Story::container()
.child(Story::title_for::<TabBar>())
.child(Story::label("Default"))
.child(
h_stack().child(
TabBar::new("tab_bar_1")
.start_child(
IconButton::new("navigate_backward", Icon::ArrowLeft)
.icon_size(IconSize::Small),
)
.start_child(
IconButton::new("navigate_forward", Icon::ArrowRight)
.icon_size(IconSize::Small),
)
.end_child(IconButton::new("new", Icon::Plus).icon_size(IconSize::Small))
.end_child(
IconButton::new("split_pane", Icon::Split).icon_size(IconSize::Small),
)
.children(tabs),
),
)
}
}

View file

@ -0,0 +1,95 @@
use gpui::Render;
use story::{StoryContainer, StoryItem, StorySection};
use crate::{prelude::*, ToggleButton};
pub struct ToggleButtonStory;
impl Render for ToggleButtonStory {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
StoryContainer::new(
"Toggle Button",
"crates/ui2/src/components/stories/toggle_button.rs",
)
.child(
StorySection::new().child(
StoryItem::new(
"Default",
ToggleButton::new("default_toggle_button", "Hello"),
)
.description("Displays a toggle button.")
.usage(""),
),
)
.child(
StorySection::new().child(
StoryItem::new(
"Toggle button group",
h_stack()
.child(
ToggleButton::new(1, "Apple")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.first(),
)
.child(
ToggleButton::new(2, "Banana")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.middle(),
)
.child(
ToggleButton::new(3, "Cherry")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.middle(),
)
.child(
ToggleButton::new(4, "Dragonfruit")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.last(),
),
)
.description("Displays a group of toggle buttons.")
.usage(""),
),
)
.child(
StorySection::new().child(
StoryItem::new(
"Toggle button group with selection",
h_stack()
.child(
ToggleButton::new(1, "Apple")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.first(),
)
.child(
ToggleButton::new(2, "Banana")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.selected(true)
.middle(),
)
.child(
ToggleButton::new(3, "Cherry")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.middle(),
)
.child(
ToggleButton::new(4, "Dragonfruit")
.style(ButtonStyle::Filled)
.size(ButtonSize::Large)
.last(),
),
)
.description("Displays a group of toggle buttons.")
.usage(""),
),
)
.into_element()
}
}

View file

@ -0,0 +1,174 @@
use crate::prelude::*;
use gpui::{AnyElement, IntoElement, Stateful};
use smallvec::SmallVec;
use std::cmp::Ordering;
/// The position of a [`Tab`] within a list of tabs.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TabPosition {
/// The tab is first in the list.
First,
/// The tab is in the middle of the list (i.e., it is not the first or last tab).
///
/// The [`Ordering`] is where this tab is positioned with respect to the selected tab.
Middle(Ordering),
/// The tab is last in the list.
Last,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TabCloseSide {
Start,
End,
}
#[derive(IntoElement)]
pub struct Tab {
div: Stateful<Div>,
selected: bool,
position: TabPosition,
close_side: TabCloseSide,
start_slot: Option<AnyElement>,
end_slot: Option<AnyElement>,
children: SmallVec<[AnyElement; 2]>,
}
impl Tab {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
div: div().id(id),
selected: false,
position: TabPosition::First,
close_side: TabCloseSide::End,
start_slot: None,
end_slot: None,
children: SmallVec::new(),
}
}
pub const HEIGHT_IN_REMS: f32 = 30. / 16.;
pub fn position(mut self, position: TabPosition) -> Self {
self.position = position;
self
}
pub fn close_side(mut self, close_side: TabCloseSide) -> Self {
self.close_side = close_side;
self
}
pub fn start_slot<E: IntoElement>(mut self, element: impl Into<Option<E>>) -> Self {
self.start_slot = element.into().map(IntoElement::into_any_element);
self
}
pub fn end_slot<E: IntoElement>(mut self, element: impl Into<Option<E>>) -> Self {
self.end_slot = element.into().map(IntoElement::into_any_element);
self
}
}
impl InteractiveElement for Tab {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.div.interactivity()
}
}
impl StatefulInteractiveElement for Tab {}
impl Selectable for Tab {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl ParentElement for Tab {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}
impl RenderOnce for Tab {
#[allow(refining_impl_trait)]
fn render(self, cx: &mut WindowContext) -> Stateful<Div> {
let (text_color, tab_bg, _tab_hover_bg, _tab_active_bg) = match self.selected {
false => (
cx.theme().colors().text_muted,
cx.theme().colors().tab_inactive_background,
cx.theme().colors().ghost_element_hover,
cx.theme().colors().ghost_element_active,
),
true => (
cx.theme().colors().text,
cx.theme().colors().tab_active_background,
cx.theme().colors().element_hover,
cx.theme().colors().element_active,
),
};
self.div
.h(rems(Self::HEIGHT_IN_REMS))
.bg(tab_bg)
.border_color(cx.theme().colors().border)
.map(|this| match self.position {
TabPosition::First => {
if self.selected {
this.pl_px().border_r().pb_px()
} else {
this.pl_px().pr_px().border_b()
}
}
TabPosition::Last => {
if self.selected {
this.border_l().border_r().pb_px()
} else {
this.pr_px().pl_px().border_b()
}
}
TabPosition::Middle(Ordering::Equal) => this.border_l().border_r().pb_px(),
TabPosition::Middle(Ordering::Less) => this.border_l().pr_px().border_b(),
TabPosition::Middle(Ordering::Greater) => this.border_r().pl_px().border_b(),
})
.child(
h_stack()
.group("")
.relative()
.h_full()
.px_5()
.gap_1()
.text_color(text_color)
// .hover(|style| style.bg(tab_hover_bg))
// .active(|style| style.bg(tab_active_bg))
.child(
h_stack()
.w_3()
.h_3()
.justify_center()
.absolute()
.map(|this| match self.close_side {
TabCloseSide::Start => this.right_1(),
TabCloseSide::End => this.left_1(),
})
.children(self.start_slot),
)
.child(
h_stack()
.w_3()
.h_3()
.justify_center()
.absolute()
.map(|this| match self.close_side {
TabCloseSide::Start => this.left_1(),
TabCloseSide::End => this.right_1(),
})
.visible_on_hover("")
.children(self.end_slot),
)
.children(self.children),
)
}
}

View file

@ -0,0 +1,156 @@
use gpui::{AnyElement, ScrollHandle};
use smallvec::SmallVec;
use crate::prelude::*;
#[derive(IntoElement)]
pub struct TabBar {
id: ElementId,
start_children: SmallVec<[AnyElement; 2]>,
children: SmallVec<[AnyElement; 2]>,
end_children: SmallVec<[AnyElement; 2]>,
scroll_handle: Option<ScrollHandle>,
}
impl TabBar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
start_children: SmallVec::new(),
children: SmallVec::new(),
end_children: SmallVec::new(),
scroll_handle: None,
}
}
pub fn track_scroll(mut self, scroll_handle: ScrollHandle) -> Self {
self.scroll_handle = Some(scroll_handle);
self
}
pub fn start_children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.start_children
}
pub fn start_child(mut self, start_child: impl IntoElement) -> Self
where
Self: Sized,
{
self.start_children_mut()
.push(start_child.into_element().into_any());
self
}
pub fn start_children(
mut self,
start_children: impl IntoIterator<Item = impl IntoElement>,
) -> Self
where
Self: Sized,
{
self.start_children_mut().extend(
start_children
.into_iter()
.map(|child| child.into_any_element()),
);
self
}
pub fn end_children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.end_children
}
pub fn end_child(mut self, end_child: impl IntoElement) -> Self
where
Self: Sized,
{
self.end_children_mut()
.push(end_child.into_element().into_any());
self
}
pub fn end_children(mut self, end_children: impl IntoIterator<Item = impl IntoElement>) -> Self
where
Self: Sized,
{
self.end_children_mut().extend(
end_children
.into_iter()
.map(|child| child.into_any_element()),
);
self
}
}
impl ParentElement for TabBar {
fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> {
&mut self.children
}
}
impl RenderOnce for TabBar {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
const HEIGHT_IN_REMS: f32 = 30. / 16.;
div()
.id(self.id)
.group("tab_bar")
.flex()
.flex_none()
.w_full()
.h(rems(HEIGHT_IN_REMS))
.bg(cx.theme().colors().tab_bar_background)
.when(!self.start_children.is_empty(), |this| {
this.child(
h_stack()
.flex_none()
.gap_1()
.px_1()
.border_b()
.border_r()
.border_color(cx.theme().colors().border)
.children(self.start_children),
)
})
.child(
div()
.relative()
.flex_1()
.h_full()
.overflow_hidden_x()
.child(
div()
.absolute()
.top_0()
.left_0()
.z_index(1)
.size_full()
.border_b()
.border_color(cx.theme().colors().border),
)
.child(
h_stack()
.id("tabs")
.z_index(2)
.flex_grow()
.overflow_x_scroll()
.when_some(self.scroll_handle, |cx, scroll_handle| {
cx.track_scroll(&scroll_handle)
})
.children(self.children),
),
)
.when(!self.end_children.is_empty(), |this| {
this.child(
h_stack()
.flex_none()
.gap_1()
.px_1()
.border_b()
.border_l()
.border_color(cx.theme().colors().border)
.children(self.end_children),
)
})
}
}

View file

@ -0,0 +1,97 @@
use gpui::{overlay, Action, AnyView, IntoElement, Render, VisualContext};
use settings::Settings;
use theme::ThemeSettings;
use crate::prelude::*;
use crate::{h_stack, v_stack, Color, KeyBinding, Label, LabelSize, StyledExt};
pub struct Tooltip {
title: SharedString,
meta: Option<SharedString>,
key_binding: Option<KeyBinding>,
}
impl Tooltip {
pub fn text(title: impl Into<SharedString>, cx: &mut WindowContext) -> AnyView {
cx.new_view(|_cx| Self {
title: title.into(),
meta: None,
key_binding: None,
})
.into()
}
pub fn for_action(
title: impl Into<SharedString>,
action: &dyn Action,
cx: &mut WindowContext,
) -> AnyView {
cx.new_view(|cx| Self {
title: title.into(),
meta: None,
key_binding: KeyBinding::for_action(action, cx),
})
.into()
}
pub fn with_meta(
title: impl Into<SharedString>,
action: Option<&dyn Action>,
meta: impl Into<SharedString>,
cx: &mut WindowContext,
) -> AnyView {
cx.new_view(|cx| Self {
title: title.into(),
meta: Some(meta.into()),
key_binding: action.and_then(|action| KeyBinding::for_action(action, cx)),
})
.into()
}
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
meta: None,
key_binding: None,
}
}
pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
self.meta = Some(meta.into());
self
}
pub fn key_binding(mut self, key_binding: impl Into<Option<KeyBinding>>) -> Self {
self.key_binding = key_binding.into();
self
}
}
impl Render for Tooltip {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
overlay().child(
// padding to avoid mouse cursor
div().pl_2().pt_2p5().child(
v_stack()
.elevation_2(cx)
.font(ui_font)
.text_ui()
.text_color(cx.theme().colors().text)
.py_1()
.px_2()
.child(
h_stack()
.gap_4()
.child(self.title.clone())
.when_some(self.key_binding.clone(), |this, key_binding| {
this.justify_between().child(key_binding)
}),
)
.when_some(self.meta.clone(), |this, meta| {
this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
}),
),
)
}
}

View file

@ -0,0 +1,5 @@
/// A trait for elements that can be disabled.
pub trait Disableable {
/// Sets whether the element is disabled.
fn disabled(self, disabled: bool) -> Self;
}

10
crates/ui/src/fixed.rs Normal file
View file

@ -0,0 +1,10 @@
use gpui::DefiniteLength;
/// A trait for elements that have a fixed with.
pub trait FixedWidth {
/// Sets the width of the element.
fn width(self, width: DefiniteLength) -> Self;
/// Sets the element's width to the full width of its container.
fn full_width(self) -> Self;
}

19
crates/ui/src/prelude.rs Normal file
View file

@ -0,0 +1,19 @@
pub use gpui::prelude::*;
pub use gpui::{
div, px, relative, rems, AbsoluteLength, DefiniteLength, Div, Element, ElementId,
InteractiveElement, ParentElement, Pixels, Rems, RenderOnce, SharedString, Styled, ViewContext,
WindowContext,
};
pub use crate::clickable::*;
pub use crate::disableable::*;
pub use crate::fixed::*;
pub use crate::selectable::*;
pub use crate::styles::{vh, vw};
pub use crate::visible_on_hover::*;
pub use crate::{h_stack, v_stack};
pub use crate::{Button, ButtonSize, ButtonStyle, IconButton};
pub use crate::{ButtonCommon, Color, StyledExt};
pub use crate::{Icon, IconElement, IconPosition, IconSize};
pub use crate::{Label, LabelCommon, LabelSize, LineHeightStyle};
pub use theme::ActiveTheme;

View file

@ -0,0 +1,22 @@
/// A trait for elements that can be selected.
pub trait Selectable {
/// Sets whether the element is selected.
fn selected(self, selected: bool) -> Self;
}
#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)]
pub enum Selection {
#[default]
Unselected,
Indeterminate,
Selected,
}
impl Selection {
pub fn inverse(&self) -> Self {
match self {
Self::Unselected | Self::Indeterminate => Self::Selected,
Self::Selected => Self::Unselected,
}
}
}

147
crates/ui/src/styled_ext.rs Normal file
View file

@ -0,0 +1,147 @@
use gpui::{hsla, px, Styled, WindowContext};
use settings::Settings;
use theme::ThemeSettings;
use crate::prelude::*;
use crate::{ElevationIndex, UiTextSize};
fn elevated<E: Styled>(this: E, cx: &mut WindowContext, index: ElevationIndex) -> E {
this.bg(cx.theme().colors().elevated_surface_background)
.z_index(index.z_index())
.rounded(px(8.))
.border()
.border_color(cx.theme().colors().border_variant)
.shadow(index.shadow())
}
/// Extends [`Styled`](gpui::Styled) with Zed specific styling methods.
pub trait StyledExt: Styled + Sized {
/// Horizontally stacks elements.
///
/// Sets `flex()`, `flex_row()`, `items_center()`
fn h_flex(self) -> Self {
self.flex().flex_row().items_center()
}
/// Vertically stacks elements.
///
/// Sets `flex()`, `flex_col()`
fn v_flex(self) -> Self {
self.flex().flex_col()
}
fn text_ui_size(self, size: UiTextSize) -> Self {
self.text_size(size.rems())
}
/// The default size for UI text.
///
/// `0.825rem` or `14px` at the default scale of `1rem` = `16px`.
///
/// Note: The absolute size of this text will change based on a user's `ui_scale` setting.
///
/// Use [`text_ui_sm`] for regular-sized text.
fn text_ui(self) -> Self {
self.text_size(UiTextSize::default().rems())
}
/// The small size for UI text.
///
/// `0.75rem` or `12px` at the default scale of `1rem` = `16px`.
///
/// Note: The absolute size of this text will change based on a user's `ui_scale` setting.
///
/// Use [`text_ui`] for regular-sized text.
fn text_ui_sm(self) -> Self {
self.text_size(UiTextSize::Small.rems())
}
/// The extra small size for UI text.
///
/// `0.625rem` or `10px` at the default scale of `1rem` = `16px`.
///
/// Note: The absolute size of this text will change based on a user's `ui_scale` setting.
///
/// Use [`text_ui`] for regular-sized text.
fn text_ui_xs(self) -> Self {
self.text_size(UiTextSize::XSmall.rems())
}
/// The font size for buffer text.
///
/// Retrieves the default font size, or the user's custom font size if set.
///
/// This should only be used for text that is displayed in a buffer,
/// or other places that text needs to match the user's buffer font size.
fn text_buffer(self, cx: &mut WindowContext) -> Self {
let settings = ThemeSettings::get_global(cx);
self.text_size(settings.buffer_font_size(cx))
}
/// The [`Surface`](ui::ElevationIndex::Surface) elevation level, located above the app background, is the standard level for all elements
///
/// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
///
/// Example Elements: Title Bar, Panel, Tab Bar, Editor
fn elevation_1(self, cx: &mut WindowContext) -> Self {
elevated(self, cx, ElevationIndex::Surface)
}
/// Non-Modal Elevated Surfaces appear above the [`Surface`](ui::ElevationIndex::Surface) layer and is used for things that should appear above most UI elements like an editor or panel, but not elements like popovers, context menus, modals, etc.
///
/// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
///
/// Examples: Notifications, Palettes, Detached/Floating Windows, Detached/Floating Panels
fn elevation_2(self, cx: &mut WindowContext) -> Self {
elevated(self, cx, ElevationIndex::ElevatedSurface)
}
/// Modal Surfaces are used for elements that should appear above all other UI elements and are located above the wash layer. This is the maximum elevation at which UI elements can be rendered in their default state.
///
/// Elements rendered at this layer should have an enforced behavior: Any interaction outside of the modal will either dismiss the modal or prompt an action (Save your progress, etc) then dismiss the modal.
///
/// If the element does not have this behavior, it should be rendered at the [`Elevated Surface`](ui::ElevationIndex::ElevatedSurface) layer.
///
/// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`
///
/// Examples: Settings Modal, Channel Management, Wizards/Setup UI, Dialogs
fn elevation_3(self, cx: &mut WindowContext) -> Self {
elevated(self, cx, ElevationIndex::ModalSurface)
}
/// The theme's primary border color.
fn border_primary(self, cx: &mut WindowContext) -> Self {
self.border_color(cx.theme().colors().border)
}
/// The theme's secondary or muted border color.
fn border_muted(self, cx: &mut WindowContext) -> Self {
self.border_color(cx.theme().colors().border_variant)
}
fn debug_bg_red(self) -> Self {
self.bg(hsla(0. / 360., 1., 0.5, 1.))
}
fn debug_bg_green(self) -> Self {
self.bg(hsla(120. / 360., 1., 0.5, 1.))
}
fn debug_bg_blue(self) -> Self {
self.bg(hsla(240. / 360., 1., 0.5, 1.))
}
fn debug_bg_yellow(self) -> Self {
self.bg(hsla(60. / 360., 1., 0.5, 1.))
}
fn debug_bg_cyan(self) -> Self {
self.bg(hsla(160. / 360., 1., 0.5, 1.))
}
fn debug_bg_magenta(self) -> Self {
self.bg(hsla(300. / 360., 1., 0.5, 1.))
}
}
impl<E: Styled> StyledExt for E {}

9
crates/ui/src/styles.rs Normal file
View file

@ -0,0 +1,9 @@
mod color;
mod elevation;
mod typography;
mod units;
pub use color::*;
pub use elevation::*;
pub use typography::*;
pub use units::*;

View file

@ -0,0 +1,46 @@
use gpui::{Hsla, WindowContext};
use theme::ActiveTheme;
#[derive(Debug, Default, PartialEq, Copy, Clone)]
pub enum Color {
#[default]
Default,
Accent,
Created,
Deleted,
Disabled,
Error,
Hidden,
Info,
Modified,
Conflict,
Muted,
Placeholder,
Player(u32),
Selected,
Success,
Warning,
}
impl Color {
pub fn color(&self, cx: &WindowContext) -> Hsla {
match self {
Color::Default => cx.theme().colors().text,
Color::Muted => cx.theme().colors().text_muted,
Color::Created => cx.theme().status().created,
Color::Modified => cx.theme().status().modified,
Color::Conflict => cx.theme().status().conflict,
Color::Deleted => cx.theme().status().deleted,
Color::Disabled => cx.theme().colors().text_disabled,
Color::Hidden => cx.theme().status().hidden,
Color::Info => cx.theme().status().info,
Color::Placeholder => cx.theme().colors().text_placeholder,
Color::Accent => cx.theme().colors().text_accent,
Color::Player(i) => cx.theme().styles.player.0[i.clone() as usize].cursor,
Color::Error => cx.theme().status().error,
Color::Selected => cx.theme().colors().text_accent,
Color::Success => cx.theme().status().success,
Color::Warning => cx.theme().status().warning,
}
}
}

View file

@ -0,0 +1,44 @@
# Elevation
Elevation can be thought of as the physical closeness of an element to the user. Elements with lower elevations are physically further away from the user on the z-axis and appear to be underneath elements with higher elevations.
Material Design 3 has a some great visualizations of elevation that may be helpful to understanding the mental modal of elevation. [Material Design Elevation](https://m3.material.io/styles/elevation/overview)
## Elevation Levels
1. App Background (e.x.: Workspace, system window)
1. UI Surface (e.x.: Title Bar, Panel, Tab Bar)
1. Elevated Surface (e.x.: Palette, Notification, Floating Window)
1. Wash
1. Modal Surfaces (e.x.: Modal)
1. Dragged Element (This is a special case, see Layer section below)
### App Background
The app background constitutes the lowest elevation layer, appearing behind all other surfaces and components. It is predominantly used for the background color of the app.
### Surface
The Surface elevation level, located above the app background, is the standard level for all elements
Example Elements: Title Bar, Panel, Tab Bar, Editor
### Elevated Surface
Non-Modal Elevated Surfaces appear above the UI surface layer and is used for things that should appear above most UI elements like an editor or panel, but not elements like popovers, context menus, modals, etc.
Examples: Notifications, Palettes, Detached/Floating Windows, Detached/Floating Panels
You could imagine a variant of the assistant that floats in a window above the editor on this elevation, or a floating terminal window that becomes less opaque when not focused.
### Wash
Wash denotes a distinct elevation reserved to isolate app UI layers from high elevation components such as modals, notifications, and overlaid panels. The wash may not consistently be visible when these components are active. This layer is often referred to as a scrim or overlay and the background color of the wash is typically deployed in its design.
### Modal Surfaces
Modal Surfaces are used for elements that should appear above all other UI elements and are located above the wash layer. This is the maximum elevation at which UI elements can be rendered
Elements rendered at this layer have an enforced behavior: Any interaction outside of the modal will either dismiss the modal or prompt an action (Save your progress, etc) then dismiss the modal.
If the element does not have this behavior, it should be rendered at the Elevated Surface layer.

View file

@ -0,0 +1,109 @@
use gpui::{hsla, point, px, BoxShadow};
use smallvec::{smallvec, SmallVec};
#[doc = include_str!("docs/elevation.md")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Elevation {
ElevationIndex(ElevationIndex),
LayerIndex(LayerIndex),
ElementIndex(ElementIndex),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElevationIndex {
Background,
Surface,
ElevatedSurface,
Wash,
ModalSurface,
DraggedElement,
}
impl ElevationIndex {
pub fn z_index(self) -> u8 {
match self {
ElevationIndex::Background => 0,
ElevationIndex::Surface => 42,
ElevationIndex::ElevatedSurface => 84,
ElevationIndex::Wash => 126,
ElevationIndex::ModalSurface => 168,
ElevationIndex::DraggedElement => 210,
}
}
pub fn shadow(self) -> SmallVec<[BoxShadow; 2]> {
match self {
ElevationIndex::Surface => smallvec![],
ElevationIndex::ElevatedSurface => smallvec![BoxShadow {
color: hsla(0., 0., 0., 0.12),
offset: point(px(0.), px(2.)),
blur_radius: px(3.),
spread_radius: px(0.),
}],
ElevationIndex::ModalSurface => smallvec![
BoxShadow {
color: hsla(0., 0., 0., 0.12),
offset: point(px(0.), px(2.)),
blur_radius: px(3.),
spread_radius: px(0.),
},
BoxShadow {
color: hsla(0., 0., 0., 0.08),
offset: point(px(0.), px(3.)),
blur_radius: px(6.),
spread_radius: px(0.),
},
BoxShadow {
color: hsla(0., 0., 0., 0.04),
offset: point(px(0.), px(6.)),
blur_radius: px(12.),
spread_radius: px(0.),
},
],
_ => smallvec![],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayerIndex {
BehindElement,
Element,
ElevatedElement,
}
impl LayerIndex {
pub fn usize(&self) -> usize {
match *self {
LayerIndex::BehindElement => 0,
LayerIndex::Element => 100,
LayerIndex::ElevatedElement => 200,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElementIndex {
Effect,
Background,
Tint,
Highlight,
Content,
Overlay,
}
impl ElementIndex {
pub fn usize(&self) -> usize {
match *self {
ElementIndex::Effect => 0,
ElementIndex::Background => 100,
ElementIndex::Tint => 200,
ElementIndex::Highlight => 300,
ElementIndex::Content => 400,
ElementIndex::Overlay => 500,
}
}
}

View file

@ -0,0 +1,35 @@
use gpui::{rems, Rems};
#[derive(Debug, Default, Clone)]
pub enum UiTextSize {
/// The default size for UI text.
///
/// `0.825rem` or `14px` at the default scale of `1rem` = `16px`.
///
/// Note: The absolute size of this text will change based on a user's `ui_scale` setting.
#[default]
Default,
/// The small size for UI text.
///
/// `0.75rem` or `12px` at the default scale of `1rem` = `16px`.
///
/// Note: The absolute size of this text will change based on a user's `ui_scale` setting.
Small,
/// The extra small size for UI text.
///
/// `0.625rem` or `10px` at the default scale of `1rem` = `16px`.
///
/// Note: The absolute size of this text will change based on a user's `ui_scale` setting.
XSmall,
}
impl UiTextSize {
pub fn rems(self) -> Rems {
match self {
Self::Default => rems(14. / 16.),
Self::Small => rems(12. / 16.),
Self::XSmall => rems(10. / 16.),
}
}
}

View file

@ -0,0 +1,15 @@
use gpui::{Length, WindowContext};
/// Returns a [`Length`] corresponding to the specified percentage of the viewport's width.
///
/// `percent` should be a value between `0.0` and `1.0`.
pub fn vw(percent: f32, cx: &mut WindowContext) -> Length {
Length::from(cx.viewport_size().width * percent)
}
/// Returns a [`Length`] corresponding to the specified percentage of the viewport's height.
///
/// `percent` should be a value between `0.0` and `1.0`.
pub fn vh(percent: f32, cx: &mut WindowContext) -> Length {
Length::from(cx.viewport_size().height * percent)
}

33
crates/ui/src/ui.rs Normal file
View file

@ -0,0 +1,33 @@
//! # UI Zed UI Primitives & Components
//!
//! This crate provides a set of UI primitives and components that are used to build all of the elements in Zed's UI.
//!
//! ## Work in Progress
//!
//! This crate is still a work in progress. The initial primitives and components are built for getting all the UI on the screen,
//! much of the state and functionality is mocked or hard codeded, and performance has not been a focus.
//!
#![doc = include_str!("../docs/hello-world.md")]
#![doc = include_str!("../docs/building-ui.md")]
#![doc = include_str!("../docs/todo.md")]
mod clickable;
mod components;
mod disableable;
mod fixed;
pub mod prelude;
mod selectable;
mod styled_ext;
mod styles;
pub mod utils;
mod visible_on_hover;
pub use clickable::*;
pub use components::*;
pub use disableable::*;
pub use fixed::*;
pub use prelude::*;
pub use styled_ext::*;
pub use styles::*;

3
crates/ui/src/utils.rs Normal file
View file

@ -0,0 +1,3 @@
mod format_distance;
pub use format_distance::*;

View file

@ -0,0 +1,434 @@
use chrono::{DateTime, Local, NaiveDateTime};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DateTimeType {
Naive(NaiveDateTime),
Local(DateTime<Local>),
}
impl DateTimeType {
/// Converts the DateTimeType to a NaiveDateTime.
///
/// If the DateTimeType is already a NaiveDateTime, it will be returned as is.
/// If the DateTimeType is a DateTime<Local>, it will be converted to a NaiveDateTime.
pub fn to_naive(&self) -> NaiveDateTime {
match self {
DateTimeType::Naive(naive) => *naive,
DateTimeType::Local(local) => local.naive_local(),
}
}
}
pub struct FormatDistance {
date: DateTimeType,
base_date: DateTimeType,
include_seconds: bool,
add_suffix: bool,
hide_prefix: bool,
}
impl FormatDistance {
pub fn new(date: DateTimeType, base_date: DateTimeType) -> Self {
Self {
date,
base_date,
include_seconds: false,
add_suffix: false,
hide_prefix: false,
}
}
pub fn from_now(date: DateTimeType) -> Self {
Self::new(date, DateTimeType::Local(Local::now()))
}
pub fn to_string(self) -> String {
format_distance(
self.date,
self.base_date.to_naive(),
self.include_seconds,
self.add_suffix,
self.hide_prefix,
)
}
pub fn include_seconds(mut self, include_seconds: bool) -> Self {
self.include_seconds = include_seconds;
self
}
pub fn add_suffix(mut self, add_suffix: bool) -> Self {
self.add_suffix = add_suffix;
self
}
pub fn hide_prefix(mut self, hide_prefix: bool) -> Self {
self.hide_prefix = hide_prefix;
self
}
}
/// Calculates the distance in seconds between two NaiveDateTime objects.
/// It returns a signed integer denoting the difference. If `date` is earlier than `base_date`, the returned value will be negative.
///
/// ## Arguments
///
/// * `date` - A NaiveDateTime object representing the date of interest
/// * `base_date` - A NaiveDateTime object representing the base date against which the comparison is made
fn distance_in_seconds(date: NaiveDateTime, base_date: NaiveDateTime) -> i64 {
let duration = date.signed_duration_since(base_date);
-duration.num_seconds()
}
/// Generates a string describing the time distance between two dates in a human-readable way.
fn distance_string(
distance: i64,
include_seconds: bool,
add_suffix: bool,
hide_prefix: bool,
) -> String {
let suffix = if distance < 0 { " from now" } else { " ago" };
let distance = distance.abs();
let minutes = distance / 60;
let hours = distance / 3_600;
let days = distance / 86_400;
let months = distance / 2_592_000;
let string = if distance < 5 && include_seconds {
if hide_prefix {
"5 seconds"
} else {
"less than 5 seconds"
}
.to_string()
} else if distance < 10 && include_seconds {
if hide_prefix {
"10 seconds"
} else {
"less than 10 seconds"
}
.to_string()
} else if distance < 20 && include_seconds {
if hide_prefix {
"20 seconds"
} else {
"less than 20 seconds"
}
.to_string()
} else if distance < 40 && include_seconds {
if hide_prefix {
"half a minute"
} else {
"half a minute"
}
.to_string()
} else if distance < 60 && include_seconds {
if hide_prefix {
"a minute"
} else {
"less than a minute"
}
.to_string()
} else if distance < 90 && include_seconds {
"1 minute".to_string()
} else if distance < 30 {
if hide_prefix {
"a minute"
} else {
"less than a minute"
}
.to_string()
} else if distance < 90 {
"1 minute".to_string()
} else if distance < 2_700 {
format!("{} minutes", minutes)
} else if distance < 5_400 {
if hide_prefix {
"1 hour"
} else {
"about 1 hour"
}
.to_string()
} else if distance < 86_400 {
if hide_prefix {
format!("{} hours", hours)
} else {
format!("about {} hours", hours)
}
.to_string()
} else if distance < 172_800 {
"1 day".to_string()
} else if distance < 2_592_000 {
format!("{} days", days)
} else if distance < 5_184_000 {
if hide_prefix {
"1 month"
} else {
"about 1 month"
}
.to_string()
} else if distance < 7_776_000 {
if hide_prefix {
"2 months"
} else {
"about 2 months"
}
.to_string()
} else if distance < 31_540_000 {
format!("{} months", months)
} else if distance < 39_425_000 {
if hide_prefix {
"1 year"
} else {
"about 1 year"
}
.to_string()
} else if distance < 55_195_000 {
if hide_prefix { "1 year" } else { "over 1 year" }.to_string()
} else if distance < 63_080_000 {
if hide_prefix {
"2 years"
} else {
"almost 2 years"
}
.to_string()
} else {
let years = distance / 31_536_000;
let remaining_months = (distance % 31_536_000) / 2_592_000;
if remaining_months < 3 {
if hide_prefix {
format!("{} years", years)
} else {
format!("about {} years", years)
}
.to_string()
} else if remaining_months < 9 {
if hide_prefix {
format!("{} years", years)
} else {
format!("over {} years", years)
}
.to_string()
} else {
if hide_prefix {
format!("{} years", years + 1)
} else {
format!("almost {} years", years + 1)
}
.to_string()
}
};
if add_suffix {
format!("{}{}", string, suffix)
} else {
string
}
}
/// Get the time difference between two dates into a relative human readable string.
///
/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
///
/// Use [naive_format_distance_from_now] to compare a NaiveDateTime against now.
///
/// # Arguments
///
/// * `date` - The NaiveDateTime to compare.
/// * `base_date` - The NaiveDateTime to compare against.
/// * `include_seconds` - A boolean. If true, distances less than a minute are more detailed
/// * `add_suffix` - A boolean. If true, result indicates if the time is in the past or future
///
/// # Example
///
/// ```rust
/// use chrono::DateTime;
/// use ui::utils::format_distance;
///
/// fn time_between_moon_landings() -> String {
/// let date = DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z").unwrap().naive_local();
/// let base_date = DateTime::parse_from_rfc3339("1972-12-14T00:00:00Z").unwrap().naive_local();
/// format!("There was {} between the first and last crewed moon landings.", naive_format_distance(date, base_date, false, false))
/// }
/// ```
///
/// Output: `"There was about 3 years between the first and last crewed moon landings."`
pub fn format_distance(
date: DateTimeType,
base_date: NaiveDateTime,
include_seconds: bool,
add_suffix: bool,
hide_prefix: bool,
) -> String {
let distance = distance_in_seconds(date.to_naive(), base_date);
distance_string(distance, include_seconds, add_suffix, hide_prefix)
}
/// Get the time difference between a date and now as relative human readable string.
///
/// For example, "less than a minute ago", "about 2 hours ago", "3 months from now", etc.
///
/// # Arguments
///
/// * `datetime` - The NaiveDateTime to compare with the current time.
/// * `include_seconds` - A boolean. If true, distances less than a minute are more detailed
/// * `add_suffix` - A boolean. If true, result indicates if the time is in the past or future
///
/// # Example
///
/// ```rust
/// use chrono::DateTime;
/// use ui::utils::naive_format_distance_from_now;
///
/// fn time_since_first_moon_landing() -> String {
/// let date = DateTime::parse_from_rfc3339("1969-07-20T00:00:00Z").unwrap().naive_local();
/// format!("It's been {} since Apollo 11 first landed on the moon.", naive_format_distance_from_now(date, false, false))
/// }
/// ```
///
/// Output: `It's been over 54 years since Apollo 11 first landed on the moon.`
pub fn format_distance_from_now(
datetime: DateTimeType,
include_seconds: bool,
add_suffix: bool,
hide_prefix: bool,
) -> String {
let now = chrono::offset::Local::now().naive_local();
format_distance(datetime, now, include_seconds, add_suffix, hide_prefix)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDateTime;
#[test]
fn test_format_distance() {
let date = DateTimeType::Naive(
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
);
let base_date = DateTimeType::Naive(
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
);
assert_eq!(
"about 2 hours",
format_distance(date, base_date.to_naive(), false, false, false)
);
}
#[test]
fn test_format_distance_with_suffix() {
let date = DateTimeType::Naive(
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
);
let base_date = DateTimeType::Naive(
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
);
assert_eq!(
"about 2 hours from now",
format_distance(date, base_date.to_naive(), false, true, false)
);
}
#[test]
fn test_format_distance_from_now() {
let date = DateTimeType::Naive(
NaiveDateTime::parse_from_str("1969-07-20T00:00:00Z", "%Y-%m-%dT%H:%M:%SZ")
.expect("Invalid NaiveDateTime for date"),
);
assert_eq!(
"over 54 years ago",
format_distance_from_now(date, false, true, false)
);
}
#[test]
fn test_format_distance_string() {
assert_eq!(
distance_string(3, false, false, false),
"less than a minute"
);
assert_eq!(
distance_string(7, false, false, false),
"less than a minute"
);
assert_eq!(
distance_string(13, false, false, false),
"less than a minute"
);
assert_eq!(
distance_string(21, false, false, false),
"less than a minute"
);
assert_eq!(distance_string(45, false, false, false), "1 minute");
assert_eq!(distance_string(61, false, false, false), "1 minute");
assert_eq!(distance_string(1920, false, false, false), "32 minutes");
assert_eq!(distance_string(3902, false, false, false), "about 1 hour");
assert_eq!(distance_string(18002, false, false, false), "about 5 hours");
assert_eq!(distance_string(86470, false, false, false), "1 day");
assert_eq!(distance_string(345880, false, false, false), "4 days");
assert_eq!(
distance_string(2764800, false, false, false),
"about 1 month"
);
assert_eq!(
distance_string(5184000, false, false, false),
"about 2 months"
);
assert_eq!(distance_string(10368000, false, false, false), "4 months");
assert_eq!(
distance_string(34694000, false, false, false),
"about 1 year"
);
assert_eq!(
distance_string(47310000, false, false, false),
"over 1 year"
);
assert_eq!(
distance_string(61503000, false, false, false),
"almost 2 years"
);
assert_eq!(
distance_string(160854000, false, false, false),
"about 5 years"
);
assert_eq!(
distance_string(236550000, false, false, false),
"over 7 years"
);
assert_eq!(
distance_string(249166000, false, false, false),
"almost 8 years"
);
}
#[test]
fn test_format_distance_string_include_seconds() {
assert_eq!(
distance_string(3, true, false, false),
"less than 5 seconds"
);
assert_eq!(
distance_string(7, true, false, false),
"less than 10 seconds"
);
assert_eq!(
distance_string(13, true, false, false),
"less than 20 seconds"
);
assert_eq!(distance_string(21, true, false, false), "half a minute");
assert_eq!(
distance_string(45, true, false, false),
"less than a minute"
);
assert_eq!(distance_string(61, true, false, false), "1 minute");
}
}

View file

@ -0,0 +1,15 @@
use gpui::{InteractiveElement, SharedString, Styled};
pub trait VisibleOnHover {
/// Sets the element to only be visible when the specified group is hovered.
///
/// Pass `""` as the `group_name` to use the global group.
fn visible_on_hover(self, group_name: impl Into<SharedString>) -> Self;
}
impl<E: InteractiveElement + Styled> VisibleOnHover for E {
fn visible_on_hover(self, group_name: impl Into<SharedString>) -> Self {
self.invisible()
.group_hover(group_name, |style| style.visible())
}
}