logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

39
crates/ui/Cargo.toml Normal file
View File

@@ -0,0 +1,39 @@
[package]
name = "ui"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
name = "ui"
path = "src/ui.rs"
[dependencies]
chrono.workspace = true
component.workspace = true
documented.workspace = true
gpui.workspace = true
gpui_macros.workspace = true
icons.workspace = true
itertools.workspace = true
menu.workspace = true
schemars.workspace = true
serde.workspace = true
smallvec.workspace = true
strum.workspace = true
theme.workspace = true
ui_macros.workspace = true
gpui_util.workspace = true
[target.'cfg(windows)'.dependencies]
windows.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
[features]
default = []

1
crates/ui/LICENSE-GPL Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,6 @@
pub use component::{
Component, ComponentId, ComponentScope, ComponentStatus, example_group,
example_group_with_title, single_example,
};
pub use documented::Documented;
pub use ui_macros::RegisterComponent;

View File

@@ -0,0 +1,83 @@
mod ai;
mod avatar;
mod banner;
mod button;
mod callout;
mod chip;
mod collab;
mod context_menu;
mod count_badge;
mod data_table;
mod diff_stat;
mod disclosure;
mod divider;
mod dropdown_menu;
mod facepile;
mod gradient_fade;
mod group;
mod icon;
mod image;
mod indent_guides;
mod indicator;
mod keybinding;
mod keybinding_hint;
mod label;
mod list;
mod modal;
mod navigable;
mod notification;
mod popover;
mod popover_menu;
mod progress;
mod redistributable_columns;
mod right_click_menu;
mod scrollbar;
mod stack;
mod sticky_items;
mod tab;
mod tab_bar;
mod toggle;
mod tooltip;
mod tree_view_item;
pub use ai::*;
pub use avatar::*;
pub use banner::*;
pub use button::*;
pub use callout::*;
pub use chip::*;
pub use collab::*;
pub use context_menu::*;
pub use count_badge::*;
pub use data_table::*;
pub use diff_stat::*;
pub use disclosure::*;
pub use divider::*;
pub use dropdown_menu::*;
pub use facepile::*;
pub use gradient_fade::*;
pub use group::*;
pub use icon::*;
pub use image::*;
pub use indent_guides::*;
pub use indicator::*;
pub use keybinding::*;
pub use keybinding_hint::*;
pub use label::*;
pub use list::*;
pub use modal::*;
pub use navigable::*;
pub use notification::*;
pub use popover::*;
pub use popover_menu::*;
pub use progress::*;
pub use redistributable_columns::*;
pub use right_click_menu::*;
pub use scrollbar::*;
pub use stack::*;
pub use sticky_items::*;
pub use tab::*;
pub use tab_bar::*;
pub use toggle::*;
pub use tooltip::*;
pub use tree_view_item::*;

View File

@@ -0,0 +1,11 @@
mod agent_setup_button;
mod ai_setting_item;
mod configured_api_card;
mod parallel_agents_illustration;
mod thread_item;
pub use agent_setup_button::*;
pub use ai_setting_item::*;
pub use configured_api_card::*;
pub use parallel_agents_illustration::*;
pub use thread_item::*;

View File

@@ -0,0 +1,110 @@
use crate::prelude::*;
use gpui::{ClickEvent, SharedString};
#[derive(IntoElement, RegisterComponent)]
pub struct AgentSetupButton {
id: ElementId,
icon: Option<Icon>,
name: Option<SharedString>,
state: Option<AnyElement>,
disabled: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl AgentSetupButton {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
icon: None,
name: None,
state: None,
disabled: false,
on_click: None,
}
}
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = Some(name.into());
self
}
pub fn state(mut self, element: impl IntoElement) -> Self {
self.state = Some(element.into_any_element());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl Component for AgentSetupButton {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
None
}
}
impl RenderOnce for AgentSetupButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_clickable = !self.disabled && self.on_click.is_some();
let has_top_section = self.icon.is_some() || self.name.is_some();
let top_section = has_top_section.then(|| {
h_flex()
.p_1p5()
.gap_1()
.justify_center()
.when_some(self.icon, |this, icon| this.child(icon))
.when_some(self.name, |this, name| {
this.child(Label::new(name).size(LabelSize::Small))
})
});
let bottom_section = self.state.map(|state_element| {
h_flex()
.p_0p5()
.h_full()
.justify_center()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().element_background.opacity(0.5))
.child(state_element)
});
v_flex()
.id(self.id)
.border_1()
.border_color(cx.theme().colors().border_variant)
.rounded_sm()
.when(is_clickable, |this| {
this.cursor_pointer().hover(|style| {
style
.bg(cx.theme().colors().element_hover)
.border_color(cx.theme().colors().border)
})
})
.when_some(top_section, |this, section| this.child(section))
.when_some(bottom_section, |this, section| this.child(section))
.when_some(self.on_click.filter(|_| is_clickable), |this, on_click| {
this.on_click(on_click)
})
}
}

View File

@@ -0,0 +1,406 @@
use crate::{IconDecoration, IconDecorationKind, Tooltip, prelude::*};
use gpui::{Animation, AnimationExt, SharedString, pulsating_between};
use std::time::Duration;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AiSettingItemStatus {
#[default]
Stopped,
Starting,
Running,
Error,
AuthRequired,
Authenticating,
}
impl AiSettingItemStatus {
fn tooltip_text(&self) -> &'static str {
match self {
Self::Stopped => "Server is stopped.",
Self::Starting => "Server is starting.",
Self::Running => "Server is active.",
Self::Error => "Server has an error.",
Self::AuthRequired => "Authentication required.",
Self::Authenticating => "Waiting for authorization…",
}
}
fn indicator_color(&self) -> Option<Color> {
match self {
Self::Stopped => None,
Self::Starting | Self::Authenticating => Some(Color::Muted),
Self::Running => Some(Color::Success),
Self::Error => Some(Color::Error),
Self::AuthRequired => Some(Color::Warning),
}
}
fn is_animated(&self) -> bool {
matches!(self, Self::Starting | Self::Authenticating)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AiSettingItemSource {
Extension,
Custom,
Registry,
}
impl AiSettingItemSource {
fn icon_name(&self) -> IconName {
match self {
Self::Extension => IconName::ZedSrcExtension,
Self::Custom => IconName::ZedSrcCustom,
Self::Registry => IconName::AcpRegistry,
}
}
fn tooltip_text(&self, label: &str) -> String {
match self {
Self::Extension => format!("{label} was installed from an extension."),
Self::Registry => format!("{label} was installed from the ACP registry."),
Self::Custom => format!("{label} was configured manually."),
}
}
}
/// A reusable setting item row for AI-related configuration lists.
#[derive(IntoElement, RegisterComponent)]
pub struct AiSettingItem {
id: ElementId,
status: AiSettingItemStatus,
source: AiSettingItemSource,
icon: Option<AnyElement>,
label: SharedString,
detail_label: Option<SharedString>,
actions: Vec<AnyElement>,
details: Option<AnyElement>,
}
impl AiSettingItem {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
status: AiSettingItemStatus,
source: AiSettingItemSource,
) -> Self {
Self {
id: id.into(),
status,
source,
icon: None,
label: label.into(),
detail_label: None,
actions: Vec::new(),
details: None,
}
}
pub fn icon(mut self, element: impl IntoElement) -> Self {
self.icon = Some(element.into_any_element());
self
}
pub fn detail_label(mut self, detail: impl Into<SharedString>) -> Self {
self.detail_label = Some(detail.into());
self
}
pub fn action(mut self, element: impl IntoElement) -> Self {
self.actions.push(element.into_any_element());
self
}
pub fn details(mut self, element: impl IntoElement) -> Self {
self.details = Some(element.into_any_element());
self
}
}
impl RenderOnce for AiSettingItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self {
id,
status,
source,
icon,
label,
detail_label,
actions,
details,
} = self;
let source_id = format!("source-{}", id);
let icon_id = format!("icon-{}", id);
let status_tooltip = status.tooltip_text();
let source_tooltip = source.tooltip_text(&label);
let icon_element = icon.unwrap_or_else(|| {
let letter = label.chars().next().unwrap_or('?').to_ascii_uppercase();
h_flex()
.size_5()
.flex_none()
.justify_center()
.rounded_sm()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().element_active.opacity(0.2))
.child(
Label::new(SharedString::from(letter.to_string()))
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
.into_any_element()
});
let icon_child = if status.is_animated() {
div()
.child(icon_element)
.with_animation(
format!("icon-pulse-{}", id),
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|element, delta| element.opacity(delta),
)
.into_any_element()
} else {
icon_element.into_any_element()
};
let icon_container = div()
.id(icon_id)
.relative()
.flex_none()
.tooltip(Tooltip::text(status_tooltip))
.child(icon_child)
.when_some(status.indicator_color(), |this, color| {
this.child(
IconDecoration::new(
IconDecorationKind::Dot,
cx.theme().colors().panel_background,
cx,
)
.size(px(12.))
.color(color.color(cx))
.position(gpui::Point {
x: px(-3.),
y: px(-3.),
}),
)
});
v_flex()
.id(id)
.min_w_0()
.child(
h_flex()
.min_w_0()
.w_full()
.gap_1p5()
.justify_between()
.child(
h_flex()
.flex_1()
.min_w_0()
.gap_1p5()
.child(icon_container)
.child(Label::new(label).flex_shrink_0().truncate())
.child(
div()
.id(source_id)
.min_w_0()
.flex_none()
.tooltip(Tooltip::text(source_tooltip))
.child(
Icon::new(source.icon_name())
.size(IconSize::Small)
.color(Color::Muted),
),
)
.when_some(detail_label, |this, detail| {
this.child(
Label::new(detail)
.color(Color::Muted)
.size(LabelSize::Small),
)
}),
)
.when(!actions.is_empty(), |this| {
this.child(h_flex().gap_0p5().flex_none().children(actions))
}),
)
.children(details)
}
}
impl Component for AiSettingItem {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let container = || {
v_flex()
.w_80()
.p_2()
.gap_2()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
};
let details_row = |icon_name: IconName, icon_color: Color, message: &str| {
h_flex()
.py_1()
.min_w_0()
.w_full()
.gap_2()
.justify_between()
.child(
h_flex()
.pr_4()
.min_w_0()
.w_full()
.gap_2()
.child(
Icon::new(icon_name)
.size(IconSize::XSmall)
.color(icon_color),
)
.child(
div().min_w_0().flex_1().child(
Label::new(SharedString::from(message.to_string()))
.color(Color::Muted)
.size(LabelSize::Small),
),
),
)
};
let examples = vec![
single_example(
"MCP server with letter avatar (running)",
container()
.child(
AiSettingItem::new(
"ext-mcp",
"Postgres",
AiSettingItemStatus::Running,
AiSettingItemSource::Extension,
)
.detail_label("3 tools")
.action(
IconButton::new("menu", IconName::Settings)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
)
.action(
IconButton::new("toggle", IconName::Check)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
),
)
.into_any_element(),
),
single_example(
"MCP server (stopped)",
container()
.child(AiSettingItem::new(
"custom-mcp",
"my-local-server",
AiSettingItemStatus::Stopped,
AiSettingItemSource::Custom,
))
.into_any_element(),
),
single_example(
"MCP server (starting, animated)",
container()
.child(AiSettingItem::new(
"starting-mcp",
"Context7",
AiSettingItemStatus::Starting,
AiSettingItemSource::Extension,
))
.into_any_element(),
),
single_example(
"Agent with icon (running)",
container()
.child(
AiSettingItem::new(
"ext-agent",
"Claude Agent",
AiSettingItemStatus::Running,
AiSettingItemSource::Extension,
)
.icon(
Icon::new(IconName::AiClaude)
.size(IconSize::Small)
.color(Color::Muted),
)
.action(
IconButton::new("restart", IconName::RotateCw)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
)
.action(
IconButton::new("delete", IconName::Trash)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
),
)
.into_any_element(),
),
single_example(
"Registry agent (starting, animated)",
container()
.child(
AiSettingItem::new(
"reg-agent",
"Devin Agent",
AiSettingItemStatus::Starting,
AiSettingItemSource::Registry,
)
.icon(
Icon::new(IconName::ZedAssistant)
.size(IconSize::Small)
.color(Color::Muted),
),
)
.into_any_element(),
),
single_example(
"Error with details",
container()
.child(
AiSettingItem::new(
"error-mcp",
"Amplitude",
AiSettingItemStatus::Error,
AiSettingItemSource::Extension,
)
.details(
details_row(
IconName::XCircle,
Color::Error,
"Failed to connect: connection refused",
)
.child(
Button::new("logout", "Log Out")
.style(ButtonStyle::Outlined)
.label_size(LabelSize::Small),
),
),
)
.into_any_element(),
),
];
Some(example_group(examples).vertical().into_any_element())
}
}

View File

@@ -0,0 +1,151 @@
use crate::{Tooltip, prelude::*};
use gpui::{ClickEvent, IntoElement, ParentElement, SharedString};
#[derive(IntoElement, RegisterComponent)]
pub struct ConfiguredApiCard {
label: SharedString,
button_label: Option<SharedString>,
button_tab_index: Option<isize>,
tooltip_label: Option<SharedString>,
disabled: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl ConfiguredApiCard {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
button_label: None,
button_tab_index: None,
tooltip_label: None,
disabled: false,
on_click: None,
}
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn button_label(mut self, button_label: impl Into<SharedString>) -> Self {
self.button_label = Some(button_label.into());
self
}
pub fn tooltip_label(mut self, tooltip_label: impl Into<SharedString>) -> Self {
self.tooltip_label = Some(tooltip_label.into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn button_tab_index(mut self, tab_index: isize) -> Self {
self.button_tab_index = Some(tab_index);
self
}
}
impl Component for ConfiguredApiCard {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let container = || {
v_flex()
.w_72()
.p_2()
.gap_2()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
};
let examples = vec![
single_example(
"Default",
container()
.child(ConfiguredApiCard::new("API key is configured"))
.into_any_element(),
),
single_example(
"Custom Button Label",
container()
.child(
ConfiguredApiCard::new("OpenAI API key configured")
.button_label("Remove Key"),
)
.into_any_element(),
),
single_example(
"With Tooltip",
container()
.child(
ConfiguredApiCard::new("Anthropic API key configured")
.tooltip_label("Click to reset your API key"),
)
.into_any_element(),
),
single_example(
"Disabled",
container()
.child(ConfiguredApiCard::new("API key is configured").disabled(true))
.into_any_element(),
),
];
Some(example_group(examples).into_any_element())
}
}
impl RenderOnce for ConfiguredApiCard {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let button_label = self.button_label.unwrap_or("Reset Key".into());
let button_id = SharedString::new(format!("id-{}", button_label));
h_flex()
.min_w_0()
.mt_0p5()
.p_1()
.justify_between()
.rounded_md()
.flex_wrap()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().background)
.child(
h_flex()
.min_w_0()
.gap_1()
.child(Icon::new(IconName::Check).color(Color::Success))
.child(Label::new(self.label)),
)
.child(
Button::new(button_id, button_label)
.when_some(self.button_tab_index, |elem, tab_index| {
elem.tab_index(tab_index)
})
.label_size(LabelSize::Small)
.start_icon(
Icon::new(IconName::Undo)
.size(IconSize::Small)
.color(Color::Muted),
)
.disabled(self.disabled)
.when_some(self.tooltip_label, |this, label| {
this.tooltip(Tooltip::text(label))
})
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| this.on_click(on_click),
),
)
}
}

View File

@@ -0,0 +1,272 @@
use crate::{DiffStat, Divider, prelude::*};
use gpui::{Animation, AnimationExt, pulsating_between};
use std::time::Duration;
#[derive(IntoElement)]
pub struct ParallelAgentsIllustration;
impl ParallelAgentsIllustration {
pub fn new() -> Self {
Self
}
}
impl RenderOnce for ParallelAgentsIllustration {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let icon_container = || h_flex().size_4().flex_shrink_0().justify_center();
let loading_bar = |id: &'static str, width: DefiniteLength, duration_ms: u64| {
div()
.h(rems_from_px(5.))
.w(width)
.rounded_full()
.bg(cx.theme().colors().element_selected)
.with_animation(
id,
Animation::new(Duration::from_millis(duration_ms))
.repeat()
.with_easing(pulsating_between(0.1, 0.8)),
|label, delta| label.opacity(delta),
)
};
let skeleton_bar = |width: DefiniteLength| {
div().h(rems_from_px(5.)).w(width).rounded_full().bg(cx
.theme()
.colors()
.text_muted
.opacity(0.05))
};
let time =
|time: SharedString| Label::new(time).size(LabelSize::XSmall).color(Color::Muted);
let worktree = |worktree: SharedString| {
h_flex()
.gap_0p5()
.child(
Icon::new(IconName::GitWorktree)
.color(Color::Muted)
.size(IconSize::Indicator),
)
.child(
Label::new(worktree)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
};
let dot_separator = || {
Label::new("")
.size(LabelSize::Small)
.color(Color::Muted)
.alpha(0.5)
};
let agent = |title: SharedString, icon: IconName, selected: bool, data: Vec<AnyElement>| {
v_flex()
.when(selected, |this| {
this.bg(cx.theme().colors().element_active.opacity(0.2))
})
.p_1()
.child(
h_flex()
.w_full()
.gap_1()
.child(
icon_container()
.child(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted)),
)
.map(|this| {
if selected {
this.child(
Label::new(title)
.color(Color::Muted)
.size(LabelSize::XSmall),
)
} else {
this.child(skeleton_bar(relative(0.7)))
}
}),
)
.child(
h_flex()
.opacity(0.8)
.w_full()
.gap_1()
.child(icon_container())
.children(data),
)
};
let agents = v_flex()
.col_span(3)
.bg(cx.theme().colors().elevated_surface_background)
.child(agent(
"Fix branch label".into(),
IconName::ZedAgent,
true,
vec![
worktree("bug-fix".into()).into_any_element(),
dot_separator().into_any_element(),
DiffStat::new("ds", 5, 2)
.label_size(LabelSize::XSmall)
.into_any_element(),
dot_separator().into_any_element(),
time("2m".into()).into_any_element(),
],
))
.child(Divider::horizontal())
.child(agent(
"Improve thread id".into(),
IconName::AiClaude,
false,
vec![
DiffStat::new("ds", 120, 84)
.label_size(LabelSize::XSmall)
.into_any_element(),
dot_separator().into_any_element(),
time("16m".into()).into_any_element(),
],
))
.child(Divider::horizontal())
.child(agent(
"Refactor archive view".into(),
IconName::AiOpenAi,
false,
vec![
worktree("silent-forest".into()).into_any_element(),
dot_separator().into_any_element(),
time("37m".into()).into_any_element(),
],
));
let thread_view = v_flex()
.col_span(3)
.h_full()
.flex_1()
.border_l_1()
.border_color(cx.theme().colors().border.opacity(0.5))
.bg(cx.theme().colors().panel_background)
.child(
h_flex()
.px_1p5()
.py_0p5()
.w_full()
.justify_between()
.border_b_1()
.border_color(cx.theme().colors().border.opacity(0.5))
.child(
Label::new("Fix branch label")
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.child(
Icon::new(IconName::Plus)
.size(IconSize::Indicator)
.color(Color::Muted),
),
)
.child(
div().p_1().child(
v_flex()
.px_1()
.py_1p5()
.gap_1()
.border_1()
.border_color(cx.theme().colors().border.opacity(0.5))
.bg(cx.theme().colors().editor_background)
.rounded_sm()
.shadow_sm()
.child(skeleton_bar(relative(0.7)))
.child(skeleton_bar(relative(0.2))),
),
)
.child(
v_flex()
.p_2()
.gap_1()
.child(loading_bar("a", relative(0.55), 2200))
.child(loading_bar("b", relative(0.75), 2000))
.child(loading_bar("c", relative(0.25), 2400)),
);
let file_row = |indent: usize, is_folder: bool, bar_width: Rems| {
let indent_px = rems_from_px((indent as f32) * 4.0);
h_flex()
.px_2()
.py_px()
.gap_1()
.pl(indent_px)
.child(
icon_container().child(
Icon::new(if is_folder {
IconName::FolderOpen
} else {
IconName::FileRust
})
.size(IconSize::Indicator)
.color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.2))),
),
)
.child(
div().h_1p5().w(bar_width).rounded_sm().bg(cx
.theme()
.colors()
.text
.opacity(if is_folder { 0.15 } else { 0.1 })),
)
};
let project_panel = v_flex()
.col_span(1)
.h_full()
.flex_1()
.border_l_1()
.border_color(cx.theme().colors().border.opacity(0.5))
.bg(cx.theme().colors().panel_background)
.child(
v_flex()
.child(file_row(0, true, rems_from_px(42.0)))
.child(file_row(1, true, rems_from_px(28.0)))
.child(file_row(2, false, rems_from_px(52.0)))
.child(file_row(2, false, rems_from_px(36.0)))
.child(file_row(2, false, rems_from_px(44.0)))
.child(file_row(1, true, rems_from_px(34.0)))
.child(file_row(2, false, rems_from_px(48.0)))
.child(file_row(2, true, rems_from_px(26.0)))
.child(file_row(3, false, rems_from_px(40.0)))
.child(file_row(3, false, rems_from_px(56.0)))
.child(file_row(1, false, rems_from_px(38.0)))
.child(file_row(0, true, rems_from_px(30.0)))
.child(file_row(1, false, rems_from_px(46.0)))
.child(file_row(1, false, rems_from_px(32.0))),
);
let workspace = div()
.absolute()
.top_8()
.grid()
.grid_cols(7)
.w(rems_from_px(380.))
.rounded_t_sm()
.border_1()
.border_color(cx.theme().colors().border.opacity(0.5))
.shadow_md()
.child(agents)
.child(thread_view)
.child(project_panel);
h_flex()
.relative()
.h(rems_from_px(180.))
.bg(cx.theme().colors().editor_background.opacity(0.6))
.justify_center()
.items_end()
.rounded_t_md()
.overflow_hidden()
.bg(gpui::black().opacity(0.2))
.child(workspace)
}
}

View File

@@ -0,0 +1,939 @@
use crate::{CommonAnimationExt, DiffStat, GradientFade, HighlightedLabel, Tooltip, prelude::*};
use gpui::{
Animation, AnimationExt, ClickEvent, Hsla, MouseButton, SharedString, pulsating_between,
};
use itertools::Itertools as _;
use std::{path::PathBuf, sync::Arc, time::Duration};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AgentThreadStatus {
#[default]
Completed,
Running,
WaitingForConfirmation,
Error,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum WorktreeKind {
#[default]
Main,
Linked,
}
#[derive(Clone, Default)]
pub struct ThreadItemWorktreeInfo {
pub worktree_name: Option<SharedString>,
pub branch_name: Option<SharedString>,
pub full_path: SharedString,
pub highlight_positions: Vec<usize>,
pub kind: WorktreeKind,
}
#[derive(IntoElement, RegisterComponent)]
pub struct ThreadItem {
id: ElementId,
icon: IconName,
icon_color: Option<Color>,
icon_visible: bool,
custom_icon_from_external_svg: Option<SharedString>,
title: SharedString,
title_label_color: Option<Color>,
title_generating: bool,
highlight_positions: Vec<usize>,
timestamp: SharedString,
notified: bool,
status: AgentThreadStatus,
selected: bool,
focused: bool,
hovered: bool,
rounded: bool,
added: Option<usize>,
removed: Option<usize>,
project_paths: Option<Arc<[PathBuf]>>,
project_name: Option<SharedString>,
worktrees: Vec<ThreadItemWorktreeInfo>,
is_remote: bool,
archived: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
action_slot: Option<AnyElement>,
base_bg: Option<Hsla>,
}
impl ThreadItem {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
icon: IconName::ZedAgent,
icon_color: None,
icon_visible: true,
custom_icon_from_external_svg: None,
title: title.into(),
title_label_color: None,
title_generating: false,
highlight_positions: Vec::new(),
timestamp: "".into(),
notified: false,
status: AgentThreadStatus::default(),
selected: false,
focused: false,
hovered: false,
rounded: false,
added: None,
removed: None,
project_paths: None,
project_name: None,
worktrees: Vec::new(),
is_remote: false,
archived: false,
on_click: None,
on_hover: Box::new(|_, _, _| {}),
action_slot: None,
base_bg: None,
}
}
pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
self.timestamp = timestamp.into();
self
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = icon;
self
}
pub fn icon_color(mut self, color: Color) -> Self {
self.icon_color = Some(color);
self
}
pub fn icon_visible(mut self, visible: bool) -> Self {
self.icon_visible = visible;
self
}
pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
self.custom_icon_from_external_svg = Some(svg.into());
self
}
pub fn notified(mut self, notified: bool) -> Self {
self.notified = notified;
self
}
pub fn status(mut self, status: AgentThreadStatus) -> Self {
self.status = status;
self
}
pub fn title_generating(mut self, generating: bool) -> Self {
self.title_generating = generating;
self
}
pub fn title_label_color(mut self, color: Color) -> Self {
self.title_label_color = Some(color);
self
}
pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
self.highlight_positions = positions;
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn added(mut self, added: usize) -> Self {
self.added = Some(added);
self
}
pub fn removed(mut self, removed: usize) -> Self {
self.removed = Some(removed);
self
}
pub fn project_paths(mut self, paths: Arc<[PathBuf]>) -> Self {
self.project_paths = Some(paths);
self
}
pub fn project_name(mut self, name: impl Into<SharedString>) -> Self {
self.project_name = Some(name.into());
self
}
pub fn worktrees(mut self, worktrees: Vec<ThreadItemWorktreeInfo>) -> Self {
self.worktrees = worktrees;
self
}
pub fn is_remote(mut self, is_remote: bool) -> Self {
self.is_remote = is_remote;
self
}
pub fn archived(mut self, archived: bool) -> Self {
self.archived = archived;
self
}
pub fn hovered(mut self, hovered: bool) -> Self {
self.hovered = hovered;
self
}
pub fn rounded(mut self, rounded: bool) -> Self {
self.rounded = rounded;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Box::new(on_hover);
self
}
pub fn action_slot(mut self, element: impl IntoElement) -> Self {
self.action_slot = Some(element.into_any_element());
self
}
pub fn base_bg(mut self, color: Hsla) -> Self {
self.base_bg = Some(color);
self
}
}
impl RenderOnce for ThreadItem {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let color = cx.theme().colors();
let sidebar_base_bg = color
.title_bar_background
.blend(color.panel_background.opacity(0.25));
let raw_bg = self.base_bg.unwrap_or(sidebar_base_bg);
let apparent_bg = color.background.blend(raw_bg);
let base_bg = if self.selected {
apparent_bg.blend(color.element_active)
} else {
apparent_bg
};
let hover_color = color
.element_active
.blend(color.element_background.opacity(0.2));
let hover_bg = apparent_bg.blend(hover_color);
let gradient_overlay = GradientFade::new(base_bg, hover_bg, hover_bg)
.width(px(64.0))
.right(px(-10.0))
.gradient_stop(0.75)
.group_name("thread-item");
let separator_color = Color::Custom(color.text_muted.opacity(0.4));
let dot_separator = || {
Label::new("")
.size(LabelSize::Small)
.color(separator_color)
};
let icon_id = format!("icon-{}", self.id);
let icon_visible = self.icon_visible;
let icon_container = || {
h_flex()
.id(icon_id.clone())
.size_4()
.flex_none()
.justify_center()
.when(!icon_visible, |this| this.invisible())
};
let icon_color = self.icon_color.unwrap_or(Color::Muted);
let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
Icon::from_external_svg(custom_svg)
.color(icon_color)
.size(IconSize::Small)
} else {
Icon::new(self.icon).color(icon_color).size(IconSize::Small)
};
let status_icon = if self.status == AgentThreadStatus::Error {
Some(
Icon::new(IconName::Close)
.size(IconSize::Small)
.color(Color::Error),
)
} else if self.status == AgentThreadStatus::WaitingForConfirmation {
Some(
Icon::new(IconName::Warning)
.size(IconSize::XSmall)
.color(Color::Warning),
)
} else if self.notified {
Some(
Icon::new(IconName::Circle)
.size(IconSize::Small)
.color(Color::Accent),
)
} else {
None
};
let icon = if self.status == AgentThreadStatus::Running {
icon_container()
.child(
Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(2),
)
.into_any_element()
} else if let Some(status_icon) = status_icon {
icon_container().child(status_icon).into_any_element()
} else {
icon_container().child(agent_icon).into_any_element()
};
let title = self.title;
let highlight_positions = self.highlight_positions;
let title_label = if self.title_generating {
Label::new(title)
.color(Color::Muted)
.with_animation(
"generating-title",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(pulsating_between(0.4, 0.8)),
|label, delta| label.alpha(delta),
)
.into_any_element()
} else if highlight_positions.is_empty() {
Label::new(title)
.when_some(self.title_label_color, |label, color| label.color(color))
.into_any_element()
} else {
HighlightedLabel::new(title, highlight_positions)
.when_some(self.title_label_color, |label, color| label.color(color))
.into_any_element()
};
let has_diff_stats = self.added.is_some() || self.removed.is_some();
let diff_stat_id = self.id.clone();
let added_count = self.added.unwrap_or(0);
let removed_count = self.removed.unwrap_or(0);
let project_paths = self.project_paths.as_ref().and_then(|paths| {
let paths_str = paths
.as_ref()
.iter()
.filter_map(|p| p.file_name())
.filter_map(|name| name.to_str())
.join(", ");
if paths_str.is_empty() {
None
} else {
Some(paths_str)
}
});
let has_project_name = self.project_name.is_some();
let has_project_paths = project_paths.is_some();
let has_timestamp = !self.timestamp.is_empty();
let timestamp = self.timestamp;
let show_tooltip = matches!(
self.status,
AgentThreadStatus::Error | AgentThreadStatus::WaitingForConfirmation
);
let linked_worktrees: Vec<ThreadItemWorktreeInfo> = self
.worktrees
.into_iter()
.filter(|wt| wt.kind == WorktreeKind::Linked)
.filter(|wt| wt.worktree_name.is_some() || wt.branch_name.is_some())
.collect();
let has_worktree = !linked_worktrees.is_empty();
let has_metadata = has_project_name
|| has_project_paths
|| has_worktree
|| has_diff_stats
|| has_timestamp;
v_flex()
.id(self.id.clone())
.cursor_pointer()
.group("thread-item")
.relative()
.flex_shrink_0()
.overflow_hidden()
.w_full()
.py_1()
.px_1p5()
.when(self.selected, |s| s.bg(color.element_active))
.border_1()
.border_color(gpui::transparent_black())
.when(self.focused, |s| s.border_color(color.border_focused))
.when(self.rounded, |s| s.rounded_sm())
.hover(|s| s.bg(hover_color))
.on_hover(self.on_hover)
.child(
h_flex()
.min_w_0()
.w_full()
.gap_2()
.justify_between()
.child(
h_flex()
.id("content")
.min_w_0()
.flex_1()
.gap_1p5()
.child(icon)
.child(title_label),
)
.child(gradient_overlay)
.when(self.hovered, |this| {
this.when_some(self.action_slot, |this, slot| {
let overlay = GradientFade::new(base_bg, hover_bg, hover_bg)
.width(px(80.0))
.right(px(8.))
.gradient_stop(0.80)
.group_name("thread-item");
this.child(
h_flex()
.relative()
.pr_1p5()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation()
})
.child(overlay)
.child(slot),
)
})
}),
)
.when(has_metadata, |this| {
this.child(
h_flex()
.gap_1p5()
.child(icon_container()) // Icon Spacing
.when(self.archived, |this| {
this.child(
Icon::new(IconName::Archive).size(IconSize::XSmall).color(
Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)),
),
)
// .child(dot_separator())
})
.when(
has_project_name || has_project_paths || has_worktree,
|this| {
this.when_some(self.project_name, |this, name| {
this.child(
Label::new(name).size(LabelSize::Small).color(Color::Muted),
)
})
.when(
has_project_name && (has_project_paths || has_worktree),
|this| this.child(dot_separator()),
)
.when_some(project_paths, |this, paths| {
this.child(
Label::new(paths)
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when(has_project_paths && has_worktree, |this| {
this.child(dot_separator())
})
.children(
linked_worktrees.into_iter().map(|wt| {
let worktree_label = wt.worktree_name.clone().map(|name| {
if wt.highlight_positions.is_empty() {
Label::new(name)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element()
} else {
HighlightedLabel::new(
name,
wt.highlight_positions.clone(),
)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element()
}
});
// When only the branch is shown, lead with a branch icon;
// otherwise keep the worktree icon (which "covers" both the
// worktree and any accompanying branch).
let chip_icon = if wt.worktree_name.is_none()
&& wt.branch_name.is_some()
{
IconName::GitBranch
} else {
IconName::GitWorktree
};
let branch_label = wt.branch_name.map(|branch| {
Label::new(branch)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element()
});
let show_separator =
worktree_label.is_some() && branch_label.is_some();
h_flex()
.min_w_0()
.gap_0p5()
.child(
Icon::new(chip_icon)
.size(IconSize::XSmall)
.color(Color::Muted),
)
.when_some(worktree_label, |this, label| {
this.child(label)
})
.when(show_separator, |this| {
this.child(
Label::new("/")
.size(LabelSize::Small)
.color(separator_color)
.flex_shrink_0(),
)
})
.when_some(branch_label, |this, label| {
this.child(label)
})
}),
)
},
)
.when(
(has_project_name || has_project_paths || has_worktree)
&& (has_diff_stats || has_timestamp),
|this| this.child(dot_separator()),
)
.when(has_diff_stats, |this| {
this.child(DiffStat::new(diff_stat_id, added_count, removed_count))
})
.when(has_diff_stats && has_timestamp, |this| {
this.child(dot_separator())
})
.when(has_timestamp, |this| {
this.child(
Label::new(timestamp.clone())
.size(LabelSize::Small)
.color(Color::Muted),
)
}),
)
})
.when(show_tooltip, |this| {
let status = self.status;
this.tooltip(Tooltip::element(move |_, _| match status {
AgentThreadStatus::Error => h_flex()
.gap_1()
.child(
Icon::new(IconName::Close)
.size(IconSize::Small)
.color(Color::Error),
)
.child(Label::new("Thread has an Error"))
.into_any_element(),
AgentThreadStatus::WaitingForConfirmation => h_flex()
.gap_1()
.child(
Icon::new(IconName::Warning)
.size(IconSize::Small)
.color(Color::Warning),
)
.child(Label::new("Waiting for Confirmation"))
.into_any_element(),
_ => gpui::Empty.into_any_element(),
}))
})
.when_some(self.on_click, |this, on_click| this.on_click(on_click))
}
}
impl Component for ThreadItem {
fn scope() -> ComponentScope {
ComponentScope::Agent
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let color = cx.theme().colors();
let bg = color
.title_bar_background
.blend(color.panel_background.opacity(0.25));
let container = || {
v_flex()
.w_72()
.border_1()
.border_color(color.border_variant)
.bg(bg)
};
let thread_item_examples = vec![
single_example(
"Default",
container()
.child(
ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
.icon(IconName::AiOpenAi)
.timestamp("15m"),
)
.into_any_element(),
),
single_example(
"Waiting for Confirmation",
container()
.child(
ThreadItem::new("ti-2b", "Execute shell command in terminal")
.timestamp("2h")
.status(AgentThreadStatus::WaitingForConfirmation),
)
.into_any_element(),
),
single_example(
"Error",
container()
.child(
ThreadItem::new("ti-2c", "Failed to connect to language server")
.timestamp("5h")
.status(AgentThreadStatus::Error),
)
.into_any_element(),
),
single_example(
"Running Agent",
container()
.child(
ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
.icon(IconName::AiClaude)
.timestamp("23h")
.status(AgentThreadStatus::Running),
)
.into_any_element(),
),
single_example(
"In Worktree",
container()
.child(
ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
.icon(IconName::AiClaude)
.timestamp("2w")
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("link-agent-panel".into()),
full_path: "link-agent-panel".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
}]),
)
.into_any_element(),
),
single_example(
"With Changes",
container()
.child(
ThreadItem::new("ti-5", "Managing user and project settings interactions")
.icon(IconName::AiClaude)
.timestamp("1mo")
.added(10)
.removed(3),
)
.into_any_element(),
),
single_example(
"Worktree + Changes + Timestamp",
container()
.child(
ThreadItem::new("ti-5b", "Full metadata example")
.icon(IconName::AiClaude)
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("my-project".into()),
full_path: "my-project".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
}])
.added(42)
.removed(17)
.timestamp("3w"),
)
.into_any_element(),
),
single_example(
"Worktree + Branch + Changes + Timestamp",
container()
.child(
ThreadItem::new("ti-5c", "Full metadata with branch")
.icon(IconName::AiClaude)
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("my-project".into()),
full_path: "/worktrees/my-project/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("feature-branch".into()),
}])
.added(42)
.removed(17)
.timestamp("3w"),
)
.into_any_element(),
),
single_example(
"Long Branch + Changes (truncation)",
container()
.child(
ThreadItem::new("ti-5d", "Metadata overflow with long branch name")
.icon(IconName::AiClaude)
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("my-project".into()),
full_path: "/worktrees/my-project/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("fix-very-long-branch-name-here".into()),
}])
.added(108)
.removed(53)
.timestamp("2d"),
)
.into_any_element(),
),
single_example(
"Main Worktree (hidden) + Changes + Timestamp",
container()
.child(
ThreadItem::new("ti-5e", "Main worktree branch with diff stats")
.icon(IconName::ZedAgent)
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("zed".into()),
full_path: "/projects/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Main,
branch_name: Some("sidebar-show-branch-name".into()),
}])
.added(23)
.removed(8)
.timestamp("5m"),
)
.into_any_element(),
),
single_example(
"Long Worktree Name (truncation)",
container()
.child(
ThreadItem::new("ti-5f", "Thread with a very long worktree name")
.icon(IconName::AiClaude)
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some(
"very-long-worktree-name-that-should-truncate".into(),
),
full_path: "/worktrees/very-long-worktree-name/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
}])
.timestamp("1h"),
)
.into_any_element(),
),
single_example(
"Worktree with Search Highlights",
container()
.child(
ThreadItem::new("ti-5g", "Filtered thread with highlighted worktree")
.icon(IconName::AiClaude)
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("jade-glen".into()),
full_path: "/worktrees/jade-glen/zed".into(),
highlight_positions: vec![0, 1, 2, 3],
kind: WorktreeKind::Linked,
branch_name: Some("fix-scrolling".into()),
}])
.timestamp("3d"),
)
.into_any_element(),
),
single_example(
"Multiple Worktrees (no branches)",
container()
.child(
ThreadItem::new("ti-5h", "Thread spanning multiple worktrees")
.icon(IconName::AiClaude)
.worktrees(vec![
ThreadItemWorktreeInfo {
worktree_name: Some("jade-glen".into()),
full_path: "/worktrees/jade-glen/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
},
ThreadItemWorktreeInfo {
worktree_name: Some("fawn-otter".into()),
full_path: "/worktrees/fawn-otter/zed-slides".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: None,
},
])
.timestamp("2h"),
)
.into_any_element(),
),
single_example(
"Multiple Worktrees with Branches",
container()
.child(
ThreadItem::new("ti-5i", "Multi-root with per-worktree branches")
.icon(IconName::ZedAgent)
.worktrees(vec![
ThreadItemWorktreeInfo {
worktree_name: Some("jade-glen".into()),
full_path: "/worktrees/jade-glen/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("fix".into()),
},
ThreadItemWorktreeInfo {
worktree_name: Some("fawn-otter".into()),
full_path: "/worktrees/fawn-otter/zed-slides".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("main".into()),
},
])
.timestamp("15m"),
)
.into_any_element(),
),
single_example(
"Project Name + Worktree + Branch",
container()
.child(
ThreadItem::new("ti-5j", "Thread with project context")
.icon(IconName::AiClaude)
.project_name("my-remote-server")
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("jade-glen".into()),
full_path: "/worktrees/jade-glen/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("feature-branch".into()),
}])
.timestamp("1d"),
)
.into_any_element(),
),
single_example(
"Project Paths + Worktree (archive view)",
container()
.child(
ThreadItem::new("ti-5k", "Archived thread with folder paths")
.icon(IconName::AiClaude)
.project_paths(Arc::from(vec![
PathBuf::from("/projects/zed"),
PathBuf::from("/projects/zed-slides"),
]))
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("jade-glen".into()),
full_path: "/worktrees/jade-glen/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("feature".into()),
}])
.timestamp("2mo"),
)
.into_any_element(),
),
single_example(
"All Metadata",
container()
.child(
ThreadItem::new("ti-5l", "Thread with every metadata field populated")
.icon(IconName::ZedAgent)
.project_name("remote-dev")
.worktrees(vec![ThreadItemWorktreeInfo {
worktree_name: Some("my-worktree".into()),
full_path: "/worktrees/my-worktree/zed".into(),
highlight_positions: Vec::new(),
kind: WorktreeKind::Linked,
branch_name: Some("main".into()),
}])
.added(15)
.removed(4)
.timestamp("8h"),
)
.into_any_element(),
),
single_example(
"Focused Item (Keyboard Selection)",
container()
.child(
ThreadItem::new("ti-7", "Implement keyboard navigation")
.icon(IconName::AiClaude)
.timestamp("12h")
.focused(true),
)
.into_any_element(),
),
single_example(
"Action Slot",
container()
.child(
ThreadItem::new("ti-9", "Hover to see action button")
.icon(IconName::AiClaude)
.timestamp("6h")
.hovered(true)
.action_slot(
IconButton::new("delete", IconName::Trash)
.icon_size(IconSize::Small)
.icon_color(Color::Muted),
),
)
.into_any_element(),
),
];
Some(
example_group(thread_item_examples)
.vertical()
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,303 @@
use crate::prelude::*;
use documented::Documented;
use gpui::{AnyElement, Hsla, ImageSource, Img, IntoElement, Styled, img};
/// An element that renders a user avatar with customizable appearance options.
///
/// # Examples
///
/// ```
/// use ui::Avatar;
///
/// Avatar::new("path/to/image.png")
/// .grayscale(true)
/// .border_color(gpui::red());
/// ```
#[derive(IntoElement, Documented, RegisterComponent)]
pub struct Avatar {
image: Img,
size: Option<AbsoluteLength>,
border_color: Option<Hsla>,
indicator: Option<AnyElement>,
}
impl Avatar {
/// Creates a new avatar element with the specified image source.
pub fn new(src: impl Into<ImageSource>) -> Self {
Avatar {
image: img(src),
size: None,
border_color: None,
indicator: None,
}
}
/// Applies a grayscale filter to the avatar image.
///
/// # Examples
///
/// ```
/// use ui::Avatar;
///
/// let avatar = Avatar::new("path/to/image.png").grayscale(true);
/// ```
pub fn grayscale(mut self, grayscale: bool) -> Self {
self.image = self.image.grayscale(grayscale);
self
}
/// Sets the border color of the avatar.
///
/// This might be used to match the border to the background color of
/// the parent element to create the illusion of cropping another
/// shape underneath (for example in face piles.)
pub fn border_color(mut self, color: impl Into<Hsla>) -> Self {
self.border_color = Some(color.into());
self
}
/// Size overrides the avatar size. By default they are 1rem.
pub fn size<L: Into<AbsoluteLength>>(mut self, size: impl Into<Option<L>>) -> Self {
self.size = size.into().map(Into::into);
self
}
/// Sets the current indicator to be displayed on the avatar, if any.
pub fn indicator<E: IntoElement>(mut self, indicator: impl Into<Option<E>>) -> Self {
self.indicator = indicator.into().map(IntoElement::into_any_element);
self
}
}
impl RenderOnce for Avatar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_width = if self.border_color.is_some() {
px(1.)
} else {
px(0.)
};
let image_size = self.size.unwrap_or_else(|| rems(1.).into());
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
div()
.size(container_size)
.rounded_full()
.when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color)
})
.child(
self.image
.size(image_size)
.rounded_full()
.bg(cx.theme().colors().element_disabled)
.with_fallback(|| {
h_flex()
.size_full()
.justify_center()
.child(
Icon::new(IconName::Person)
.color(Color::Muted)
.size(IconSize::Small),
)
.into_any_element()
}),
)
.children(self.indicator.map(|indicator| div().child(indicator)))
}
}
use gpui::AnyView;
/// The audio status of an player, for use in representing
/// their status visually on their avatar.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum AudioStatus {
/// The player's microphone is muted.
Muted,
/// The player's microphone is muted, and collaboration audio is disabled.
Deafened,
}
/// An indicator that shows the audio status of a player.
#[derive(IntoElement)]
pub struct AvatarAudioStatusIndicator {
audio_status: AudioStatus,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
}
impl AvatarAudioStatusIndicator {
/// Creates a new `AvatarAudioStatusIndicator`
pub fn new(audio_status: AudioStatus) -> Self {
Self {
audio_status,
tooltip: None,
}
}
/// Sets the tooltip for the indicator.
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Box::new(tooltip));
self
}
}
impl RenderOnce for AvatarAudioStatusIndicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let icon_size = IconSize::Indicator;
let width_in_px = icon_size.rems() * window.rem_size();
let padding_x = px(4.);
div()
.absolute()
.bottom(rems_from_px(-3.))
.right(rems_from_px(-6.))
.w(width_in_px + padding_x)
.h(icon_size.rems())
.child(
h_flex()
.id("muted-indicator")
.justify_center()
.px(padding_x)
.py(px(2.))
.bg(cx.theme().status().error_background)
.rounded_sm()
.child(
Icon::new(match self.audio_status {
AudioStatus::Muted => IconName::MicMute,
AudioStatus::Deafened => IconName::AudioOff,
})
.size(icon_size)
.color(Color::Error),
)
.when_some(self.tooltip, |this, tooltip| {
this.tooltip(move |window, cx| tooltip(window, cx))
}),
)
}
}
/// Represents the availability status of a collaborator.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum CollaboratorAvailability {
Free,
Busy,
}
/// Represents the availability and presence status of a collaborator.
#[derive(IntoElement)]
pub struct AvatarAvailabilityIndicator {
availability: CollaboratorAvailability,
avatar_size: Option<Pixels>,
}
impl AvatarAvailabilityIndicator {
/// Creates a new indicator
pub fn new(availability: CollaboratorAvailability) -> Self {
Self {
availability,
avatar_size: None,
}
}
/// Sets the size of the [`Avatar`](crate::Avatar) this indicator appears on.
pub fn avatar_size(mut self, size: impl Into<Option<Pixels>>) -> Self {
self.avatar_size = size.into();
self
}
}
impl RenderOnce for AvatarAvailabilityIndicator {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let avatar_size = self.avatar_size.unwrap_or_else(|| window.rem_size());
// HACK: non-integer sizes result in oval indicators.
let indicator_size = (avatar_size * 0.4).round();
div()
.absolute()
.bottom_0()
.right_0()
.size(indicator_size)
.rounded(indicator_size)
.bg(match self.availability {
CollaboratorAvailability::Free => cx.theme().status().created,
CollaboratorAvailability::Busy => cx.theme().status().deleted,
})
}
}
// View this component preview using `workspace: open component-preview`
impl Component for Avatar {
fn scope() -> ComponentScope {
ComponentScope::Collaboration
}
fn description() -> Option<&'static str> {
Some(Avatar::DOCS)
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let example_avatar = "https://avatars.githubusercontent.com/u/1714999?v=4";
Some(
v_flex()
.gap_6()
.children(vec![
example_group(vec![
single_example("Default", Avatar::new(example_avatar).into_any_element()),
single_example(
"Grayscale",
Avatar::new(example_avatar)
.grayscale(true)
.into_any_element(),
),
single_example(
"Border",
Avatar::new(example_avatar)
.border_color(cx.theme().colors().border)
.into_any_element(),
).description("Can be used to create visual space by setting the border color to match the background, which creates the appearance of a gap around the avatar."),
]),
example_group_with_title(
"Indicator Styles",
vec![
single_example(
"Muted",
Avatar::new(example_avatar)
.indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted))
.into_any_element(),
).description("Indicates the collaborator's mic is muted."),
single_example(
"Deafened",
Avatar::new(example_avatar)
.indicator(AvatarAudioStatusIndicator::new(
AudioStatus::Deafened,
))
.into_any_element(),
).description("Indicates that both the collaborator's mic and audio are muted."),
single_example(
"Availability: Free",
Avatar::new(example_avatar)
.indicator(AvatarAvailabilityIndicator::new(
CollaboratorAvailability::Free,
))
.into_any_element(),
).description("Indicates that the person is free, usually meaning they are not in a call."),
single_example(
"Availability: Busy",
Avatar::new(example_avatar)
.indicator(AvatarAvailabilityIndicator::new(
CollaboratorAvailability::Busy,
))
.into_any_element(),
).description("Indicates that the person is busy, usually meaning they are in a channel or direct call."),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,188 @@
use crate::prelude::*;
use gpui::{AnyElement, IntoElement, ParentElement, Styled};
/// Banners provide informative and brief messages without interrupting the user.
/// This component offers four severity levels that can be used depending on the message.
///
/// # Usage Example
///
/// ```
/// use ui::prelude::*;
/// use ui::{Banner, Button, Icon, IconName, IconSize, Label, Severity};
///
/// Banner::new()
/// .severity(Severity::Success)
/// .children([Label::new("This is a success message")])
/// .action_slot(
/// Button::new("learn-more", "Learn More")
/// .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)),
/// );
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct Banner {
severity: Severity,
children: Vec<AnyElement>,
action_slot: Option<AnyElement>,
wrap_content: bool,
}
impl Banner {
/// Creates a new `Banner` component with default styling.
pub fn new() -> Self {
Self {
severity: Severity::Info,
children: Vec::new(),
action_slot: None,
wrap_content: false,
}
}
/// Sets the severity of the banner.
pub fn severity(mut self, severity: Severity) -> Self {
self.severity = severity;
self
}
/// A slot for actions, such as CTA or dismissal buttons.
pub fn action_slot(mut self, element: impl IntoElement) -> Self {
self.action_slot = Some(element.into_any_element());
self
}
/// Sets whether the banner content should wrap.
pub fn wrap_content(mut self, wrap: bool) -> Self {
self.wrap_content = wrap;
self
}
}
impl ParentElement for Banner {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for Banner {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let banner = h_flex()
.min_w_0()
.py_0p5()
.gap_1p5()
.when(self.wrap_content, |this| this.flex_wrap())
.justify_between()
.rounded_sm()
.border_1();
let (icon, icon_color, bg_color, border_color) = match self.severity {
Severity::Info => (
IconName::Info,
Color::Muted,
cx.theme().status().info_background.opacity(0.5),
cx.theme().colors().border.opacity(0.5),
),
Severity::Success => (
IconName::Check,
Color::Success,
cx.theme().status().success.opacity(0.1),
cx.theme().status().success.opacity(0.2),
),
Severity::Warning => (
IconName::Warning,
Color::Warning,
cx.theme().status().warning_background.opacity(0.5),
cx.theme().status().warning_border.opacity(0.4),
),
Severity::Error => (
IconName::XCircle,
Color::Error,
cx.theme().status().error.opacity(0.1),
cx.theme().status().error.opacity(0.2),
),
};
let mut banner = banner.bg(bg_color).border_color(border_color);
let icon_and_child = h_flex()
.items_start()
.min_w_0()
.flex_1()
.gap_1p5()
.child(
h_flex()
.h(window.line_height())
.flex_shrink_0()
.child(Icon::new(icon).size(IconSize::XSmall).color(icon_color)),
)
.child(div().min_w_0().flex_1().children(self.children));
if let Some(action_slot) = self.action_slot {
banner = banner
.pl_2()
.pr_1()
.child(icon_and_child)
.child(action_slot);
} else {
banner = banner.px_2().child(icon_and_child);
}
banner
}
}
impl Component for Banner {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let severity_examples = vec![
single_example(
"Default",
Banner::new()
.child(Label::new("This is a default banner with no customization"))
.into_any_element(),
),
single_example(
"Info",
Banner::new()
.severity(Severity::Info)
.child(Label::new("This is an informational message"))
.action_slot(
Button::new("learn-more", "Learn More")
.end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)),
)
.into_any_element(),
),
single_example(
"Success",
Banner::new()
.severity(Severity::Success)
.child(Label::new("Operation completed successfully"))
.action_slot(Button::new("dismiss", "Dismiss"))
.into_any_element(),
),
single_example(
"Warning",
Banner::new()
.severity(Severity::Warning)
.child(Label::new("Your settings file uses deprecated settings"))
.action_slot(Button::new("update", "Update Settings"))
.into_any_element(),
),
single_example(
"Error",
Banner::new()
.severity(Severity::Error)
.child(Label::new("Connection error: unable to connect to server"))
.action_slot(Button::new("reconnect", "Retry"))
.into_any_element(),
),
];
Some(
example_group(severity_examples)
.vertical()
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,15 @@
mod button;
mod button_like;
mod button_link;
mod copy_button;
mod icon_button;
mod split_button;
mod toggle_button;
pub use button::*;
pub use button_like::*;
pub use button_link::*;
pub use copy_button::*;
pub use icon_button::*;
pub use split_button::*;
pub use toggle_button::*;

View File

@@ -0,0 +1,577 @@
use crate::component_prelude::*;
use gpui::{AnyElement, AnyView, DefiniteLength};
use ui_macros::RegisterComponent;
use crate::traits::animation_ext::CommonAnimationExt;
use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, Label};
use crate::{
Color, DynamicSpacing, ElevationIndex, KeyBinding, KeybindingPosition, TintColor, prelude::*,
};
/// An element that creates a button with a label and optional icons.
///
/// Common buttons:
/// - Label, Icon + Label: [`Button`] (this component)
/// - Icon only: [`IconButton`]
/// - Custom: [`ButtonLike`]
///
/// To create a more complex button than what the [`Button`] or [`IconButton`] components provide, use
/// [`ButtonLike`] directly.
///
/// # Examples
///
/// **A button with a label**, is typically used in scenarios such as a form, where the button's label
/// indicates what action will be performed when the button is clicked.
///
/// ```
/// use ui::prelude::*;
///
/// Button::new("button_id", "Click me!")
/// .on_click(|event, window, cx| {
/// // Handle click event
/// });
/// ```
///
/// **A toggleable button**, is typically used in scenarios such as a toolbar,
/// where the button's state indicates whether a feature is enabled or not, or
/// a trigger for a popover menu, where clicking the button toggles the visibility of the menu.
///
/// ```
/// use ui::prelude::*;
///
/// Button::new("button_id", "Click me!")
/// .start_icon(Icon::new(IconName::Check))
/// .toggle_state(true)
/// .on_click(|event, window, cx| {
/// // Handle click event
/// });
/// ```
///
/// To change the style of the button when it is selected use the [`selected_style`][Button::selected_style] method.
///
/// ```
/// use ui::prelude::*;
/// use ui::TintColor;
///
/// Button::new("button_id", "Click me!")
/// .toggle_state(true)
/// .selected_style(ButtonStyle::Tinted(TintColor::Accent))
/// .on_click(|event, window, cx| {
/// // Handle click event
/// });
/// ```
/// This will create a button with a blue tinted background when selected.
///
/// **A full-width button**, is typically used in scenarios such as the bottom of a modal or form, where it occupies the entire width of its container.
/// The button's content, including text and icons, is centered by default.
///
/// ```
/// use ui::prelude::*;
///
/// let button = Button::new("button_id", "Click me!")
/// .full_width()
/// .on_click(|event, window, cx| {
/// // Handle click event
/// });
/// ```
///
#[derive(IntoElement, Documented, RegisterComponent)]
pub struct Button {
base: ButtonLike,
label: SharedString,
label_color: Option<Color>,
label_size: Option<LabelSize>,
selected_label: Option<SharedString>,
selected_label_color: Option<Color>,
start_icon: Option<Icon>,
end_icon: Option<Icon>,
key_binding: Option<KeyBinding>,
key_binding_position: KeybindingPosition,
alpha: Option<f32>,
truncate: bool,
loading: bool,
}
impl Button {
/// Creates a new [`Button`] with a specified identifier and label.
///
/// This is the primary constructor for a [`Button`] component. It initializes
/// the button with the provided identifier and label text, setting all other
/// properties to their default values, which can be customized using the
/// builder pattern methods provided by this struct.
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,
selected_label_color: None,
start_icon: None,
end_icon: None,
key_binding: None,
key_binding_position: KeybindingPosition::default(),
alpha: None,
truncate: false,
loading: false,
}
}
/// Sets the color of the button's label.
pub fn color(mut self, label_color: impl Into<Option<Color>>) -> Self {
self.label_color = label_color.into();
self
}
/// Defines the size of the button's label.
pub fn label_size(mut self, label_size: impl Into<Option<LabelSize>>) -> Self {
self.label_size = label_size.into();
self
}
/// Sets the label used when the button is in a selected state.
pub fn selected_label<L: Into<SharedString>>(mut self, label: impl Into<Option<L>>) -> Self {
self.selected_label = label.into().map(Into::into);
self
}
/// Sets the label color used when the button is in a selected state.
pub fn selected_label_color(mut self, color: impl Into<Option<Color>>) -> Self {
self.selected_label_color = color.into();
self
}
/// Sets an icon to display at the start (left) of the button label.
///
/// The icon's color will be overridden to `Color::Disabled` when the button is disabled.
pub fn start_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
self.start_icon = icon.into();
self
}
/// Sets an icon to display at the end (right) of the button label.
///
/// The icon's color will be overridden to `Color::Disabled` when the button is disabled.
pub fn end_icon(mut self, icon: impl Into<Option<Icon>>) -> Self {
self.end_icon = icon.into();
self
}
/// Display the keybinding that triggers the button action.
pub fn key_binding(mut self, key_binding: impl Into<Option<KeyBinding>>) -> Self {
self.key_binding = key_binding.into();
self
}
/// Sets the position of the keybinding relative to the button label.
///
/// This method allows you to specify where the keybinding should be displayed
/// in relation to the button's label.
pub fn key_binding_position(mut self, position: KeybindingPosition) -> Self {
self.key_binding_position = position;
self
}
/// Sets the alpha property of the color of label.
pub fn alpha(mut self, alpha: f32) -> Self {
self.alpha = Some(alpha);
self
}
/// Truncates overflowing labels with an ellipsis (`…`) if needed.
///
/// Buttons with static labels should _never_ be truncated, ensure
/// this is only used when the label is dynamic and may overflow.
pub fn truncate(mut self, truncate: bool) -> Self {
self.truncate = truncate;
self
}
/// Displays a rotating loading spinner in place of the `start_icon`.
///
/// When `loading` is `true`, any `start_icon` is ignored. and a rotating
/// loading spinner is shown instead.
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
}
impl Toggleable for Button {
/// Sets the selected state of the button.
///
/// # Examples
///
/// Create a toggleable button that changes appearance when selected:
///
/// ```
/// use ui::prelude::*;
/// use ui::TintColor;
///
/// let selected = true;
///
/// Button::new("toggle_button", "Toggle Me")
/// .start_icon(Icon::new(IconName::Check))
/// .toggle_state(selected)
/// .selected_style(ButtonStyle::Tinted(TintColor::Accent))
/// .on_click(|event, window, cx| {
/// // Toggle the selected state
/// });
/// ```
fn toggle_state(mut self, selected: bool) -> Self {
self.base = self.base.toggle_state(selected);
self
}
}
impl SelectableButton for Button {
/// Sets the style for the button in a selected state.
///
/// # Examples
///
/// Customize the selected appearance of a button:
///
/// ```
/// use ui::prelude::*;
/// use ui::TintColor;
///
/// Button::new("styled_button", "Styled Button")
/// .toggle_state(true)
/// .selected_style(ButtonStyle::Tinted(TintColor::Accent));
/// ```
fn selected_style(mut self, style: ButtonStyle) -> Self {
self.base = self.base.selected_style(style);
self
}
}
impl Disableable for Button {
/// Disables the button, preventing interaction and changing its appearance.
///
/// When disabled, the button's icon and label will use `Color::Disabled`.
///
/// # Examples
///
/// Create a disabled button:
///
/// ```
/// use ui::prelude::*;
///
/// Button::new("disabled_button", "Can't Click Me")
/// .disabled(true);
/// ```
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 Window, &mut App) + 'static,
) -> Self {
self.base = self.base.on_click(handler);
self
}
fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
self.base = self.base.cursor_style(cursor_style);
self
}
}
impl FixedWidth for Button {
/// Sets a fixed width for the button.
///
/// # Examples
///
/// Create a button with a fixed width of 100 pixels:
///
/// ```
/// use ui::prelude::*;
///
/// Button::new("fixed_width_button", "Fixed Width")
/// .width(px(100.0));
/// ```
fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.base = self.base.width(width);
self
}
/// Makes the button take up the full width of its container.
///
/// # Examples
///
/// Create a button that takes up the full width of its container:
///
/// ```
/// use ui::prelude::*;
///
/// Button::new("full_width_button", "Full Width")
/// .full_width();
/// ```
fn full_width(mut self) -> Self {
self.base = self.base.full_width();
self
}
}
impl ButtonCommon for Button {
fn id(&self) -> &ElementId {
self.base.id()
}
/// Sets the visual style of the button.
fn style(mut self, style: ButtonStyle) -> Self {
self.base = self.base.style(style);
self
}
/// Sets the size of the button.
fn size(mut self, size: ButtonSize) -> Self {
self.base = self.base.size(size);
self
}
/// Sets a tooltip that appears on hover.
///
/// # Examples
///
/// Add a tooltip to a button:
///
/// ```
/// use ui::{Tooltip, prelude::*};
///
/// Button::new("tooltip_button", "Hover Me")
/// .tooltip(Tooltip::text("This is a tooltip"));
/// ```
fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.base = self.base.tooltip(tooltip);
self
}
fn tab_index(mut self, tab_index: impl Into<isize>) -> Self {
self.base = self.base.tab_index(tab_index);
self
}
fn layer(mut self, elevation: ElevationIndex) -> Self {
self.base = self.base.layer(elevation);
self
}
fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
self.base = self.base.track_focus(focus_handle);
self
}
}
impl RenderOnce for Button {
#[allow(refining_impl_trait)]
fn render(self, _window: &mut Window, cx: &mut App) -> 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 {
self.selected_label_color.unwrap_or(Color::Selected)
} else {
self.label_color.unwrap_or_default()
};
self.base.child(
h_flex()
.when(self.truncate, |this| this.min_w_0().overflow_hidden())
.gap(DynamicSpacing::Base04.rems(cx))
.when_else(
self.loading,
|this| {
this.child(
Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.color(Color::Muted)
.with_rotate_animation(2),
)
},
|this| {
this.when_some(self.start_icon, |this, icon| {
this.child(if is_disabled {
icon.color(Color::Disabled)
} else {
icon
})
})
},
)
.child(
h_flex()
.when(self.truncate, |this| this.min_w_0().overflow_hidden())
.when(
self.key_binding_position == KeybindingPosition::Start,
|this| this.flex_row_reverse(),
)
.gap(DynamicSpacing::Base06.rems(cx))
.justify_between()
.child(
Label::new(label)
.color(label_color)
.size(self.label_size.unwrap_or_default())
.when_some(self.alpha, |this, alpha| this.alpha(alpha))
.when(self.truncate, |this| this.truncate()),
)
.children(self.key_binding),
)
.when_some(self.end_icon, |this, icon| {
this.child(if is_disabled {
icon.color(Color::Disabled)
} else {
icon
})
}),
)
}
}
impl Component for Button {
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn sort_name() -> &'static str {
"ButtonA"
}
fn description() -> Option<&'static str> {
Some("A button triggers an event or action.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Button Styles",
vec![
single_example(
"Default",
Button::new("default", "Default").into_any_element(),
),
single_example(
"Filled",
Button::new("filled", "Filled")
.style(ButtonStyle::Filled)
.into_any_element(),
),
single_example(
"Subtle",
Button::new("outline", "Subtle")
.style(ButtonStyle::Subtle)
.into_any_element(),
),
single_example(
"Tinted",
Button::new("tinted_accent_style", "Accent")
.style(ButtonStyle::Tinted(TintColor::Accent))
.into_any_element(),
),
single_example(
"Transparent",
Button::new("transparent", "Transparent")
.style(ButtonStyle::Transparent)
.into_any_element(),
),
],
),
example_group_with_title(
"Tint Styles",
vec![
single_example(
"Accent",
Button::new("tinted_accent", "Accent")
.style(ButtonStyle::Tinted(TintColor::Accent))
.into_any_element(),
),
single_example(
"Error",
Button::new("tinted_negative", "Error")
.style(ButtonStyle::Tinted(TintColor::Error))
.into_any_element(),
),
single_example(
"Warning",
Button::new("tinted_warning", "Warning")
.style(ButtonStyle::Tinted(TintColor::Warning))
.into_any_element(),
),
single_example(
"Success",
Button::new("tinted_positive", "Success")
.style(ButtonStyle::Tinted(TintColor::Success))
.into_any_element(),
),
],
),
example_group_with_title(
"Special States",
vec![
single_example(
"Default",
Button::new("default_state", "Default").into_any_element(),
),
single_example(
"Disabled",
Button::new("disabled", "Disabled")
.disabled(true)
.into_any_element(),
),
single_example(
"Selected",
Button::new("selected", "Selected")
.toggle_state(true)
.into_any_element(),
),
],
),
example_group_with_title(
"Buttons with Icons",
vec![
single_example(
"Start Icon",
Button::new("icon_start", "Start Icon")
.start_icon(Icon::new(IconName::Check))
.into_any_element(),
),
single_example(
"End Icon",
Button::new("icon_end", "End Icon")
.end_icon(Icon::new(IconName::Check))
.into_any_element(),
),
single_example(
"Both Icons",
Button::new("both_icons", "Both Icons")
.start_icon(Icon::new(IconName::Check))
.end_icon(Icon::new(IconName::ChevronDown))
.into_any_element(),
),
single_example(
"Icon Color",
Button::new("icon_color", "Icon Color")
.start_icon(Icon::new(IconName::Check).color(Color::Accent))
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,880 @@
use documented::Documented;
use gpui::{
AnyElement, AnyView, ClickEvent, CursorStyle, DefiniteLength, FocusHandle, Hsla, MouseButton,
MouseClickEvent, MouseDownEvent, MouseUpEvent, Rems, StyleRefinement, relative,
transparent_black,
};
use smallvec::SmallVec;
use crate::{DynamicSpacing, ElevationIndex, prelude::*};
/// A trait for buttons that can be Selected. Enables setting the [`ButtonStyle`] of a button when it is selected.
pub trait SelectableButton: Toggleable {
fn selected_style(self, style: ButtonStyle) -> Self;
}
/// A common set of traits all buttons must implement.
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 Window, &mut App) -> AnyView + 'static) -> Self;
fn tab_index(self, tab_index: impl Into<isize>) -> Self;
fn layer(self, elevation: ElevationIndex) -> Self;
fn track_focus(self, focus_handle: &FocusHandle) -> Self;
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum IconPosition {
#[default]
Start,
End,
}
#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum KeybindingPosition {
Start,
#[default]
End,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum TintColor {
#[default]
Accent,
Error,
Warning,
Success,
}
impl TintColor {
fn button_like_style(self, cx: &mut App) -> ButtonLikeStyles {
match self {
TintColor::Accent => ButtonLikeStyles {
background: cx.theme().status().info_background,
border_color: cx.theme().status().info_border,
label_color: cx.theme().colors().text,
icon_color: cx.theme().colors().text,
},
TintColor::Error => ButtonLikeStyles {
background: cx.theme().status().error_background,
border_color: cx.theme().status().error_border,
label_color: cx.theme().colors().text,
icon_color: cx.theme().colors().text,
},
TintColor::Warning => ButtonLikeStyles {
background: cx.theme().status().warning_background,
border_color: cx.theme().status().warning_border,
label_color: cx.theme().colors().text,
icon_color: cx.theme().colors().text,
},
TintColor::Success => ButtonLikeStyles {
background: cx.theme().status().success_background,
border_color: cx.theme().status().success_border,
label_color: cx.theme().colors().text,
icon_color: cx.theme().colors().text,
},
}
}
}
impl From<TintColor> for Color {
fn from(tint: TintColor) -> Self {
match tint {
TintColor::Accent => Color::Accent,
TintColor::Error => Color::Error,
TintColor::Warning => Color::Warning,
TintColor::Success => Color::Success,
}
}
}
// Used to go from ButtonStyle -> Color through tint colors.
impl From<ButtonStyle> for Color {
fn from(style: ButtonStyle) -> Self {
match style {
ButtonStyle::Tinted(tint) => tint.into(),
_ => Color::Default,
}
}
}
/// The visual appearance of a button.
#[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,
/// Used to emphasize a button in some way, like a selected state, or a semantic
/// coloring like an error or success button.
Tinted(TintColor),
/// Usually used as a secondary action that should have more emphasis than
/// a fully transparent button.
Outlined,
/// A more de-emphasized version of the outlined button.
OutlinedGhost,
/// Like [`ButtonStyle::Outlined`], but with a caller-provided border color.
OutlinedCustom(Hsla),
/// 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 foreground color on hover and active states.
///
/// TODO: Better docs for this.
Transparent,
}
/// Rounding for a button that may have straight edges.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub(crate) struct ButtonLikeRounding {
/// Top-left corner rounding
pub top_left: bool,
/// Top-right corner rounding
pub top_right: bool,
/// Bottom-right corner rounding
pub bottom_right: bool,
/// Bottom-left corner rounding
pub bottom_left: bool,
}
impl ButtonLikeRounding {
pub const ALL: Self = Self {
top_left: true,
top_right: true,
bottom_right: true,
bottom_left: true,
};
pub const LEFT: Self = Self {
top_left: true,
top_right: false,
bottom_right: false,
bottom_left: true,
};
pub const RIGHT: Self = Self {
top_left: false,
top_right: true,
bottom_right: true,
bottom_left: false,
};
}
#[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,
}
fn element_bg_from_elevation(elevation: Option<ElevationIndex>, cx: &mut App) -> Hsla {
match elevation {
Some(ElevationIndex::Background) => cx.theme().colors().element_background,
Some(ElevationIndex::ElevatedSurface) => cx.theme().colors().elevated_surface_background,
Some(ElevationIndex::Surface) => cx.theme().colors().surface_background,
Some(ElevationIndex::ModalSurface) => cx.theme().colors().background,
_ => cx.theme().colors().element_background,
}
}
impl ButtonStyle {
pub(crate) fn enabled(
self,
elevation: Option<ElevationIndex>,
cx: &mut App,
) -> ButtonLikeStyles {
match self {
ButtonStyle::Filled => ButtonLikeStyles {
background: element_bg_from_elevation(elevation, cx),
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::Tinted(tint) => tint.button_like_style(cx),
ButtonStyle::Outlined => ButtonLikeStyles {
background: element_bg_from_elevation(elevation, cx),
border_color: cx.theme().colors().border_variant,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border_variant,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
background: transparent_black(),
border_color,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
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,
elevation: Option<ElevationIndex>,
cx: &mut App,
) -> ButtonLikeStyles {
match self {
ButtonStyle::Filled => {
let mut filled_background = element_bg_from_elevation(elevation, cx);
filled_background.fade_out(0.5);
ButtonLikeStyles {
background: filled_background,
border_color: transparent_black(),
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
}
}
ButtonStyle::Tinted(tint) => {
let mut styles = tint.button_like_style(cx);
let theme = cx.theme();
styles.background = theme.darken(styles.background, 0.05, 0.2);
styles
}
ButtonStyle::Outlined => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_hover,
border_color: cx.theme().colors().border,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_hover,
border_color: cx.theme().colors().border,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_hover,
border_color,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
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 App) -> 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(tint) => tint.button_like_style(cx),
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::Outlined => ButtonLikeStyles {
background: cx.theme().colors().element_active,
border_color: cx.theme().colors().border_variant,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border_variant,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
background: cx.theme().colors().element_active,
border_color,
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, window: &mut Window, cx: &mut App) -> 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(tint) => tint.button_like_style(cx),
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::Outlined => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_background,
border_color: cx.theme().colors().border,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedCustom(border_color) => ButtonLikeStyles {
background: cx.theme().colors().ghost_element_background,
border_color,
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,
elevation: Option<ElevationIndex>,
window: &mut Window,
cx: &mut App,
) -> 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(tint) => tint.button_like_style(cx),
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::Outlined => ButtonLikeStyles {
background: cx.theme().colors().element_disabled,
border_color: cx.theme().colors().border_disabled,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedGhost => ButtonLikeStyles {
background: transparent_black(),
border_color: cx.theme().colors().border_disabled,
label_color: Color::Default.color(cx),
icon_color: Color::Default.color(cx),
},
ButtonStyle::OutlinedCustom(_) => ButtonLikeStyles {
background: cx.theme().colors().element_disabled,
border_color: cx.theme().colors().border_disabled,
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::Disabled.color(cx),
icon_color: Color::Disabled.color(cx),
},
}
}
}
/// The height of a button.
///
/// Can also be used to size non-button elements to align with [`Button`]s.
#[derive(Default, PartialEq, Clone, Copy)]
pub enum ButtonSize {
Large,
Medium,
#[default]
Default,
Compact,
None,
}
impl ButtonSize {
pub fn rems(self) -> Rems {
match self {
ButtonSize::Large => rems_from_px(32.),
ButtonSize::Medium => rems_from_px(28.),
ButtonSize::Default => rems_from_px(22.),
ButtonSize::Compact => rems_from_px(18.),
ButtonSize::None => rems_from_px(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, Documented, RegisterComponent)]
pub struct ButtonLike {
pub(super) base: Div,
id: ElementId,
pub(super) style: ButtonStyle,
pub(super) disabled: bool,
pub(super) selected: bool,
pub(super) selected_style: Option<ButtonStyle>,
pub(super) width: Option<DefiniteLength>,
pub(super) height: Option<DefiniteLength>,
pub(super) layer: Option<ElevationIndex>,
tab_index: Option<isize>,
size: ButtonSize,
rounding: Option<ButtonLikeRounding>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
hoverable_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
cursor_style: CursorStyle,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_right_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
children: SmallVec<[AnyElement; 2]>,
focus_handle: Option<FocusHandle>,
}
impl ButtonLike {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div(),
id: id.into(),
style: ButtonStyle::default(),
disabled: false,
selected: false,
selected_style: None,
width: None,
height: None,
size: ButtonSize::Default,
rounding: Some(ButtonLikeRounding::ALL),
tooltip: None,
hoverable_tooltip: None,
children: SmallVec::new(),
cursor_style: CursorStyle::PointingHand,
on_click: None,
on_right_click: None,
layer: None,
tab_index: None,
focus_handle: None,
}
}
pub fn new_rounded_left(id: impl Into<ElementId>) -> Self {
Self::new(id).rounding(ButtonLikeRounding::LEFT)
}
pub fn new_rounded_right(id: impl Into<ElementId>) -> Self {
Self::new(id).rounding(ButtonLikeRounding::RIGHT)
}
pub fn new_rounded_all(id: impl Into<ElementId>) -> Self {
Self::new(id).rounding(ButtonLikeRounding::ALL)
}
pub fn opacity(mut self, opacity: f32) -> Self {
self.base = self.base.opacity(opacity);
self
}
pub fn height(mut self, height: DefiniteLength) -> Self {
self.height = Some(height);
self
}
pub(crate) fn rounding(mut self, rounding: impl Into<Option<ButtonLikeRounding>>) -> Self {
self.rounding = rounding.into();
self
}
pub fn on_right_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_right_click = Some(Box::new(handler));
self
}
pub fn hoverable_tooltip(
mut self,
tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
) -> Self {
self.hoverable_tooltip = Some(Box::new(tooltip));
self
}
}
impl Disableable for ButtonLike {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Toggleable for ButtonLike {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl SelectableButton for ButtonLike {
fn selected_style(mut self, style: ButtonStyle) -> Self {
self.selected_style = Some(style);
self
}
}
impl Clickable for ButtonLike {
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
fn cursor_style(mut self, cursor_style: CursorStyle) -> Self {
self.cursor_style = cursor_style;
self
}
}
impl FixedWidth for ButtonLike {
fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.width = Some(width.into());
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 Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Box::new(tooltip));
self
}
fn tab_index(mut self, tab_index: impl Into<isize>) -> Self {
self.tab_index = Some(tab_index.into());
self
}
fn layer(mut self, elevation: ElevationIndex) -> Self {
self.layer = Some(elevation);
self
}
fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
self.focus_handle = Some(focus_handle.clone());
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 extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ButtonLike {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let style = self
.selected_style
.filter(|_| self.selected)
.unwrap_or(self.style);
let is_outlined = matches!(
self.style,
ButtonStyle::Outlined | ButtonStyle::OutlinedGhost | ButtonStyle::OutlinedCustom(_)
);
self.base
.h_flex()
.id(self.id.clone())
.when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index))
.when_some(self.focus_handle, |this, focus_handle| {
this.track_focus(&focus_handle)
})
.font_ui(cx)
.group("")
.flex_none()
.h(self.height.unwrap_or(self.size.rems().into()))
.when_some(self.width, |this, width| {
this.w(width).justify_center().text_center()
})
.when(is_outlined, |this| this.border_1())
.when_some(self.rounding, |this, rounding| {
this.when(rounding.top_left, |this| this.rounded_tl_sm())
.when(rounding.top_right, |this| this.rounded_tr_sm())
.when(rounding.bottom_right, |this| this.rounded_br_sm())
.when(rounding.bottom_left, |this| this.rounded_bl_sm())
})
.gap(DynamicSpacing::Base04.rems(cx))
.map(|this| match self.size {
ButtonSize::Large | ButtonSize::Medium => this.px(DynamicSpacing::Base08.rems(cx)),
ButtonSize::Default | ButtonSize::Compact => {
this.px(DynamicSpacing::Base04.rems(cx))
}
ButtonSize::None => this.px_px(),
})
.border_color(style.enabled(self.layer, cx).border_color)
.bg(style.enabled(self.layer, cx).background)
.when(self.disabled, |this| {
if self.cursor_style == CursorStyle::PointingHand {
this.cursor_not_allowed()
} else {
this.cursor(self.cursor_style)
}
})
.when(!self.disabled, |this| {
let hovered_style = style.hovered(self.layer, cx);
let focus_color =
|refinement: StyleRefinement| refinement.bg(hovered_style.background);
this.cursor(self.cursor_style)
.hover(focus_color)
.map(|this| {
if is_outlined {
this.focus_visible(|s| {
s.border_color(cx.theme().colors().border_focused)
})
} else {
this.focus_visible(focus_color)
}
})
.active(|active| active.bg(style.active(cx).background))
})
.when_some(
self.on_right_click.filter(|_| !self.disabled),
|this, on_right_click| {
this.on_mouse_down(MouseButton::Right, |_event, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.on_mouse_up(
MouseButton::Right,
move |event, window, cx| {
cx.stop_propagation();
let click_event = ClickEvent::Mouse(MouseClickEvent {
down: MouseDownEvent {
button: MouseButton::Right,
position: event.position,
modifiers: event.modifiers,
click_count: 1,
first_mouse: false,
},
up: MouseUpEvent {
button: MouseButton::Right,
position: event.position,
modifiers: event.modifiers,
click_count: 1,
},
});
(on_right_click)(&click_event, window, cx)
},
)
},
)
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| {
this.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |event, window, cx| {
cx.stop_propagation();
(on_click)(event, window, cx)
})
},
)
.when_some(self.tooltip, |this, tooltip| {
this.tooltip(move |window, cx| tooltip(window, cx))
})
.when_some(self.hoverable_tooltip, |this, tooltip| {
this.hoverable_tooltip(move |window, cx| tooltip(window, cx))
})
.children(self.children)
}
}
impl Component for ButtonLike {
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn sort_name() -> &'static str {
// ButtonLike should be at the bottom of the button list
"ButtonZ"
}
fn description() -> Option<&'static str> {
Some(ButtonLike::DOCS)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group(vec![
single_example(
"Default",
ButtonLike::new("default")
.child(Label::new("Default"))
.into_any_element(),
),
single_example(
"Filled",
ButtonLike::new("filled")
.style(ButtonStyle::Filled)
.child(Label::new("Filled"))
.into_any_element(),
),
single_example(
"Subtle",
ButtonLike::new("outline")
.style(ButtonStyle::Subtle)
.child(Label::new("Subtle"))
.into_any_element(),
),
single_example(
"Tinted",
ButtonLike::new("tinted_accent_style")
.style(ButtonStyle::Tinted(TintColor::Accent))
.child(Label::new("Accent"))
.into_any_element(),
),
single_example(
"Transparent",
ButtonLike::new("transparent")
.style(ButtonStyle::Transparent)
.child(Label::new("Transparent"))
.into_any_element(),
),
]),
example_group_with_title(
"Button Group Constructors",
vec![
single_example(
"Left Rounded",
ButtonLike::new_rounded_left("left_rounded")
.child(Label::new("Left Rounded"))
.style(ButtonStyle::Filled)
.into_any_element(),
),
single_example(
"Right Rounded",
ButtonLike::new_rounded_right("right_rounded")
.child(Label::new("Right Rounded"))
.style(ButtonStyle::Filled)
.into_any_element(),
),
single_example(
"Button Group",
h_flex()
.gap_px()
.child(
ButtonLike::new_rounded_left("bg_left")
.child(Label::new("Left"))
.style(ButtonStyle::Filled),
)
.child(
ButtonLike::new_rounded_right("bg_right")
.child(Label::new("Right"))
.style(ButtonStyle::Filled),
)
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,102 @@
use gpui::{IntoElement, Window, prelude::*};
use crate::{ButtonLike, prelude::*};
/// A button that takes an underline to look like a regular web link.
/// It also contains an arrow icon to communicate the link takes you out of Zed.
///
/// # Usage Example
///
/// ```
/// use ui::ButtonLink;
///
/// let button_link = ButtonLink::new("Click me", "https://example.com");
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct ButtonLink {
label: SharedString,
label_size: LabelSize,
label_color: Color,
link: String,
no_icon: bool,
}
impl ButtonLink {
pub fn new(label: impl Into<SharedString>, link: impl Into<String>) -> Self {
Self {
link: link.into(),
label: label.into(),
label_size: LabelSize::Default,
label_color: Color::Default,
no_icon: false,
}
}
pub fn no_icon(mut self, no_icon: bool) -> Self {
self.no_icon = no_icon;
self
}
pub fn label_size(mut self, label_size: LabelSize) -> Self {
self.label_size = label_size;
self
}
pub fn label_color(mut self, label_color: Color) -> Self {
self.label_color = label_color;
self
}
}
impl RenderOnce for ButtonLink {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let id = format!("{}-{}", self.label, self.link);
ButtonLike::new(id)
.size(ButtonSize::None)
.child(
h_flex()
.gap_0p5()
.child(
Label::new(self.label)
.size(self.label_size)
.color(self.label_color)
.underline(),
)
.when(!self.no_icon, |this| {
this.child(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::Small)
.color(Color::Muted),
)
}),
)
.on_click(move |_, _, cx| cx.open_url(&self.link))
.into_any_element()
}
}
impl Component for ButtonLink {
fn scope() -> ComponentScope {
ComponentScope::Navigation
}
fn description() -> Option<&'static str> {
Some("A button that opens a URL.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.child(
example_group(vec![single_example(
"Simple",
ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(),
)])
.vertical(),
)
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,200 @@
use std::time::{Duration, Instant};
use gpui::{
AnyElement, App, ClipboardItem, Context, ElementId, Entity, IntoElement, ParentElement,
RenderOnce, Styled, Window,
};
use crate::{Tooltip, prelude::*};
const COPIED_STATE_DURATION: Duration = Duration::from_secs(2);
struct CopyButtonState {
copied_at: Option<Instant>,
}
impl CopyButtonState {
fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
Self { copied_at: None }
}
fn is_copied(&self) -> bool {
self.copied_at
.map(|t| t.elapsed() < COPIED_STATE_DURATION)
.unwrap_or(false)
}
fn mark_copied(&mut self) {
self.copied_at = Some(Instant::now());
}
}
#[derive(IntoElement, RegisterComponent)]
pub struct CopyButton {
id: ElementId,
message: SharedString,
icon_size: IconSize,
disabled: bool,
tooltip_label: SharedString,
visible_on_hover: Option<SharedString>,
custom_on_click: Option<Box<dyn Fn(&mut Window, &mut App) + 'static>>,
}
impl CopyButton {
pub fn new(id: impl Into<ElementId>, message: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
message: message.into(),
icon_size: IconSize::Small,
disabled: false,
tooltip_label: "Copy".into(),
visible_on_hover: None,
custom_on_click: None,
}
}
pub fn icon_size(mut self, icon_size: IconSize) -> Self {
self.icon_size = icon_size;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn tooltip_label(mut self, tooltip_label: impl Into<SharedString>) -> Self {
self.tooltip_label = tooltip_label.into();
self
}
pub fn visible_on_hover(mut self, visible_on_hover: impl Into<SharedString>) -> Self {
self.visible_on_hover = Some(visible_on_hover.into());
self
}
pub fn custom_on_click(
mut self,
custom_on_click: impl Fn(&mut Window, &mut App) + 'static,
) -> Self {
self.custom_on_click = Some(Box::new(custom_on_click));
self
}
}
impl RenderOnce for CopyButton {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
let message = self.message;
let custom_on_click = self.custom_on_click;
let visible_on_hover = self.visible_on_hover;
let state: Entity<CopyButtonState> =
window.use_keyed_state(id.clone(), cx, CopyButtonState::new);
let is_copied = state.read(cx).is_copied();
let (icon, color, tooltip) = if is_copied {
(IconName::Check, Color::Success, "Copied!".into())
} else {
(IconName::Copy, Color::Muted, self.tooltip_label)
};
let button = IconButton::new(id, icon)
.icon_color(color)
.icon_size(self.icon_size)
.disabled(self.disabled)
.tooltip(Tooltip::text(tooltip))
.on_click(move |_, window, cx| {
state.update(cx, |state, _cx| {
state.mark_copied();
});
if let Some(custom_on_click) = custom_on_click.as_ref() {
(custom_on_click)(window, cx);
} else {
cx.stop_propagation();
cx.write_to_clipboard(ClipboardItem::new_string(message.to_string()));
}
let state_id = state.entity_id();
cx.spawn(async move |cx| {
cx.background_executor().timer(COPIED_STATE_DURATION).await;
cx.update(|cx| {
cx.notify(state_id);
})
})
.detach();
});
if let Some(visible_on_hover) = visible_on_hover {
button.visible_on_hover(visible_on_hover)
} else {
button
}
}
}
impl Component for CopyButton {
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn description() -> Option<&'static str> {
Some("An icon button that encapsulates the logic to copy a string into the clipboard.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let label_text = "Here's an example label";
let examples = vec![
single_example(
"Default",
h_flex()
.gap_1()
.child(Label::new(label_text).size(LabelSize::Small))
.child(CopyButton::new("preview-default", label_text))
.into_any_element(),
),
single_example(
"Multiple Icon Sizes",
h_flex()
.gap_1()
.child(Label::new(label_text).size(LabelSize::Small))
.child(
CopyButton::new("preview-xsmall", label_text).icon_size(IconSize::XSmall),
)
.child(
CopyButton::new("preview-medium", label_text).icon_size(IconSize::Medium),
)
.child(
CopyButton::new("preview-xlarge", label_text).icon_size(IconSize::XLarge),
)
.into_any_element(),
),
single_example(
"Custom Tooltip Label",
h_flex()
.gap_1()
.child(Label::new(label_text).size(LabelSize::Small))
.child(
CopyButton::new("preview-tooltip", label_text)
.tooltip_label("Custom tooltip label"),
)
.into_any_element(),
),
single_example(
"Visible On Hover",
h_flex()
.group("container")
.gap_1()
.child(Label::new(label_text).size(LabelSize::Small))
.child(
CopyButton::new("preview-hover", label_text).visible_on_hover("container"),
)
.into_any_element(),
),
];
Some(example_group(examples).vertical().into_any_element())
}
}

View File

@@ -0,0 +1,399 @@
use gpui::{AnyView, DefiniteLength, Hsla};
use super::button_like::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle};
use crate::{
ElevationIndex, Icon, IconWithIndicator, Indicator, SelectableButton, TintColor, prelude::*,
};
use crate::{IconName, IconSize};
/// The shape of an [`IconButton`].
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum IconButtonShape {
Square,
Wide,
}
#[derive(IntoElement, RegisterComponent)]
pub struct IconButton {
base: ButtonLike,
shape: IconButtonShape,
icon: IconName,
icon_size: IconSize,
icon_color: Color,
selected_icon: Option<IconName>,
selected_icon_color: Option<Color>,
selected_style: Option<ButtonStyle>,
indicator: Option<Indicator>,
indicator_border_color: Option<Hsla>,
alpha: Option<f32>,
}
impl IconButton {
pub fn new(id: impl Into<ElementId>, icon: IconName) -> Self {
let mut this = Self {
base: ButtonLike::new(id),
shape: IconButtonShape::Wide,
icon,
icon_size: IconSize::default(),
icon_color: Color::Default,
selected_icon: None,
selected_icon_color: None,
selected_style: None,
indicator: None,
indicator_border_color: None,
alpha: None,
};
this.base.base = this.base.base.debug_selector(|| format!("ICON-{:?}", icon));
this
}
pub fn shape(mut self, shape: IconButtonShape) -> Self {
self.shape = shape;
self
}
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 alpha(mut self, alpha: f32) -> Self {
self.alpha = Some(alpha);
self
}
pub fn selected_icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
self.selected_icon = icon.into();
self
}
pub fn on_right_click(
mut self,
handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.base = self.base.on_right_click(handler);
self
}
/// Sets the icon color used when the button is in a selected state.
pub fn selected_icon_color(mut self, color: impl Into<Option<Color>>) -> Self {
self.selected_icon_color = color.into();
self
}
pub fn indicator(mut self, indicator: Indicator) -> Self {
self.indicator = Some(indicator);
self
}
pub fn indicator_border_color(mut self, color: Option<Hsla>) -> Self {
self.indicator_border_color = color;
self
}
}
impl Disableable for IconButton {
fn disabled(mut self, disabled: bool) -> Self {
self.base = self.base.disabled(disabled);
self
}
}
impl Toggleable for IconButton {
fn toggle_state(mut self, selected: bool) -> Self {
self.base = self.base.toggle_state(selected);
self
}
}
impl SelectableButton for IconButton {
fn selected_style(mut self, style: ButtonStyle) -> Self {
self.selected_style = Some(style);
self.base = self.base.selected_style(style);
self
}
}
impl Clickable for IconButton {
fn on_click(
mut self,
handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.base = self.base.on_click(handler);
self
}
fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
self.base = self.base.cursor_style(cursor_style);
self
}
}
impl FixedWidth for IconButton {
fn width(mut self, width: impl Into<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 Window, &mut App) -> AnyView + 'static) -> Self {
self.base = self.base.tooltip(tooltip);
self
}
fn tab_index(mut self, tab_index: impl Into<isize>) -> Self {
self.base = self.base.tab_index(tab_index);
self
}
fn layer(mut self, elevation: ElevationIndex) -> Self {
self.base = self.base.layer(elevation);
self
}
fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
self.base = self.base.track_focus(focus_handle);
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 {
#[allow(refining_impl_trait)]
fn render(self, window: &mut Window, cx: &mut App) -> ButtonLike {
let is_disabled = self.base.disabled;
let is_selected = self.base.selected;
let icon = self
.selected_icon
.filter(|_| is_selected)
.unwrap_or(self.icon);
let icon_color = if is_disabled {
Color::Disabled
} else if self.selected_style.is_some() && is_selected {
self.selected_style.unwrap().into()
} else if is_selected {
self.selected_icon_color.unwrap_or(Color::Selected)
} else {
let base_color = self.icon_color.color(cx);
Color::Custom(base_color.opacity(self.alpha.unwrap_or(1.0)))
};
let icon_element = Icon::new(icon).size(self.icon_size).color(icon_color);
self.base
.map(|this| match self.shape {
IconButtonShape::Square => {
let size = self.icon_size.square(window, cx);
this.width(size).height(size.into())
}
IconButtonShape::Wide => this,
})
.child(match self.indicator {
Some(indicator) => IconWithIndicator::new(icon_element, Some(indicator))
.indicator_border_color(self.indicator_border_color)
.into_any_element(),
None => icon_element.into_any_element(),
})
}
}
impl Component for IconButton {
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn sort_name() -> &'static str {
"ButtonB"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Icon Button Styles",
vec![
single_example(
"Default",
IconButton::new("default", IconName::Check)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"Filled",
IconButton::new("filled", IconName::Check)
.layer(ElevationIndex::Background)
.style(ButtonStyle::Filled)
.into_any_element(),
),
single_example(
"Subtle",
IconButton::new("subtle", IconName::Check)
.layer(ElevationIndex::Background)
.style(ButtonStyle::Subtle)
.into_any_element(),
),
single_example(
"Tinted",
IconButton::new("tinted", IconName::Check)
.layer(ElevationIndex::Background)
.style(ButtonStyle::Tinted(TintColor::Accent))
.into_any_element(),
),
single_example(
"Transparent",
IconButton::new("transparent", IconName::Check)
.layer(ElevationIndex::Background)
.style(ButtonStyle::Transparent)
.into_any_element(),
),
],
),
example_group_with_title(
"Icon Button Shapes",
vec![
single_example(
"Square",
IconButton::new("square", IconName::Check)
.shape(IconButtonShape::Square)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"Wide",
IconButton::new("wide", IconName::Check)
.shape(IconButtonShape::Wide)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
],
),
example_group_with_title(
"Icon Button Sizes",
vec![
single_example(
"XSmall",
IconButton::new("xsmall", IconName::Check)
.icon_size(IconSize::XSmall)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"Small",
IconButton::new("small", IconName::Check)
.icon_size(IconSize::Small)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"Medium",
IconButton::new("medium", IconName::Check)
.icon_size(IconSize::Medium)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"XLarge",
IconButton::new("xlarge", IconName::Check)
.icon_size(IconSize::XLarge)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
],
),
example_group_with_title(
"Special States",
vec![
single_example(
"Disabled",
IconButton::new("disabled", IconName::Check)
.disabled(true)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"Selected",
IconButton::new("selected", IconName::Check)
.toggle_state(true)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"With Indicator",
IconButton::new("indicator", IconName::Check)
.indicator(Indicator::dot().color(Color::Success))
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
],
),
example_group_with_title(
"Custom Colors",
vec![
single_example(
"Custom Icon Color",
IconButton::new("custom_color", IconName::Check)
.icon_color(Color::Accent)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
single_example(
"With Alpha",
IconButton::new("alpha", IconName::Check)
.alpha(0.5)
.style(ButtonStyle::Filled)
.layer(ElevationIndex::Background)
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,100 @@
use gpui::{
AnyElement, App, BoxShadow, IntoElement, ParentElement, RenderOnce, Styled, Window, div, hsla,
point, prelude::FluentBuilder, px, relative,
};
use theme::ActiveTheme;
use crate::{ElevationIndex, prelude::*};
use super::ButtonLike;
#[derive(Clone, Copy, PartialEq)]
pub enum SplitButtonStyle {
Filled,
Outlined,
Transparent,
}
pub enum SplitButtonKind {
ButtonLike(ButtonLike),
IconButton(IconButton),
}
impl From<IconButton> for SplitButtonKind {
fn from(icon_button: IconButton) -> Self {
Self::IconButton(icon_button)
}
}
impl From<ButtonLike> for SplitButtonKind {
fn from(button_like: ButtonLike) -> Self {
Self::ButtonLike(button_like)
}
}
/// /// A button with two parts: a primary action on the left and a secondary action on the right.
///
/// The left side is a [`ButtonLike`] with the main action, while the right side can contain
/// any element (typically a dropdown trigger or similar).
///
/// The two sections are visually separated by a divider, but presented as a unified control.
#[derive(IntoElement)]
pub struct SplitButton {
left: SplitButtonKind,
right: AnyElement,
style: SplitButtonStyle,
}
impl SplitButton {
pub fn new(left: impl Into<SplitButtonKind>, right: AnyElement) -> Self {
Self {
left: left.into(),
right,
style: SplitButtonStyle::Filled,
}
}
pub fn style(mut self, style: SplitButtonStyle) -> Self {
self.style = style;
self
}
}
impl RenderOnce for SplitButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_filled_or_outlined = matches!(
self.style,
SplitButtonStyle::Filled | SplitButtonStyle::Outlined
);
h_flex()
.when(is_filled_or_outlined, |this| {
this.rounded_sm()
.border_1()
.border_color(cx.theme().colors().border.opacity(0.8))
})
.when(self.style == SplitButtonStyle::Transparent, |this| {
this.gap_px()
})
.child(div().flex_grow().child(match self.left {
SplitButtonKind::ButtonLike(button) => button.into_any_element(),
SplitButtonKind::IconButton(icon) => icon.into_any_element(),
}))
.child(
div()
.h(relative(0.8))
.w_px()
.bg(cx.theme().colors().border.opacity(0.5)),
)
.child(self.right)
.when(self.style == SplitButtonStyle::Filled, |this| {
this.bg(ElevationIndex::Surface.on_elevation_bg(cx))
.shadow(vec![BoxShadow {
color: hsla(0.0, 0.0, 0.0, 0.16),
offset: point(px(0.), px(1.)),
blur_radius: px(0.),
spread_radius: px(0.),
}])
})
}
}

View File

@@ -0,0 +1,769 @@
use std::rc::Rc;
use gpui::{AnyView, ClickEvent, relative};
use crate::{ButtonLike, ButtonLikeRounding, TintColor, Tooltip, prelude::*};
/// The position of a [`ToggleButton`] within a group of buttons.
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct ToggleButtonPosition {
/// The toggle button is one of the leftmost of the group.
leftmost: bool,
/// The toggle button is one of the rightmost of the group.
rightmost: bool,
/// The toggle button is one of the topmost of the group.
topmost: bool,
/// The toggle button is one of the bottommost of the group.
bottommost: bool,
}
impl ToggleButtonPosition {
pub const HORIZONTAL_FIRST: Self = Self {
leftmost: true,
..Self::HORIZONTAL_MIDDLE
};
pub const HORIZONTAL_MIDDLE: Self = Self {
leftmost: false,
rightmost: false,
topmost: true,
bottommost: true,
};
pub const HORIZONTAL_LAST: Self = Self {
rightmost: true,
..Self::HORIZONTAL_MIDDLE
};
pub(crate) fn to_rounding(self) -> ButtonLikeRounding {
ButtonLikeRounding {
top_left: self.topmost && self.leftmost,
top_right: self.topmost && self.rightmost,
bottom_right: self.bottommost && self.rightmost,
bottom_left: self.bottommost && self.leftmost,
}
}
}
pub struct ButtonConfiguration {
label: SharedString,
icon: Option<IconName>,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
selected: bool,
tooltip: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
}
mod private {
pub trait ToggleButtonStyle {}
}
pub trait ButtonBuilder: 'static + private::ToggleButtonStyle {
fn into_configuration(self) -> ButtonConfiguration;
}
pub struct ToggleButtonSimple {
label: SharedString,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
selected: bool,
tooltip: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
}
impl ToggleButtonSimple {
pub fn new(
label: impl Into<SharedString>,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
Self {
label: label.into(),
on_click: Box::new(on_click),
selected: false,
tooltip: None,
}
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Rc::new(tooltip));
self
}
}
impl private::ToggleButtonStyle for ToggleButtonSimple {}
impl ButtonBuilder for ToggleButtonSimple {
fn into_configuration(self) -> ButtonConfiguration {
ButtonConfiguration {
label: self.label,
icon: None,
on_click: self.on_click,
selected: self.selected,
tooltip: self.tooltip,
}
}
}
pub struct ToggleButtonWithIcon {
label: SharedString,
icon: IconName,
on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
selected: bool,
tooltip: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyView>>,
}
impl ToggleButtonWithIcon {
pub fn new(
label: impl Into<SharedString>,
icon: IconName,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
Self {
label: label.into(),
icon,
on_click: Box::new(on_click),
selected: false,
tooltip: None,
}
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Rc::new(tooltip));
self
}
}
impl private::ToggleButtonStyle for ToggleButtonWithIcon {}
impl ButtonBuilder for ToggleButtonWithIcon {
fn into_configuration(self) -> ButtonConfiguration {
ButtonConfiguration {
label: self.label,
icon: Some(self.icon),
on_click: self.on_click,
selected: self.selected,
tooltip: self.tooltip,
}
}
}
#[derive(Clone, Copy, PartialEq)]
pub enum ToggleButtonGroupStyle {
Transparent,
Filled,
Outlined,
}
#[derive(Clone, Copy, PartialEq)]
pub enum ToggleButtonGroupSize {
Default,
Medium,
Large,
Custom(Rems),
}
#[derive(IntoElement)]
pub struct ToggleButtonGroup<T, const COLS: usize = 3, const ROWS: usize = 1>
where
T: ButtonBuilder,
{
group_name: SharedString,
rows: [[T; COLS]; ROWS],
style: ToggleButtonGroupStyle,
size: ToggleButtonGroupSize,
label_size: LabelSize,
group_width: Option<DefiniteLength>,
auto_width: bool,
selected_index: usize,
tab_index: Option<isize>,
}
impl<T: ButtonBuilder, const COLS: usize> ToggleButtonGroup<T, COLS> {
pub fn single_row(group_name: impl Into<SharedString>, buttons: [T; COLS]) -> Self {
Self {
group_name: group_name.into(),
rows: [buttons],
style: ToggleButtonGroupStyle::Transparent,
size: ToggleButtonGroupSize::Default,
label_size: LabelSize::Small,
group_width: None,
auto_width: false,
selected_index: 0,
tab_index: None,
}
}
}
impl<T: ButtonBuilder, const COLS: usize> ToggleButtonGroup<T, COLS, 2> {
pub fn two_rows(
group_name: impl Into<SharedString>,
first_row: [T; COLS],
second_row: [T; COLS],
) -> Self {
Self {
group_name: group_name.into(),
rows: [first_row, second_row],
style: ToggleButtonGroupStyle::Transparent,
size: ToggleButtonGroupSize::Default,
label_size: LabelSize::Small,
group_width: None,
auto_width: false,
selected_index: 0,
tab_index: None,
}
}
}
impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> ToggleButtonGroup<T, COLS, ROWS> {
pub fn style(mut self, style: ToggleButtonGroupStyle) -> Self {
self.style = style;
self
}
pub fn size(mut self, size: ToggleButtonGroupSize) -> Self {
self.size = size;
self
}
pub fn selected_index(mut self, index: usize) -> Self {
self.selected_index = index;
self
}
/// Makes the button group size itself to fit the content of the buttons,
/// rather than filling the full width of its parent.
pub fn auto_width(mut self) -> Self {
self.auto_width = true;
self
}
pub fn label_size(mut self, label_size: LabelSize) -> Self {
self.label_size = label_size;
self
}
/// Sets the tab index for the toggle button group.
/// The tab index is set to the initial value provided, then the
/// value is incremented by the number of buttons in the group.
pub fn tab_index(mut self, tab_index: &mut isize) -> Self {
self.tab_index = Some(*tab_index);
*tab_index += (COLS * ROWS) as isize;
self
}
const fn button_width() -> DefiniteLength {
relative(1. / COLS as f32)
}
}
impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> FixedWidth
for ToggleButtonGroup<T, COLS, ROWS>
{
fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.group_width = Some(width.into());
self
}
fn full_width(mut self) -> Self {
self.group_width = Some(relative(1.));
self
}
}
impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> RenderOnce
for ToggleButtonGroup<T, COLS, ROWS>
{
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let custom_height = match self.size {
ToggleButtonGroupSize::Custom(height) => Some(height),
_ => None,
};
let entries =
self.rows.into_iter().enumerate().map(|(row_index, row)| {
let group_name = self.group_name.clone();
row.into_iter().enumerate().map(move |(col_index, button)| {
let ButtonConfiguration {
label,
icon,
on_click,
selected,
tooltip,
} = button.into_configuration();
let entry_index = row_index * COLS + col_index;
ButtonLike::new((group_name.clone(), entry_index))
.when(!self.auto_width, |this| this.full_width())
.rounding(Some(
ToggleButtonPosition {
leftmost: col_index == 0,
rightmost: col_index == COLS - 1,
topmost: row_index == 0,
bottommost: row_index == ROWS - 1,
}
.to_rounding(),
))
.when_some(self.tab_index, |this, tab_index| {
this.tab_index(tab_index + entry_index as isize)
})
.when(entry_index == self.selected_index || selected, |this| {
this.toggle_state(true)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
})
.when(self.style == ToggleButtonGroupStyle::Filled, |button| {
button.style(ButtonStyle::Filled)
})
.when(self.size == ToggleButtonGroupSize::Medium, |button| {
button.size(ButtonSize::Medium)
})
.when(self.size == ToggleButtonGroupSize::Large, |button| {
button.size(ButtonSize::Large)
})
.when_some(custom_height, |button, height| button.height(height.into()))
.child(
h_flex()
.w_full()
.px_2()
.gap_1p5()
.justify_center()
.flex_none()
.when_some(icon, |this, icon| {
this.py_2()
.child(Icon::new(icon).size(IconSize::XSmall).map(|this| {
if entry_index == self.selected_index || selected {
this.color(Color::Accent)
} else {
this.color(Color::Muted)
}
}))
})
.child(Label::new(label).size(self.label_size).when(
entry_index == self.selected_index || selected,
|this| this.color(Color::Accent),
)),
)
.when_some(tooltip, |this, tooltip| {
this.tooltip(move |window, cx| tooltip(window, cx))
})
.on_click(on_click)
.into_any_element()
})
});
let border_color = cx.theme().colors().border.opacity(0.6);
let is_outlined_or_filled = self.style == ToggleButtonGroupStyle::Outlined
|| self.style == ToggleButtonGroupStyle::Filled;
let is_transparent = self.style == ToggleButtonGroupStyle::Transparent;
v_flex()
.map(|this| {
if let Some(width) = self.group_width {
this.w(width)
} else if self.auto_width {
this
} else {
this.w_full()
}
})
.rounded_md()
.overflow_hidden()
.map(|this| {
if is_transparent {
this.gap_px()
} else {
this.border_1().border_color(border_color)
}
})
.children(entries.enumerate().map(|(row_index, row)| {
let last_row = row_index == ROWS - 1;
h_flex()
.when(!is_outlined_or_filled, |this| this.gap_px())
.when(is_outlined_or_filled && !last_row, |this| {
this.border_b_1().border_color(border_color)
})
.children(row.enumerate().map(|(item_index, item)| {
let last_item = item_index == COLS - 1;
div()
.when(is_outlined_or_filled && !last_item, |this| {
this.border_r_1().border_color(border_color)
})
.when(!self.auto_width, |this| this.w(Self::button_width()))
.overflow_hidden()
.child(item)
}))
}))
}
}
fn register_toggle_button_group() {
component::register_component::<ToggleButtonGroup<ToggleButtonSimple>>();
}
component::__private::inventory::submit! {
component::ComponentFn::new(register_toggle_button_group)
}
impl<T: ButtonBuilder, const COLS: usize, const ROWS: usize> Component
for ToggleButtonGroup<T, COLS, ROWS>
{
fn name() -> &'static str {
"ToggleButtonGroup"
}
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn sort_name() -> &'static str {
"ButtonG"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![example_group_with_title(
"Transparent Variant",
vec![
single_example(
"Single Row Group",
ToggleButtonGroup::single_row(
"single_row_test",
[
ToggleButtonSimple::new("First", |_, _, _| {}),
ToggleButtonSimple::new("Second", |_, _, _| {}),
ToggleButtonSimple::new("Third", |_, _, _| {}),
],
)
.selected_index(1)
.into_any_element(),
),
single_example(
"Single Row Group with icons",
ToggleButtonGroup::single_row(
"single_row_test_icon",
[
ToggleButtonWithIcon::new(
"First",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Second",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Third",
IconName::AiZed,
|_, _, _| {},
),
],
)
.selected_index(1)
.into_any_element(),
),
single_example(
"Multiple Row Group",
ToggleButtonGroup::two_rows(
"multiple_row_test",
[
ToggleButtonSimple::new("First", |_, _, _| {}),
ToggleButtonSimple::new("Second", |_, _, _| {}),
ToggleButtonSimple::new("Third", |_, _, _| {}),
],
[
ToggleButtonSimple::new("Fourth", |_, _, _| {}),
ToggleButtonSimple::new("Fifth", |_, _, _| {}),
ToggleButtonSimple::new("Sixth", |_, _, _| {}),
],
)
.selected_index(3)
.into_any_element(),
),
single_example(
"Multiple Row Group with Icons",
ToggleButtonGroup::two_rows(
"multiple_row_test_icons",
[
ToggleButtonWithIcon::new(
"First",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Second",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Third",
IconName::AiZed,
|_, _, _| {},
),
],
[
ToggleButtonWithIcon::new(
"Fourth",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Fifth",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Sixth",
IconName::AiZed,
|_, _, _| {},
),
],
)
.selected_index(3)
.into_any_element(),
),
],
)])
.children(vec![example_group_with_title(
"Outlined Variant",
vec![
single_example(
"Single Row Group",
ToggleButtonGroup::single_row(
"single_row_test_outline",
[
ToggleButtonSimple::new("First", |_, _, _| {}),
ToggleButtonSimple::new("Second", |_, _, _| {}),
ToggleButtonSimple::new("Third", |_, _, _| {}),
],
)
.selected_index(1)
.style(ToggleButtonGroupStyle::Outlined)
.into_any_element(),
),
single_example(
"Single Row Group with icons",
ToggleButtonGroup::single_row(
"single_row_test_icon_outlined",
[
ToggleButtonWithIcon::new(
"First",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Second",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Third",
IconName::AiZed,
|_, _, _| {},
),
],
)
.selected_index(1)
.style(ToggleButtonGroupStyle::Outlined)
.into_any_element(),
),
single_example(
"Multiple Row Group",
ToggleButtonGroup::two_rows(
"multiple_row_test",
[
ToggleButtonSimple::new("First", |_, _, _| {}),
ToggleButtonSimple::new("Second", |_, _, _| {}),
ToggleButtonSimple::new("Third", |_, _, _| {}),
],
[
ToggleButtonSimple::new("Fourth", |_, _, _| {}),
ToggleButtonSimple::new("Fifth", |_, _, _| {}),
ToggleButtonSimple::new("Sixth", |_, _, _| {}),
],
)
.selected_index(3)
.style(ToggleButtonGroupStyle::Outlined)
.into_any_element(),
),
single_example(
"Multiple Row Group with Icons",
ToggleButtonGroup::two_rows(
"multiple_row_test",
[
ToggleButtonWithIcon::new(
"First",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Second",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Third",
IconName::AiZed,
|_, _, _| {},
),
],
[
ToggleButtonWithIcon::new(
"Fourth",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Fifth",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Sixth",
IconName::AiZed,
|_, _, _| {},
),
],
)
.selected_index(3)
.style(ToggleButtonGroupStyle::Outlined)
.into_any_element(),
),
],
)])
.children(vec![example_group_with_title(
"Filled Variant",
vec![
single_example(
"Single Row Group",
ToggleButtonGroup::single_row(
"single_row_test_outline",
[
ToggleButtonSimple::new("First", |_, _, _| {}),
ToggleButtonSimple::new("Second", |_, _, _| {}),
ToggleButtonSimple::new("Third", |_, _, _| {}),
],
)
.selected_index(2)
.style(ToggleButtonGroupStyle::Filled)
.into_any_element(),
),
single_example(
"Single Row Group with icons",
ToggleButtonGroup::single_row(
"single_row_test_icon_outlined",
[
ToggleButtonWithIcon::new(
"First",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Second",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Third",
IconName::AiZed,
|_, _, _| {},
),
],
)
.selected_index(1)
.style(ToggleButtonGroupStyle::Filled)
.into_any_element(),
),
single_example(
"Multiple Row Group",
ToggleButtonGroup::two_rows(
"multiple_row_test",
[
ToggleButtonSimple::new("First", |_, _, _| {}),
ToggleButtonSimple::new("Second", |_, _, _| {}),
ToggleButtonSimple::new("Third", |_, _, _| {}),
],
[
ToggleButtonSimple::new("Fourth", |_, _, _| {}),
ToggleButtonSimple::new("Fifth", |_, _, _| {}),
ToggleButtonSimple::new("Sixth", |_, _, _| {}),
],
)
.selected_index(3)
.width(rems_from_px(100.))
.style(ToggleButtonGroupStyle::Filled)
.into_any_element(),
),
single_example(
"Multiple Row Group with Icons",
ToggleButtonGroup::two_rows(
"multiple_row_test",
[
ToggleButtonWithIcon::new(
"First",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Second",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Third",
IconName::AiZed,
|_, _, _| {},
),
],
[
ToggleButtonWithIcon::new(
"Fourth",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Fifth",
IconName::AiZed,
|_, _, _| {},
),
ToggleButtonWithIcon::new(
"Sixth",
IconName::AiZed,
|_, _, _| {},
),
],
)
.selected_index(3)
.width(rems_from_px(100.))
.style(ToggleButtonGroupStyle::Filled)
.into_any_element(),
),
],
)])
.children(vec![single_example(
"With Tooltips",
ToggleButtonGroup::single_row(
"with_tooltips",
[
ToggleButtonSimple::new("First", |_, _, _| {})
.tooltip(Tooltip::text("This is a tooltip. Hello!")),
ToggleButtonSimple::new("Second", |_, _, _| {})
.tooltip(Tooltip::text("This is a tooltip. Hey?")),
ToggleButtonSimple::new("Third", |_, _, _| {})
.tooltip(Tooltip::text("This is a tooltip. Get out of here now!")),
],
)
.selected_index(1)
.into_any_element(),
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,365 @@
use gpui::AnyElement;
use crate::prelude::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BorderPosition {
Top,
Bottom,
}
/// A callout component for displaying important information that requires user attention.
///
/// # Usage Example
///
/// ```
/// use ui::prelude::*;
/// use ui::{Button, Callout, IconName, Label, Severity};
///
/// let callout = Callout::new()
/// .severity(Severity::Warning)
/// .icon(IconName::Warning)
/// .title("Be aware of your subscription!")
/// .description("Your subscription is about to expire. Renew now!")
/// .actions_slot(Button::new("renew", "Renew Now"));
/// ```
///
#[derive(IntoElement, RegisterComponent)]
pub struct Callout {
severity: Severity,
icon: Option<IconName>,
title: Option<SharedString>,
description: Option<SharedString>,
description_slot: Option<AnyElement>,
actions_slot: Option<AnyElement>,
dismiss_action: Option<AnyElement>,
line_height: Option<Pixels>,
border_position: BorderPosition,
}
impl Callout {
/// Creates a new `Callout` component with default styling.
pub fn new() -> Self {
Self {
severity: Severity::Info,
icon: None,
title: None,
description: None,
description_slot: None,
actions_slot: None,
dismiss_action: None,
line_height: None,
border_position: BorderPosition::Top,
}
}
/// Sets the severity of the callout.
pub fn severity(mut self, severity: Severity) -> Self {
self.severity = severity;
self
}
/// Sets the icon to display in the callout.
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// Sets the title of the callout.
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Sets the description of the callout.
/// The description can be single or multi-line text.
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
/// Allows for any element—like markdown elements—to fill the description slot of the callout.
/// This method wins over `description` if both happen to be set.
pub fn description_slot(mut self, description: impl IntoElement) -> Self {
self.description_slot = Some(description.into_any_element());
self
}
/// Sets the primary call-to-action button.
pub fn actions_slot(mut self, action: impl IntoElement) -> Self {
self.actions_slot = Some(action.into_any_element());
self
}
/// Sets an optional dismiss button, which is usually an icon button with a close icon.
/// This button is always rendered as the last one to the far right.
pub fn dismiss_action(mut self, action: impl IntoElement) -> Self {
self.dismiss_action = Some(action.into_any_element());
self
}
/// Sets a custom line height for the callout content.
pub fn line_height(mut self, line_height: Pixels) -> Self {
self.line_height = Some(line_height);
self
}
/// Sets the border position in the callout.
pub fn border_position(mut self, border_position: BorderPosition) -> Self {
self.border_position = border_position;
self
}
}
impl RenderOnce for Callout {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let line_height = self.line_height.unwrap_or(window.line_height());
let has_actions = self.actions_slot.is_some() || self.dismiss_action.is_some();
let (icon, icon_color, bg_color) = match self.severity {
Severity::Info => (
IconName::Info,
Color::Muted,
cx.theme().status().info_background.opacity(0.1),
),
Severity::Success => (
IconName::Check,
Color::Success,
cx.theme().status().success.opacity(0.1),
),
Severity::Warning => (
IconName::Warning,
Color::Warning,
cx.theme().status().warning_background.opacity(0.2),
),
Severity::Error => (
IconName::XCircle,
Color::Error,
cx.theme().status().error.opacity(0.08),
),
};
h_flex()
.min_w_0()
.w_full()
.p_2()
.gap_2()
.items_start()
.map(|this| match self.border_position {
BorderPosition::Top => this.border_t_1(),
BorderPosition::Bottom => this.border_b_1(),
})
.border_color(cx.theme().colors().border)
.bg(bg_color)
.overflow_x_hidden()
.when(self.icon.is_some(), |this| {
this.child(
h_flex()
.h(line_height)
.justify_center()
.child(Icon::new(icon).size(IconSize::Small).color(icon_color)),
)
})
.child(
v_flex()
.min_w_0()
.min_h_0()
.w_full()
.child(
h_flex()
.min_h(line_height)
.w_full()
.gap_1()
.justify_between()
.flex_wrap()
.when_some(self.title, |this, title| {
this.child(
div()
.min_w_0()
.flex_1()
.child(Label::new(title).size(LabelSize::Small)),
)
})
.when(has_actions, |this| {
this.child(
h_flex()
.gap_0p5()
.when_some(self.actions_slot, |this, action| {
this.child(action)
})
.when_some(self.dismiss_action, |this, action| {
this.child(action)
}),
)
}),
)
.map(|this| {
let base_desc_container = div()
.id("callout-description-slot")
.w_full()
.max_h_32()
.flex_1()
.overflow_y_scroll()
.text_ui_sm(cx);
if let Some(description_slot) = self.description_slot {
this.child(base_desc_container.child(description_slot))
} else if let Some(description) = self.description {
this.child(
base_desc_container
.text_color(cx.theme().colors().text_muted)
.child(description),
)
} else {
this
}
}),
)
}
}
impl Component for Callout {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn description() -> Option<&'static str> {
Some(
"Used to display a callout for situations where the user needs to know some information, and likely make a decision. This might be a thread running out of tokens, or running out of prompts on a plan and needing to upgrade.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let single_action = || Button::new("got-it", "Got it").label_size(LabelSize::Small);
let multiple_actions = || {
h_flex()
.gap_0p5()
.child(Button::new("update", "Backup & Update").label_size(LabelSize::Small))
.child(Button::new("dismiss", "Dismiss").label_size(LabelSize::Small))
};
let basic_examples = vec![
single_example(
"Simple with Title Only",
Callout::new()
.icon(IconName::Info)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
)
.width(px(580.)),
single_example(
"With Title and Description",
Callout::new()
.icon(IconName::Warning)
.title("Your settings contain deprecated values")
.description(
"We'll backup your current settings and update them to the new format.",
)
.actions_slot(single_action())
.into_any_element(),
)
.width(px(580.)),
single_example(
"Error with Multiple Actions",
Callout::new()
.icon(IconName::Close)
.title("Thread reached the token limit")
.description("Start a new thread from a summary to continue the conversation.")
.actions_slot(multiple_actions())
.into_any_element(),
)
.width(px(580.)),
single_example(
"Multi-line Description",
Callout::new()
.icon(IconName::Sparkle)
.title("Upgrade to Pro")
.description("• Unlimited threads\n• Priority support\n• Advanced analytics")
.actions_slot(multiple_actions())
.into_any_element(),
)
.width(px(580.)),
single_example(
"Scrollable Long Description",
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
.title("Very Long API Error Description")
.description_slot(
v_flex().gap_1().children(
[
"You exceeded your current quota.",
"For more information, visit the docs.",
"Error details:",
"• Quota exceeded for metric",
"• Limit: 0",
"• Model: gemini-3.1-pro",
"Please retry in 26.33s.",
"Additional details:",
"- Request ID: abc123def456",
"- Timestamp: 2024-01-15T10:30:00Z",
"- Region: us-central1",
"- Service: generativelanguage.googleapis.com",
"- Error Code: RESOURCE_EXHAUSTED",
"- Retry After: 26s",
"This error occurs when you have exceeded your API quota.",
]
.into_iter()
.map(|t| Label::new(t).size(LabelSize::Small).color(Color::Muted)),
),
)
.actions_slot(single_action())
.into_any_element(),
)
.width(px(580.)),
];
let severity_examples = vec![
single_example(
"Info",
Callout::new()
.icon(IconName::Info)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
single_example(
"Warning",
Callout::new()
.severity(Severity::Warning)
.icon(IconName::Triangle)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
single_example(
"Error",
Callout::new()
.severity(Severity::Error)
.icon(IconName::XCircle)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
single_example(
"Success",
Callout::new()
.severity(Severity::Success)
.icon(IconName::Check)
.title("System maintenance scheduled for tonight")
.actions_slot(single_action())
.into_any_element(),
),
];
Some(
v_flex()
.gap_4()
.child(example_group(basic_examples).vertical())
.child(example_group_with_title("Severity", severity_examples).vertical())
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,167 @@
use crate::prelude::*;
use gpui::{AnyElement, AnyView, Hsla, IntoElement, ParentElement, Styled};
/// Chips provide a container for an informative label.
///
/// # Usage Example
///
/// ```
/// use ui::Chip;
///
/// let chip = Chip::new("This Chip");
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct Chip {
label: SharedString,
label_color: Color,
label_size: LabelSize,
icon: Option<IconName>,
icon_color: Color,
bg_color: Option<Hsla>,
border_color: Option<Hsla>,
height: Option<Pixels>,
truncate: bool,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
}
impl Chip {
/// Creates a new `Chip` component with the specified label.
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
label_color: Color::Default,
label_size: LabelSize::XSmall,
icon: None,
icon_color: Color::Default,
bg_color: None,
border_color: None,
height: None,
truncate: false,
tooltip: None,
}
}
/// Sets the color of the label.
pub fn label_color(mut self, color: Color) -> Self {
self.label_color = color;
self
}
/// Sets the size of the label.
pub fn label_size(mut self, size: LabelSize) -> Self {
self.label_size = size;
self
}
/// Sets an icon to display before the label.
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// Sets the color of the icon.
pub fn icon_color(mut self, color: Color) -> Self {
self.icon_color = color;
self
}
/// Sets a custom background color for the callout content.
pub fn bg_color(mut self, color: Hsla) -> Self {
self.bg_color = Some(color);
self
}
/// Sets a custom border color for the chip.
pub fn border_color(mut self, color: Hsla) -> Self {
self.border_color = Some(color);
self
}
/// Sets a custom height for the chip.
pub fn height(mut self, height: Pixels) -> Self {
self.height = Some(height);
self
}
/// Allows the chip to shrink and truncate its label when space is limited.
pub fn truncate(mut self) -> Self {
self.truncate = true;
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Box::new(tooltip));
self
}
}
impl RenderOnce for Chip {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let bg_color = self
.bg_color
.unwrap_or(cx.theme().colors().element_background);
let border_color = self.border_color.unwrap_or(cx.theme().colors().border);
h_flex()
.when_some(self.height, |this, h| this.h(h))
.when(self.truncate, |this| this.min_w_0())
.when(!self.truncate, |this| this.flex_none())
.gap_0p5()
.px_1()
.border_1()
.rounded_sm()
.border_color(border_color)
.bg(bg_color)
.overflow_hidden()
.when_some(self.icon, |this, icon| {
this.child(
Icon::new(icon)
.size(IconSize::XSmall)
.color(self.icon_color),
)
})
.child(
Label::new(self.label.clone())
.size(self.label_size)
.color(self.label_color)
.buffer_font(cx)
.truncate(),
)
.id(self.label.clone())
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
}
}
impl Component for Chip {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let chip_examples = vec![
single_example("Default", Chip::new("Chip Example").into_any_element()),
single_example(
"Customized Label Color",
Chip::new("Chip Example")
.label_color(Color::Accent)
.into_any_element(),
),
single_example(
"Customized Label Size",
Chip::new("Chip Example")
.label_size(LabelSize::Large)
.label_color(Color::Accent)
.into_any_element(),
),
single_example(
"Customized Background Color",
Chip::new("Chip Example")
.bg_color(cx.theme().colors().text_accent.opacity(0.1))
.into_any_element(),
),
];
Some(example_group(chip_examples).vertical().into_any_element())
}
}

View File

@@ -0,0 +1,5 @@
mod collab_notification;
mod update_button;
pub use collab_notification::*;
pub use update_button::*;

View File

@@ -0,0 +1,186 @@
use gpui::{AnyElement, SharedUri, prelude::*};
use smallvec::SmallVec;
use crate::{Avatar, prelude::*};
#[derive(IntoElement, RegisterComponent)]
pub struct CollabNotification {
avatar_uri: SharedUri,
accept_button: Button,
dismiss_button: Button,
children: SmallVec<[AnyElement; 2]>,
}
impl CollabNotification {
pub fn new(
avatar_uri: impl Into<SharedUri>,
accept_button: Button,
dismiss_button: Button,
) -> Self {
Self {
avatar_uri: avatar_uri.into(),
accept_button,
dismiss_button,
children: SmallVec::new(),
}
}
}
impl ParentElement for CollabNotification {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for CollabNotification {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.p_2()
.size_full()
.text_ui(cx)
.justify_between()
.overflow_hidden()
.elevation_3(cx)
.gap_1()
.child(
h_flex()
.min_w_0()
.gap_4()
.child(Avatar::new(self.avatar_uri).size(px(40.)))
.child(v_flex().truncate().children(self.children)),
)
.child(
v_flex()
.items_center()
.child(self.accept_button)
.child(self.dismiss_button),
)
}
}
impl Component for CollabNotification {
fn scope() -> ComponentScope {
ComponentScope::Collaboration
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let avatar = "https://avatars.githubusercontent.com/u/67129314?v=4";
let container = || div().h(px(72.)).w(px(400.)); // Size of the actual notification window
let call_examples = vec![
single_example(
"Incoming Call",
container()
.child(
CollabNotification::new(
avatar,
Button::new("accept", "Accept"),
Button::new("decline", "Decline"),
)
.child(Label::new("the user is inviting you to a call")),
)
.into_any_element(),
),
single_example(
"Screen Share Request",
container()
.child(
CollabNotification::new(
avatar,
Button::new("accept", "View"),
Button::new("decline", "Ignore"),
)
.child(Label::new("the user is sharing their screen")),
)
.into_any_element(),
),
single_example(
"Project Shared",
container()
.child(
CollabNotification::new(
avatar,
Button::new("accept", "Open"),
Button::new("decline", "Dismiss"),
)
.child(Label::new("the user is sharing a project"))
.child(Label::new("zed").color(Color::Muted)),
)
.into_any_element(),
),
single_example(
"Overflowing Content",
container()
.child(
CollabNotification::new(
avatar,
Button::new("accept", "Accept"),
Button::new("decline", "Decline"),
)
.child(Label::new(
"a_very_long_username_that_might_overflow is sharing a project in Zed:",
))
.child(
Label::new("zed-cloud, zed, edit-prediction-bench, zed.dev")
.color(Color::Muted),
),
)
.into_any_element(),
),
];
let toast_examples = vec![
single_example(
"Contact Request",
container()
.child(
CollabNotification::new(
avatar,
Button::new("accept", "Accept"),
Button::new("decline", "Decline"),
)
.child(Label::new("maxbrunsfeld wants to add you as a contact")),
)
.into_any_element(),
),
single_example(
"Contact Request Accepted",
container()
.child(
CollabNotification::new(
avatar,
Button::new("dismiss", "Dismiss"),
Button::new("close", "Close"),
)
.child(Label::new("maxbrunsfeld accepted your contact request")),
)
.into_any_element(),
),
single_example(
"Channel Invitation",
container()
.child(
CollabNotification::new(
avatar,
Button::new("accept", "Accept"),
Button::new("decline", "Decline"),
)
.child(Label::new(
"maxbrunsfeld invited you to join the #zed channel",
)),
)
.into_any_element(),
),
];
Some(
v_flex()
.gap_6()
.child(example_group_with_title("Calls & Projects", call_examples).vertical())
.child(
example_group_with_title("Contact & Channel Toasts", toast_examples).vertical(),
)
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,202 @@
use gpui::{AnyElement, ClickEvent, prelude::*};
use crate::{ButtonLike, CommonAnimationExt, Tooltip, prelude::*};
/// A button component displayed in the title bar to show auto-update status.
#[derive(IntoElement, RegisterComponent)]
pub struct UpdateButton {
icon: IconName,
icon_animate: bool,
icon_color: Option<Color>,
message: SharedString,
tooltip: Option<SharedString>,
show_dismiss: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_dismiss: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl UpdateButton {
pub fn new(icon: IconName, message: impl Into<SharedString>) -> Self {
Self {
icon,
icon_animate: false,
icon_color: None,
message: message.into(),
tooltip: None,
show_dismiss: false,
on_click: None,
on_dismiss: None,
}
}
/// Sets whether the icon should have a rotation animation (for progress states).
pub fn icon_animate(mut self, animate: bool) -> Self {
self.icon_animate = animate;
self
}
/// Sets the icon color (e.g., for warning/error states).
pub fn icon_color(mut self, color: impl Into<Option<Color>>) -> Self {
self.icon_color = color.into();
self
}
/// Sets the tooltip text shown on hover.
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
/// Shows a dismiss button on the right side.
pub fn with_dismiss(mut self) -> Self {
self.show_dismiss = true;
self
}
/// Sets the click handler for the main button area.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
/// Sets the click handler for the dismiss button.
pub fn on_dismiss(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_dismiss = Some(Box::new(handler));
self
}
pub fn checking() -> Self {
Self::new(IconName::ArrowCircle, "Checking for Zed updates…").icon_animate(true)
}
pub fn downloading(version: impl Into<SharedString>) -> Self {
Self::new(IconName::Download, "Downloading Zed update…").tooltip(version)
}
pub fn installing(version: impl Into<SharedString>) -> Self {
Self::new(IconName::ArrowCircle, "Installing Zed update…")
.icon_animate(true)
.tooltip(version)
}
pub fn updated(version: impl Into<SharedString>) -> Self {
Self::new(IconName::Download, "Restart to Update")
.tooltip(version)
.with_dismiss()
}
pub fn errored(error: impl Into<SharedString>) -> Self {
Self::new(IconName::Warning, "Failed to update Zed")
.icon_color(Color::Warning)
.tooltip(error)
.with_dismiss()
}
}
impl RenderOnce for UpdateButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_color = cx.theme().colors().border;
let icon = Icon::new(self.icon)
.size(IconSize::XSmall)
.when_some(self.icon_color, |this, color| this.color(color));
let icon_element = if self.icon_animate {
icon.with_rotate_animation(3).into_any_element()
} else {
icon.into_any_element()
};
let tooltip = self.tooltip.clone();
h_flex()
.mr_2()
.rounded_sm()
.border_1()
.border_color(border_color)
.child(
ButtonLike::new("update-button")
.child(
h_flex()
.h_full()
.gap_1()
.child(icon_element)
.child(Label::new(self.message).size(LabelSize::Small)),
)
.when_some(tooltip, |this, tooltip| {
this.tooltip(Tooltip::text(tooltip))
})
.when_some(self.on_click, |this, handler| this.on_click(handler)),
)
.when(self.show_dismiss, |this| {
this.child(
div().border_l_1().border_color(border_color).child(
IconButton::new("dismiss-update-button", IconName::Close)
.icon_size(IconSize::Indicator)
.when_some(self.on_dismiss, |this, handler| this.on_click(handler))
.tooltip(Tooltip::text("Dismiss")),
),
)
})
}
}
impl Component for UpdateButton {
fn scope() -> ComponentScope {
ComponentScope::Collaboration
}
fn name() -> &'static str {
"UpdateButton"
}
fn description() -> Option<&'static str> {
Some(
"A button component displayed in the title bar to show auto-update status and allow users to restart Zed.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let version = "1.99.0";
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Progress States",
vec![
single_example("Checking", UpdateButton::checking().into_any_element()),
single_example(
"Downloading",
UpdateButton::downloading(version).into_any_element(),
),
single_example(
"Installing",
UpdateButton::installing(version).into_any_element(),
),
],
),
example_group_with_title(
"Actionable States",
vec![
single_example(
"Ready to Update",
UpdateButton::updated(version).into_any_element(),
),
single_example(
"Error",
UpdateButton::errored("Network timeout").into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
use gpui::FontWeight;
use crate::prelude::*;
/// A small, pill-shaped badge that displays a numeric count.
///
/// The count is capped at 99 and displayed as "99+" beyond that.
#[derive(IntoElement, RegisterComponent)]
pub struct CountBadge {
count: usize,
}
impl CountBadge {
pub fn new(count: usize) -> Self {
Self { count }
}
}
impl RenderOnce for CountBadge {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let label = if self.count > 99 {
"99+".to_string()
} else {
self.count.to_string()
};
let bg = cx
.theme()
.colors()
.editor_background
.blend(cx.theme().status().error.opacity(0.4));
h_flex()
.absolute()
.top_0()
.right_0()
.p_px()
.h_3p5()
.min_w_3p5()
.rounded_full()
.justify_center()
.text_center()
.border_1()
.border_color(cx.theme().colors().border)
.bg(bg)
.shadow_sm()
.child(
Label::new(label)
.size(LabelSize::Custom(rems_from_px(9.)))
.weight(FontWeight::MEDIUM),
)
}
}
impl Component for CountBadge {
fn scope() -> ComponentScope {
ComponentScope::Status
}
fn description() -> Option<&'static str> {
Some("A small, pill-shaped badge that displays a numeric count.")
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let container = || {
div()
.relative()
.size_8()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().background)
};
Some(
v_flex()
.gap_6()
.child(example_group_with_title(
"Count Badge",
vec![
single_example(
"Basic Count",
container().child(CountBadge::new(3)).into_any_element(),
),
single_example(
"Capped Count",
container().child(CountBadge::new(150)).into_any_element(),
),
],
))
.into_any_element(),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
//! A newtype for a table row that enforces a fixed column count at runtime.
//!
//! This type ensures that all rows in a table have the same width, preventing accidental creation or mutation of rows with inconsistent lengths.
//! It is especially useful for CSV or tabular data where rectangular invariants must be maintained, but the number of columns is only known at runtime.
//! By using `TableRow`, we gain stronger guarantees and safer APIs compared to a bare `Vec<T>`, without requiring const generics.
use std::{
any::type_name,
ops::{
Index, IndexMut, Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive,
},
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TableRow<T>(Vec<T>);
impl<T> TableRow<T> {
pub fn from_element(element: T, length: usize) -> Self
where
T: Clone,
{
Self::from_vec(vec![element; length], length)
}
/// Constructs a `TableRow` from a `Vec<T>`, panicking if the length does not match `expected_length`.
///
/// Use this when you want to ensure at construction time that the row has the correct number of columns.
/// This enforces the rectangular invariant for table data, preventing accidental creation of malformed rows.
///
/// # Panics
/// Panics if `data.len() != expected_length`.
pub fn from_vec(data: Vec<T>, expected_length: usize) -> Self {
Self::try_from_vec(data, expected_length).unwrap_or_else(|e| {
let name = type_name::<Vec<T>>();
panic!("Expected {name} to be created successfully: {e}");
})
}
/// Attempts to construct a `TableRow` from a `Vec<T>`, returning an error if the length does not match `expected_len`.
///
/// This is a fallible alternative to `from_vec`, allowing you to handle inconsistent row lengths gracefully.
/// Returns `Ok(TableRow)` if the length matches, or an `Err` with a descriptive message otherwise.
pub fn try_from_vec(data: Vec<T>, expected_len: usize) -> Result<Self, String> {
if data.len() != expected_len {
Err(format!(
"Row length {} does not match expected {}",
data.len(),
expected_len
))
} else {
Ok(Self(data))
}
}
/// Returns reference to element by column index.
///
/// # Panics
/// Panics if `col` is out of bounds (i.e., `col >= self.cols()`).
pub fn expect_get(&self, col: impl Into<usize>) -> &T {
let col = col.into();
self.0.get(col).unwrap_or_else(|| {
panic!(
"Expected table row of `{}` to have {col:?}",
type_name::<T>()
)
})
}
pub fn get(&self, col: impl Into<usize>) -> Option<&T> {
self.0.get(col.into())
}
pub fn as_slice(&self) -> &[T] {
&self.0
}
pub fn into_vec(self) -> Vec<T> {
self.0
}
/// Like [`map`], but borrows the row and clones each element before mapping.
///
/// This is useful when you want to map over a borrowed row without consuming it,
/// but your mapping function requires ownership of each element.
///
/// # Difference
/// - `map_cloned` takes `&self`, clones each element, and applies `f(T) -> U`.
/// - [`map`] takes `self` by value and applies `f(T) -> U` directly, consuming the row.
/// - [`map_ref`] takes `&self` and applies `f(&T) -> U` to references of each element.
pub fn map_cloned<F, U>(&self, f: F) -> TableRow<U>
where
F: FnMut(T) -> U,
T: Clone,
{
self.clone().map(f)
}
/// Consumes the row and transforms all elements within it in a length-safe way.
///
/// # Difference
/// - `map` takes ownership of the row (`self`) and applies `f(T) -> U` to each element.
/// - Use this when you want to transform and consume the row in one step.
/// - See also [`map_cloned`] (for mapping over a borrowed row with cloning) and [`map_ref`] (for mapping over references).
pub fn map<F, U>(self, f: F) -> TableRow<U>
where
F: FnMut(T) -> U,
{
TableRow(self.0.into_iter().map(f).collect())
}
/// Borrows the row and transforms all elements by reference in a length-safe way.
///
/// # Difference
/// - `map_ref` takes `&self` and applies `f(&T) -> U` to each element by reference.
/// - Use this when you want to map over a borrowed row without cloning or consuming it.
/// - See also [`map`] (for consuming the row) and [`map_cloned`] (for mapping with cloning).
pub fn map_ref<F, U>(&self, f: F) -> TableRow<U>
where
F: FnMut(&T) -> U,
{
TableRow(self.0.iter().map(f).collect())
}
/// Number of columns (alias to `len()` with more semantic meaning)
pub fn cols(&self) -> usize {
self.0.len()
}
}
///// Convenience traits /////
pub trait IntoTableRow<T> {
fn into_table_row(self, expected_length: usize) -> TableRow<T>;
}
impl<T> IntoTableRow<T> for Vec<T> {
fn into_table_row(self, expected_length: usize) -> TableRow<T> {
TableRow::from_vec(self, expected_length)
}
}
// Index implementations for convenient access
impl<T> Index<usize> for TableRow<T> {
type Output = T;
fn index(&self, index: usize) -> &Self::Output {
&self.0[index]
}
}
impl<T> IndexMut<usize> for TableRow<T> {
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
&mut self.0[index]
}
}
// Range indexing implementations for slice operations
impl<T> Index<Range<usize>> for TableRow<T> {
type Output = [T];
fn index(&self, index: Range<usize>) -> &Self::Output {
<Vec<T> as Index<Range<usize>>>::index(&self.0, index)
}
}
impl<T> Index<RangeFrom<usize>> for TableRow<T> {
type Output = [T];
fn index(&self, index: RangeFrom<usize>) -> &Self::Output {
<Vec<T> as Index<RangeFrom<usize>>>::index(&self.0, index)
}
}
impl<T> Index<RangeTo<usize>> for TableRow<T> {
type Output = [T];
fn index(&self, index: RangeTo<usize>) -> &Self::Output {
<Vec<T> as Index<RangeTo<usize>>>::index(&self.0, index)
}
}
impl<T> Index<RangeToInclusive<usize>> for TableRow<T> {
type Output = [T];
fn index(&self, index: RangeToInclusive<usize>) -> &Self::Output {
<Vec<T> as Index<RangeToInclusive<usize>>>::index(&self.0, index)
}
}
impl<T> Index<RangeFull> for TableRow<T> {
type Output = [T];
fn index(&self, index: RangeFull) -> &Self::Output {
<Vec<T> as Index<RangeFull>>::index(&self.0, index)
}
}
impl<T> Index<RangeInclusive<usize>> for TableRow<T> {
type Output = [T];
fn index(&self, index: RangeInclusive<usize>) -> &Self::Output {
<Vec<T> as Index<RangeInclusive<usize>>>::index(&self.0, index)
}
}
impl<T> IndexMut<RangeInclusive<usize>> for TableRow<T> {
fn index_mut(&mut self, index: RangeInclusive<usize>) -> &mut Self::Output {
<Vec<T> as IndexMut<RangeInclusive<usize>>>::index_mut(&mut self.0, index)
}
}

View File

@@ -0,0 +1,319 @@
use super::table_row::TableRow;
use crate::{RedistributableColumnsState, TableResizeBehavior};
fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
}
fn cols_to_str(cols: &[f32], total_size: f32) -> String {
cols.iter()
.map(|f| "*".repeat(f32::round(f * total_size) as usize))
.collect::<Vec<String>>()
.join("|")
}
fn parse_resize_behavior(
input: &str,
total_size: f32,
expected_cols: usize,
) -> Vec<TableResizeBehavior> {
let mut resize_behavior = Vec::with_capacity(expected_cols);
for col in input.split('|') {
if col.starts_with('X') || col.is_empty() {
resize_behavior.push(TableResizeBehavior::None);
} else if col.starts_with('*') {
resize_behavior.push(TableResizeBehavior::MinSize(col.len() as f32 / total_size));
} else {
panic!("invalid test input: unrecognized resize behavior: {}", col);
}
}
if resize_behavior.len() != expected_cols {
panic!(
"invalid test input: expected {} columns, got {}",
expected_cols,
resize_behavior.len()
);
}
resize_behavior
}
mod reset_column_size {
use super::*;
fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
let mut widths = Vec::new();
let mut column_index = None;
for (index, col) in input.split('|').enumerate() {
widths.push(col.len() as f32);
if col.starts_with('X') {
column_index = Some(index);
}
}
for w in &widths {
assert!(w.is_finite(), "incorrect number of columns");
}
let total = widths.iter().sum::<f32>();
for width in &mut widths {
*width /= total;
}
(widths, total, column_index)
}
#[track_caller]
fn check_reset_size(initial_sizes: &str, widths: &str, expected: &str, resize_behavior: &str) {
let (initial_sizes, total_1, None) = parse(initial_sizes) else {
panic!("invalid test input: initial sizes should not be marked");
};
let (widths, total_2, Some(column_index)) = parse(widths) else {
panic!("invalid test input: widths should be marked");
};
assert_eq!(
total_1, total_2,
"invalid test input: total width not the same {total_1}, {total_2}"
);
let (expected, total_3, None) = parse(expected) else {
panic!("invalid test input: expected should not be marked: {expected:?}");
};
assert_eq!(
total_2, total_3,
"invalid test input: total width not the same"
);
let cols = initial_sizes.len();
let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
let result = RedistributableColumnsState::reset_to_initial_size(
column_index,
TableRow::from_vec(widths, cols),
TableRow::from_vec(initial_sizes, cols),
&resize_behavior,
);
let result_slice = result.as_slice();
let is_eq = is_almost_eq(result_slice, &expected);
if !is_eq {
let result_str = cols_to_str(result_slice, total_1);
let expected_str = cols_to_str(&expected, total_1);
panic!(
"resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_slice:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
);
}
}
macro_rules! check_reset_size {
(columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
check_reset_size($initial, $current, $expected, $resizing);
};
($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
#[test]
fn $name() {
check_reset_size($initial, $current, $expected, $resizing);
}
};
}
check_reset_size!(
basic_right,
columns: 5,
starting: "**|**|**|**|**",
snapshot: "**|**|X|***|**",
expected: "**|**|**|**|**",
minimums: "X|*|*|*|*",
);
check_reset_size!(
basic_left,
columns: 5,
starting: "**|**|**|**|**",
snapshot: "**|**|***|X|**",
expected: "**|**|**|**|**",
minimums: "X|*|*|*|**",
);
check_reset_size!(
squashed_left_reset_col2,
columns: 6,
starting: "*|***|**|**|****|*",
snapshot: "*|*|X|*|*|********",
expected: "*|*|**|*|*|*******",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
grow_cascading_right,
columns: 6,
starting: "*|***|****|**|***|*",
snapshot: "*|***|X|**|**|*****",
expected: "*|***|****|*|*|****",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
squashed_right_reset_col4,
columns: 6,
starting: "*|***|**|**|****|*",
snapshot: "*|********|*|*|X|*",
expected: "*|*****|*|*|****|*",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
reset_col6_right,
columns: 6,
starting: "*|***|**|***|***|**",
snapshot: "*|***|**|***|**|XXX",
expected: "*|***|**|***|***|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
reset_col6_left,
columns: 6,
starting: "*|***|**|***|***|**",
snapshot: "*|***|**|***|****|X",
expected: "*|***|**|***|***|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
last_column_grow_cascading,
columns: 6,
starting: "*|***|**|**|**|***",
snapshot: "*|*******|*|**|*|X",
expected: "*|******|*|*|*|***",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
goes_left_when_left_has_extreme_diff,
columns: 6,
starting: "*|***|****|**|**|***",
snapshot: "*|********|X|*|**|**",
expected: "*|*****|****|*|**|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
basic_shrink_right,
columns: 6,
starting: "**|**|**|**|**|**",
snapshot: "**|**|XXX|*|**|**",
expected: "**|**|**|**|**|**",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
shrink_should_go_left,
columns: 6,
starting: "*|***|**|*|*|*",
snapshot: "*|*|XXX|**|*|*",
expected: "*|**|**|**|*|*",
minimums: "X|*|*|*|*|*",
);
check_reset_size!(
shrink_should_go_right,
columns: 6,
starting: "*|***|**|**|**|*",
snapshot: "*|****|XXX|*|*|*",
expected: "*|****|**|**|*|*",
minimums: "X|*|*|*|*|*",
);
}
mod drag_handle {
use super::*;
fn parse(input: &str) -> (Vec<f32>, f32, Option<usize>) {
let mut widths = Vec::new();
let column_index = input.replace("*", "").find("I");
for col in input.replace("I", "|").split('|') {
widths.push(col.len() as f32);
}
for w in &widths {
assert!(w.is_finite(), "incorrect number of columns");
}
let total = widths.iter().sum::<f32>();
for width in &mut widths {
*width /= total;
}
(widths, total, column_index)
}
#[track_caller]
fn check(distance: i32, widths: &str, expected: &str, resize_behavior: &str) {
let (widths, total_1, Some(column_index)) = parse(widths) else {
panic!("invalid test input: widths should be marked");
};
let (expected, total_2, None) = parse(expected) else {
panic!("invalid test input: expected should not be marked: {expected:?}");
};
assert_eq!(
total_1, total_2,
"invalid test input: total width not the same"
);
let cols = widths.len();
let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
let distance = distance as f32 / total_1;
let mut widths_table_row = TableRow::from_vec(widths, cols);
RedistributableColumnsState::drag_column_handle(
distance,
column_index,
&mut widths_table_row,
&resize_behavior,
);
let result_widths = widths_table_row.as_slice();
let is_eq = is_almost_eq(result_widths, &expected);
if !is_eq {
let result_str = cols_to_str(result_widths, total_1);
let expected_str = cols_to_str(&expected, total_1);
panic!(
"resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result_widths:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
);
}
}
macro_rules! check {
(columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
check($dist, $current, $expected, $resizing);
};
($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
#[test]
fn $name() {
check($dist, $current, $expected, $resizing);
}
};
}
check!(
basic_right_drag,
columns: 3,
distance: 1,
snapshot: "**|**I**",
expected: "**|***|*",
minimums: "X|*|*",
);
check!(
drag_left_against_mins,
columns: 5,
distance: -1,
snapshot: "*|*|*|*I*******",
expected: "*|*|*|*|*******",
minimums: "X|*|*|*|*",
);
check!(
drag_left,
columns: 5,
distance: -2,
snapshot: "*|*|*|*****I***",
expected: "*|*|*|***|*****",
minimums: "X|*|*|*|*",
);
}

View File

@@ -0,0 +1,86 @@
use crate::Tooltip;
use crate::prelude::*;
#[derive(IntoElement, RegisterComponent)]
pub struct DiffStat {
id: ElementId,
added: usize,
removed: usize,
label_size: LabelSize,
tooltip: Option<SharedString>,
}
impl DiffStat {
pub fn new(id: impl Into<ElementId>, added: usize, removed: usize) -> Self {
Self {
id: id.into(),
added,
removed,
label_size: LabelSize::Small,
tooltip: None,
}
}
pub fn label_size(mut self, label_size: LabelSize) -> Self {
self.label_size = label_size;
self
}
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
}
impl RenderOnce for DiffStat {
fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement {
let tooltip = self.tooltip;
h_flex()
.id(self.id)
.gap_1()
.child(
Label::new(format!("+\u{2009}{}", self.added))
.color(Color::Success)
.size(self.label_size),
)
.child(
Label::new(format!("\u{2012}\u{2009}{}", self.removed))
.color(Color::Error)
.size(self.label_size),
)
.when_some(tooltip, |this, tooltip| {
this.tooltip(Tooltip::text(tooltip))
})
}
}
impl Component for DiffStat {
fn scope() -> ComponentScope {
ComponentScope::VersionControl
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let container = || {
h_flex()
.py_4()
.w_72()
.justify_center()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
};
let diff_stat_example = vec![single_example(
"Default",
container()
.child(DiffStat::new("id", 1, 2))
.into_any_element(),
)];
Some(
example_group(diff_stat_example)
.vertical()
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,151 @@
use std::sync::Arc;
use gpui::{ClickEvent, CursorStyle, SharedString};
use crate::prelude::*;
#[derive(IntoElement, RegisterComponent)]
pub struct Disclosure {
id: ElementId,
is_open: bool,
selected: bool,
disabled: bool,
on_toggle_expanded: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
cursor_style: CursorStyle,
opened_icon: IconName,
closed_icon: IconName,
visible_on_hover: Option<SharedString>,
}
impl Disclosure {
pub fn new(id: impl Into<ElementId>, is_open: bool) -> Self {
Self {
id: id.into(),
is_open,
selected: false,
disabled: false,
on_toggle_expanded: None,
cursor_style: CursorStyle::PointingHand,
opened_icon: IconName::ChevronDown,
closed_icon: IconName::ChevronRight,
visible_on_hover: None,
}
}
pub fn on_toggle_expanded(
mut self,
handler: impl Into<Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>>,
) -> Self {
self.on_toggle_expanded = handler.into();
self
}
pub fn opened_icon(mut self, icon: IconName) -> Self {
self.opened_icon = icon;
self
}
pub fn closed_icon(mut self, icon: IconName) -> Self {
self.closed_icon = icon;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Toggleable for Disclosure {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl Clickable for Disclosure {
fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
self.on_toggle_expanded = Some(Arc::new(handler));
self
}
fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
self.cursor_style = cursor_style;
self
}
}
impl VisibleOnHover for Disclosure {
fn visible_on_hover(mut self, group_name: impl Into<SharedString>) -> Self {
self.visible_on_hover = Some(group_name.into());
self
}
}
impl RenderOnce for Disclosure {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
IconButton::new(
self.id,
match self.is_open {
true => self.opened_icon,
false => self.closed_icon,
},
)
.icon_color(Color::Muted)
.icon_size(IconSize::Small)
.disabled(self.disabled)
.toggle_state(self.selected)
.when_some(self.visible_on_hover.clone(), |this, group_name| {
this.visible_on_hover(group_name)
})
.when_some(self.on_toggle_expanded, move |this, on_toggle| {
this.on_click(move |event, window, cx| on_toggle(event, window, cx))
})
}
}
impl Component for Disclosure {
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn description() -> Option<&'static str> {
Some(
"An interactive element used to show or hide content, typically used in expandable sections or tree-like structures.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Disclosure States",
vec![
single_example(
"Closed",
Disclosure::new("closed", false).into_any_element(),
),
single_example(
"Open",
Disclosure::new("open", true).into_any_element(),
),
],
),
example_group_with_title(
"Interactive Example",
vec![single_example(
"Toggleable",
v_flex()
.gap_2()
.child(Disclosure::new("interactive", false).into_any_element())
.child(Label::new("Click to toggle"))
.into_any_element(),
)],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,252 @@
use gpui::{Hsla, IntoElement, PathBuilder, canvas, point};
use crate::prelude::*;
pub fn divider() -> Divider {
Divider {
style: DividerStyle::Solid,
direction: DividerDirection::Horizontal,
color: DividerColor::default(),
inset: false,
}
}
pub fn vertical_divider() -> Divider {
Divider {
style: DividerStyle::Solid,
direction: DividerDirection::Vertical,
color: DividerColor::default(),
inset: false,
}
}
#[derive(Clone, Copy, PartialEq)]
enum DividerStyle {
Solid,
Dashed,
}
#[derive(Clone, Copy, PartialEq)]
enum DividerDirection {
Horizontal,
Vertical,
}
/// The color of a [`Divider`].
#[derive(Default)]
pub enum DividerColor {
Border,
BorderFaded,
#[default]
BorderVariant,
}
impl DividerColor {
pub fn hsla(self, cx: &mut App) -> Hsla {
match self {
DividerColor::Border => cx.theme().colors().border,
DividerColor::BorderFaded => cx.theme().colors().border.opacity(0.6),
DividerColor::BorderVariant => cx.theme().colors().border_variant,
}
}
}
#[derive(IntoElement, RegisterComponent)]
pub struct Divider {
style: DividerStyle,
direction: DividerDirection,
color: DividerColor,
inset: bool,
}
impl Divider {
pub fn horizontal() -> Self {
Self {
style: DividerStyle::Solid,
direction: DividerDirection::Horizontal,
color: DividerColor::default(),
inset: false,
}
}
pub fn vertical() -> Self {
Self {
style: DividerStyle::Solid,
direction: DividerDirection::Vertical,
color: DividerColor::default(),
inset: false,
}
}
pub fn horizontal_dashed() -> Self {
Self {
style: DividerStyle::Dashed,
direction: DividerDirection::Horizontal,
color: DividerColor::default(),
inset: false,
}
}
pub fn vertical_dashed() -> Self {
Self {
style: DividerStyle::Dashed,
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
}
pub fn render_solid(self, base: Div, cx: &mut App) -> impl IntoElement {
base.bg(self.color.hsla(cx))
}
pub fn render_dashed(self, base: Div) -> impl IntoElement {
base.relative().child(
canvas(
|_, _, _| {},
move |bounds, _, window, cx| {
let mut builder = PathBuilder::stroke(px(1.)).dash_array(&[px(4.), px(2.)]);
let (start, end) = match self.direction {
DividerDirection::Horizontal => {
let x = bounds.origin.x;
let y = bounds.origin.y + px(0.5);
(point(x, y), point(x + bounds.size.width, y))
}
DividerDirection::Vertical => {
let x = bounds.origin.x + px(0.5);
let y = bounds.origin.y;
(point(x, y), point(x, y + bounds.size.height))
}
};
builder.move_to(start);
builder.line_to(end);
if let Ok(line) = builder.build() {
window.paint_path(line, self.color.hsla(cx));
}
},
)
.absolute()
.size_full(),
)
}
}
impl RenderOnce for Divider {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let base = match self.direction {
DividerDirection::Horizontal => div()
.min_w_0()
.h_px()
.w_full()
.when(self.inset, |this| this.mx_1p5()),
DividerDirection::Vertical => div()
.min_w_0()
.w_px()
.h_full()
.when(self.inset, |this| this.my_1p5()),
};
match self.style {
DividerStyle::Solid => self.render_solid(base, cx).into_any_element(),
DividerStyle::Dashed => self.render_dashed(base).into_any_element(),
}
}
}
impl Component for Divider {
fn scope() -> ComponentScope {
ComponentScope::Layout
}
fn description() -> Option<&'static str> {
Some(
"Visual separator used to create divisions between groups of content or sections in a layout.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Horizontal Dividers",
vec![
single_example("Default", Divider::horizontal().into_any_element()),
single_example(
"Border Color",
Divider::horizontal()
.color(DividerColor::Border)
.into_any_element(),
),
single_example(
"Inset",
Divider::horizontal().inset().into_any_element(),
),
single_example(
"Dashed",
Divider::horizontal_dashed().into_any_element(),
),
],
),
example_group_with_title(
"Vertical Dividers",
vec![
single_example(
"Default",
div().h_16().child(Divider::vertical()).into_any_element(),
),
single_example(
"Border Color",
div()
.h_16()
.child(Divider::vertical().color(DividerColor::Border))
.into_any_element(),
),
single_example(
"Inset",
div()
.h_16()
.child(Divider::vertical().inset())
.into_any_element(),
),
single_example(
"Dashed",
div()
.h_16()
.child(Divider::vertical_dashed())
.into_any_element(),
),
],
),
example_group_with_title(
"Example Usage",
vec![single_example(
"Between Content",
v_flex()
.w_full()
.gap_4()
.px_4()
.child(Label::new("Section One"))
.child(Divider::horizontal())
.child(Label::new("Section Two"))
.child(Divider::horizontal_dashed())
.child(Label::new("Section Three"))
.into_any_element(),
)],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,335 @@
use gpui::{Anchor, AnyView, Entity, Pixels, Point};
use crate::{ButtonLike, ContextMenu, PopoverMenu, prelude::*};
use super::PopoverMenuHandle;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DropdownStyle {
#[default]
Solid,
Outlined,
Subtle,
Ghost,
}
enum LabelKind {
Text(SharedString),
Element(AnyElement),
}
#[derive(IntoElement, RegisterComponent)]
pub struct DropdownMenu {
id: ElementId,
label: LabelKind,
trigger_size: ButtonSize,
trigger_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
trigger_icon: Option<IconName>,
style: DropdownStyle,
menu: Entity<ContextMenu>,
full_width: bool,
disabled: bool,
handle: Option<PopoverMenuHandle<ContextMenu>>,
attach: Option<Anchor>,
offset: Option<Point<Pixels>>,
tab_index: Option<isize>,
chevron: bool,
}
impl DropdownMenu {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
menu: Entity<ContextMenu>,
) -> Self {
Self {
id: id.into(),
label: LabelKind::Text(label.into()),
trigger_size: ButtonSize::Default,
trigger_tooltip: None,
trigger_icon: Some(IconName::ChevronUpDown),
style: DropdownStyle::default(),
menu,
full_width: false,
disabled: false,
handle: None,
attach: None,
offset: None,
tab_index: None,
chevron: true,
}
}
pub fn new_with_element(
id: impl Into<ElementId>,
label: AnyElement,
menu: Entity<ContextMenu>,
) -> Self {
Self {
id: id.into(),
label: LabelKind::Element(label),
trigger_size: ButtonSize::Default,
trigger_tooltip: None,
trigger_icon: Some(IconName::ChevronUpDown),
style: DropdownStyle::default(),
menu,
full_width: false,
disabled: false,
handle: None,
attach: None,
offset: None,
tab_index: None,
chevron: true,
}
}
pub fn style(mut self, style: DropdownStyle) -> Self {
self.style = style;
self
}
pub fn trigger_size(mut self, size: ButtonSize) -> Self {
self.trigger_size = size;
self
}
pub fn trigger_tooltip(
mut self,
tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
) -> Self {
self.trigger_tooltip = Some(Box::new(tooltip));
self
}
pub fn trigger_icon(mut self, icon: IconName) -> Self {
self.trigger_icon = Some(icon);
self
}
pub fn full_width(mut self, full_width: bool) -> Self {
self.full_width = full_width;
self
}
pub fn handle(mut self, handle: PopoverMenuHandle<ContextMenu>) -> Self {
self.handle = Some(handle);
self
}
/// Defines which corner of the handle to attach the menu's anchor to.
pub fn attach(mut self, attach: Anchor) -> Self {
self.attach = Some(attach);
self
}
/// Offsets the position of the menu by that many pixels.
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
self.offset = Some(offset);
self
}
pub fn tab_index(mut self, arg: isize) -> Self {
self.tab_index = Some(arg);
self
}
pub fn no_chevron(mut self) -> Self {
self.chevron = false;
self
}
}
impl Disableable for DropdownMenu {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl RenderOnce for DropdownMenu {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let button_style = match self.style {
DropdownStyle::Solid => ButtonStyle::Filled,
DropdownStyle::Subtle => ButtonStyle::Subtle,
DropdownStyle::Outlined => ButtonStyle::Outlined,
DropdownStyle::Ghost => ButtonStyle::Transparent,
};
let full_width = self.full_width;
let trigger_size = self.trigger_size;
let (text_button, element_button) = match self.label {
LabelKind::Text(text) => (
Some(
Button::new(self.id.clone(), text)
.style(button_style)
.when_some(self.trigger_icon.filter(|_| self.chevron), |this, icon| {
this.end_icon(
Icon::new(icon).size(IconSize::XSmall).color(Color::Muted),
)
})
.when(full_width, |this| this.full_width())
.size(trigger_size)
.disabled(self.disabled)
.when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)),
),
None,
),
LabelKind::Element(element) => (
None,
Some(
ButtonLike::new(self.id.clone())
.child(element)
.style(button_style)
.when(self.chevron, |this| {
this.child(
Icon::new(IconName::ChevronUpDown)
.size(IconSize::XSmall)
.color(Color::Muted),
)
})
.when(full_width, |this| this.full_width())
.size(trigger_size)
.disabled(self.disabled)
.when_some(self.tab_index, |this, tab_index| this.tab_index(tab_index)),
),
),
};
let mut popover = PopoverMenu::new((self.id.clone(), "popover"))
.full_width(self.full_width)
.menu(move |_window, _cx| Some(self.menu.clone()));
popover = match (text_button, element_button, self.trigger_tooltip) {
(Some(text_button), None, Some(tooltip)) => {
popover.trigger_with_tooltip(text_button, tooltip)
}
(Some(text_button), None, None) => popover.trigger(text_button),
(None, Some(element_button), Some(tooltip)) => {
popover.trigger_with_tooltip(element_button, tooltip)
}
(None, Some(element_button), None) => popover.trigger(element_button),
_ => popover,
};
popover
.attach(match self.attach {
Some(attach) => attach,
None => Anchor::BottomRight,
})
.when_some(self.offset, |this, offset| this.offset(offset))
.when_some(self.handle, |this, handle| this.with_handle(handle))
}
}
impl Component for DropdownMenu {
fn scope() -> ComponentScope {
ComponentScope::Input
}
fn name() -> &'static str {
"DropdownMenu"
}
fn description() -> Option<&'static str> {
Some(
"A dropdown menu displays a list of actions or options. A dropdown menu is always activated by clicking a trigger (or via a keybinding).",
)
}
fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let menu = ContextMenu::build(window, cx, |this, _, _| {
this.entry("Option 1", None, |_, _| {})
.entry("Option 2", None, |_, _| {})
.entry("Option 3", None, |_, _| {})
.separator()
.entry("Option 4", None, |_, _| {})
});
let menu_with_submenu = ContextMenu::build(window, cx, |this, _, _| {
this.entry("Toggle All Docks", None, |_, _| {})
.submenu("Editor Layout", |menu, _, _| {
menu.entry("Split Up", None, |_, _| {})
.entry("Split Down", None, |_, _| {})
.separator()
.entry("Split Side", None, |_, _| {})
})
.separator()
.entry("Project Panel", None, |_, _| {})
.entry("Outline Panel", None, |_, _| {})
.separator()
.submenu("Autofill", |menu, _, _| {
menu.entry("Contact…", None, |_, _| {})
.entry("Passwords…", None, |_, _| {})
})
.submenu_with_icon("Predict", IconName::ZedPredict, |menu, _, _| {
menu.entry("Everywhere", None, |_, _| {})
.entry("At Cursor", None, |_, _| {})
.entry("Over Here", None, |_, _| {})
.entry("Over There", None, |_, _| {})
})
});
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic Usage",
vec![
single_example(
"Default",
DropdownMenu::new("default", "Select an option", menu.clone())
.into_any_element(),
),
single_example(
"Full Width",
DropdownMenu::new(
"full-width",
"Full Width Dropdown",
menu.clone(),
)
.full_width(true)
.into_any_element(),
),
],
),
example_group_with_title(
"Submenus",
vec![single_example(
"With Submenus",
DropdownMenu::new("submenu", "Submenu", menu_with_submenu)
.into_any_element(),
)],
),
example_group_with_title(
"Styles",
vec![
single_example(
"Outlined",
DropdownMenu::new("outlined", "Outlined Dropdown", menu.clone())
.style(DropdownStyle::Outlined)
.into_any_element(),
),
single_example(
"Ghost",
DropdownMenu::new("ghost", "Ghost Dropdown", menu.clone())
.style(DropdownStyle::Ghost)
.into_any_element(),
),
],
),
example_group_with_title(
"States",
vec![single_example(
"Disabled",
DropdownMenu::new("disabled", "Disabled Dropdown", menu)
.disabled(true)
.into_any_element(),
)],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,135 @@
use crate::component_prelude::*;
use crate::prelude::*;
use gpui::{AnyElement, StyleRefinement};
use smallvec::SmallVec;
use super::Avatar;
/// An element that displays a collection of (usually) faces stacked
/// horizontally, with the left-most face on top, visually descending
/// from left to right.
///
/// Facepiles are used to display a group of people or things,
/// such as a list of participants in a collaboration session.
///
/// # Examples
///
/// ## Default
///
/// A default, horizontal facepile.
///
/// ```
/// use gpui::IntoElement;
/// use ui::{Avatar, Facepile, EXAMPLE_FACES};
///
/// let facepile = Facepile::new(
/// EXAMPLE_FACES.iter().take(3).map(|&url|
/// Avatar::new(url).into_any_element()).collect()
/// );
/// ```
#[derive(IntoElement, Documented, RegisterComponent)]
pub struct Facepile {
base: Div,
faces: SmallVec<[AnyElement; 2]>,
}
impl Facepile {
/// Creates a new empty facepile.
pub fn empty() -> Self {
Self::new(SmallVec::new())
}
/// Creates a new facepile with the given faces.
pub fn new(faces: SmallVec<[AnyElement; 2]>) -> Self {
Self { base: div(), faces }
}
}
impl ParentElement for Facepile {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.faces.extend(elements);
}
}
// Style methods.
impl Facepile {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
gpui::padding_style_methods!({
visibility: pub
});
}
impl RenderOnce for Facepile {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
// Lay the faces out in reverse so they overlap in the desired order (left to right, front to back)
self.base
.flex()
.flex_row_reverse()
.items_center()
.justify_start()
.children(
self.faces
.into_iter()
.enumerate()
.rev()
.map(|(ix, player)| div().when(ix > 0, |div| div.ml_neg_1()).child(player)),
)
}
}
pub const EXAMPLE_FACES: [&str; 6] = [
"https://avatars.githubusercontent.com/u/326587?s=60&v=4",
"https://avatars.githubusercontent.com/u/2280405?s=60&v=4",
"https://avatars.githubusercontent.com/u/1789?s=60&v=4",
"https://avatars.githubusercontent.com/u/67129314?s=60&v=4",
"https://avatars.githubusercontent.com/u/482957?s=60&v=4",
"https://avatars.githubusercontent.com/u/1714999?s=60&v=4",
];
impl Component for Facepile {
fn scope() -> ComponentScope {
ComponentScope::Collaboration
}
fn description() -> Option<&'static str> {
Some(
"Displays a collection of avatars or initials in a compact format. Often used to represent active collaborators or a subset of contributors.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![example_group_with_title(
"Facepile Examples",
vec![
single_example(
"Default",
Facepile::new(
EXAMPLE_FACES
.iter()
.map(|&url| Avatar::new(url).into_any_element())
.collect(),
)
.into_any_element(),
),
single_example(
"Custom Size",
Facepile::new(
EXAMPLE_FACES
.iter()
.map(|&url| Avatar::new(url).size(px(24.)).into_any_element())
.collect(),
)
.into_any_element(),
),
],
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,92 @@
use gpui::{Hsla, Pixels, SharedString, linear_color_stop, linear_gradient, px};
use crate::prelude::*;
/// A gradient overlay that fades from a solid color to transparent.
#[derive(IntoElement)]
pub struct GradientFade {
base_bg: Hsla,
hover_bg: Hsla,
active_bg: Hsla,
width: Pixels,
right: Pixels,
gradient_stop: f32,
group_name: Option<SharedString>,
}
impl GradientFade {
pub fn new(base_bg: Hsla, hover_bg: Hsla, active_bg: Hsla) -> Self {
Self {
base_bg,
hover_bg,
active_bg,
width: px(48.0),
right: px(0.0),
gradient_stop: 0.6,
group_name: None,
}
}
pub fn width(mut self, width: Pixels) -> Self {
self.width = width;
self
}
pub fn right(mut self, right: Pixels) -> Self {
self.right = right;
self
}
pub fn gradient_stop(mut self, stop: f32) -> Self {
self.gradient_stop = stop;
self
}
pub fn group_name(mut self, name: impl Into<SharedString>) -> Self {
self.group_name = Some(name.into());
self
}
}
impl RenderOnce for GradientFade {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let stop = self.gradient_stop;
// Best-effort to flatten potentially-transparent colors to opaque ones.
let app_bg = cx.theme().colors().background;
let base_bg = app_bg.blend(self.base_bg);
let hover_bg = app_bg.blend(self.hover_bg);
let active_bg = app_bg.blend(self.active_bg);
div()
.id("gradient_fade")
.absolute()
.top_0()
.right(self.right)
.w(self.width)
.h_full()
.bg(linear_gradient(
90.,
linear_color_stop(base_bg, stop),
linear_color_stop(base_bg.opacity(0.0), 0.),
))
.when_some(self.group_name.clone(), |element, group_name| {
element.group_hover(group_name, move |s| {
s.bg(linear_gradient(
90.,
linear_color_stop(hover_bg, stop),
linear_color_stop(hover_bg.opacity(0.0), 0.),
))
})
})
.when_some(self.group_name, |element, group_name| {
element.group_active(group_name, move |s| {
s.bg(linear_gradient(
90.,
linear_color_stop(active_bg, stop),
linear_color_stop(active_bg.opacity(0.0), 0.),
))
})
})
}
}

View File

@@ -0,0 +1,57 @@
use gpui::{Div, div, prelude::*};
/// Creates a horizontal group with tight, consistent spacing.
///
/// xs: ~2px @16px/rem
pub fn h_group_sm() -> Div {
div().flex().gap_0p5()
}
/// Creates a horizontal group with consistent spacing.
///
/// s: ~4px @16px/rem
pub fn h_group() -> Div {
div().flex().gap_1()
}
/// Creates a horizontal group with consistent spacing.
///
/// m: ~6px @16px/rem
pub fn h_group_lg() -> Div {
div().flex().gap_1p5()
}
/// Creates a horizontal group with consistent spacing.
///
/// l: ~8px @16px/rem
pub fn h_group_xl() -> Div {
div().flex().gap_2()
}
/// Creates a vertical group with tight, consistent spacing.
///
/// xs: ~2px @16px/rem
pub fn v_group_sm() -> Div {
div().flex().flex_col().gap_0p5()
}
/// Creates a vertical group with consistent spacing.
///
/// s: ~4px @16px/rem
pub fn v_group() -> Div {
div().flex().flex_col().gap_1()
}
/// Creates a vertical group with consistent spacing.
///
/// m: ~6px @16px/rem
pub fn v_group_lg() -> Div {
div().flex().flex_col().gap_1p5()
}
/// Creates a vertical group with consistent spacing.
///
/// l: ~8px @16px/rem
pub fn v_group_xl() -> Div {
div().flex().flex_col().gap_2()
}

View File

@@ -0,0 +1,344 @@
mod decorated_icon;
mod icon_decoration;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub use decorated_icon::*;
use gpui::{AnimationElement, AnyElement, Hsla, IntoElement, Rems, Transformation, img, svg};
pub use icon_decoration::*;
pub use icons::*;
use crate::traits::transformable::Transformable;
use crate::{Indicator, prelude::*};
#[derive(IntoElement)]
pub enum AnyIcon {
Icon(Icon),
AnimatedIcon(AnimationElement<Icon>),
}
impl AnyIcon {
/// Returns a new [`AnyIcon`] after applying the given mapping function
/// to the contained [`Icon`].
pub fn map(self, f: impl FnOnce(Icon) -> Icon) -> Self {
match self {
Self::Icon(icon) => Self::Icon(f(icon)),
Self::AnimatedIcon(animated_icon) => Self::AnimatedIcon(animated_icon.map_element(f)),
}
}
}
impl From<Icon> for AnyIcon {
fn from(value: Icon) -> Self {
Self::Icon(value)
}
}
impl From<AnimationElement<Icon>> for AnyIcon {
fn from(value: AnimationElement<Icon>) -> Self {
Self::AnimatedIcon(value)
}
}
impl RenderOnce for AnyIcon {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
match self {
Self::Icon(icon) => icon.into_any_element(),
Self::AnimatedIcon(animated_icon) => animated_icon.into_any_element(),
}
}
}
#[derive(Default, PartialEq, Copy, Clone)]
pub enum IconSize {
/// 10px
Indicator,
/// 12px
XSmall,
/// 14px
Small,
#[default]
/// 16px
Medium,
/// 48px
XLarge,
Custom(Rems),
}
impl IconSize {
pub fn rems(self) -> Rems {
match self {
IconSize::Indicator => rems_from_px(10.),
IconSize::XSmall => rems_from_px(12.),
IconSize::Small => rems_from_px(14.),
IconSize::Medium => rems_from_px(16.),
IconSize::XLarge => rems_from_px(48.),
IconSize::Custom(size) => size,
}
}
/// Returns the individual components of the square that contains this [`IconSize`].
///
/// The returned tuple contains:
/// 1. The length of one side of the square
/// 2. The padding of one side of the square
pub fn square_components(&self, window: &mut Window, cx: &mut App) -> (Pixels, Pixels) {
let icon_size = self.rems() * window.rem_size();
let padding = match self {
IconSize::Indicator => DynamicSpacing::Base00.px(cx),
IconSize::XSmall => DynamicSpacing::Base02.px(cx),
IconSize::Small => DynamicSpacing::Base02.px(cx),
IconSize::Medium => DynamicSpacing::Base02.px(cx),
IconSize::XLarge => DynamicSpacing::Base02.px(cx),
// TODO: Wire into dynamic spacing
IconSize::Custom(size) => size.to_pixels(window.rem_size()),
};
(icon_size, padding)
}
/// Returns the length of a side of the square that contains this [`IconSize`], with padding.
pub fn square(&self, window: &mut Window, cx: &mut App) -> Pixels {
let (icon_size, padding) = self.square_components(window, cx);
icon_size + padding * 2.
}
}
impl From<IconName> for Icon {
fn from(icon: IconName) -> Self {
Icon::new(icon)
}
}
/// The source of an icon.
#[derive(Clone)]
enum IconSource {
/// An SVG embedded in the Zed binary.
Embedded(SharedString),
/// An image file located at the specified path.
///
/// Currently our SVG renderer is missing support for rendering polychrome SVGs.
///
/// In order to support icon themes, we render the icons as images instead.
External(Arc<Path>),
/// An SVG not embedded in the Zed binary.
ExternalSvg(SharedString),
}
#[derive(Clone, IntoElement, RegisterComponent)]
pub struct Icon {
source: IconSource,
color: Color,
size: Rems,
transformation: Transformation,
}
impl Icon {
pub fn new(icon: IconName) -> Self {
Self {
source: IconSource::Embedded(icon.path().into()),
color: Color::default(),
size: IconSize::default().rems(),
transformation: Transformation::default(),
}
}
/// Create an icon from a path. Uses a heuristic to determine if it's embedded or external:
/// - Paths starting with "icons/" are treated as embedded SVGs
/// - Other paths are treated as external raster images (from icon themes)
pub fn from_path(path: impl Into<SharedString>) -> Self {
let path = path.into();
let source = if path.starts_with("icons/") {
IconSource::Embedded(path)
} else {
IconSource::External(Arc::from(PathBuf::from(path.as_ref())))
};
Self {
source,
color: Color::default(),
size: IconSize::default().rems(),
transformation: Transformation::default(),
}
}
pub fn from_external_svg(svg: SharedString) -> Self {
Self {
source: IconSource::ExternalSvg(svg),
color: Color::default(),
size: IconSize::default().rems(),
transformation: Transformation::default(),
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn size(mut self, size: IconSize) -> Self {
self.size = size.rems();
self
}
/// Sets a custom size for the icon, in [`Rems`].
///
/// Not to be exposed outside of the `ui` crate.
pub(crate) fn custom_size(mut self, size: Rems) -> Self {
self.size = size;
self
}
}
impl Transformable for Icon {
fn transform(mut self, transformation: Transformation) -> Self {
self.transformation = transformation;
self
}
}
impl RenderOnce for Icon {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
match self.source {
IconSource::Embedded(path) => svg()
.with_transformation(self.transformation)
.size(self.size)
.flex_none()
.path(path)
.text_color(self.color.color(cx))
.into_any_element(),
IconSource::ExternalSvg(path) => svg()
.external_path(path)
.with_transformation(self.transformation)
.size(self.size)
.flex_none()
.text_color(self.color.color(cx))
.into_any_element(),
IconSource::External(path) => img(path)
.size(self.size)
.flex_none()
.text_color(self.color.color(cx))
.into_any_element(),
}
}
}
#[derive(IntoElement)]
pub struct IconWithIndicator {
icon: Icon,
indicator: Option<Indicator>,
indicator_border_color: Option<Hsla>,
}
impl IconWithIndicator {
pub fn new(icon: Icon, indicator: Option<Indicator>) -> Self {
Self {
icon,
indicator,
indicator_border_color: None,
}
}
pub fn indicator(mut self, indicator: Option<Indicator>) -> Self {
self.indicator = indicator;
self
}
pub fn indicator_color(mut self, color: Color) -> Self {
if let Some(indicator) = self.indicator.as_mut() {
indicator.color = color;
}
self
}
pub fn indicator_border_color(mut self, color: Option<Hsla>) -> Self {
self.indicator_border_color = color;
self
}
}
impl RenderOnce for IconWithIndicator {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let indicator_border_color = self
.indicator_border_color
.unwrap_or_else(|| cx.theme().colors().elevated_surface_background);
div()
.relative()
.child(self.icon)
.when_some(self.indicator, |this, indicator| {
this.child(
div()
.absolute()
.size_2p5()
.border_2()
.border_color(indicator_border_color)
.rounded_full()
.bottom_neg_0p5()
.right_neg_0p5()
.child(indicator),
)
})
}
}
impl Component for Icon {
fn scope() -> ComponentScope {
ComponentScope::Images
}
fn description() -> Option<&'static str> {
Some(
"A versatile icon component that supports SVG and image-based icons with customizable size, color, and transformations.",
)
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Sizes",
vec![single_example(
"XSmall, Small, Default, Large",
h_flex()
.gap_1()
.child(Icon::new(IconName::Star).size(IconSize::XSmall))
.child(Icon::new(IconName::Star).size(IconSize::Small))
.child(Icon::new(IconName::Star))
.child(Icon::new(IconName::Star).size(IconSize::XLarge))
.into_any_element(),
)],
),
example_group(vec![single_example(
"All Icons",
h_flex()
.image_cache(gpui::retain_all("all icons"))
.flex_wrap()
.gap_2()
.children(<IconName as strum::IntoEnumIterator>::iter().map(
|icon_name: IconName| {
let name: SharedString = format!("{icon_name:?}").into();
v_flex()
.min_w_0()
.w_24()
.p_1p5()
.gap_2()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().element_disabled)
.rounded_sm()
.items_center()
.child(Icon::new(icon_name))
.child(Label::new(name).size(LabelSize::XSmall).truncate())
},
))
.into_any_element(),
)]),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,106 @@
use gpui::{AnyElement, IntoElement, Point};
use crate::{IconDecoration, IconDecorationKind, prelude::*};
#[derive(IntoElement, RegisterComponent)]
pub struct DecoratedIcon {
icon: Icon,
decoration: Option<IconDecoration>,
}
impl DecoratedIcon {
pub fn new(icon: Icon, decoration: Option<IconDecoration>) -> Self {
Self { icon, decoration }
}
}
impl RenderOnce for DecoratedIcon {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
div()
.relative()
.size(self.icon.size)
.child(self.icon)
.children(self.decoration)
}
}
impl Component for DecoratedIcon {
fn scope() -> ComponentScope {
ComponentScope::Images
}
fn description() -> Option<&'static str> {
Some(
"An icon with an optional decoration overlay (like an X, triangle, or dot) that can be positioned relative to the icon",
)
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let decoration_x = IconDecoration::new(
IconDecorationKind::X,
cx.theme().colors().surface_background,
cx,
)
.color(cx.theme().status().error)
.position(Point {
x: px(-2.),
y: px(-2.),
});
let decoration_triangle = IconDecoration::new(
IconDecorationKind::Triangle,
cx.theme().colors().surface_background,
cx,
)
.color(cx.theme().status().error)
.position(Point {
x: px(-2.),
y: px(-2.),
});
let decoration_dot = IconDecoration::new(
IconDecorationKind::Dot,
cx.theme().colors().surface_background,
cx,
)
.color(cx.theme().status().error)
.position(Point {
x: px(-2.),
y: px(-2.),
});
Some(
v_flex()
.gap_6()
.children(vec![example_group_with_title(
"Decorations",
vec![
single_example(
"No Decoration",
DecoratedIcon::new(Icon::new(IconName::FileDoc), None)
.into_any_element(),
),
single_example(
"X Decoration",
DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_x))
.into_any_element(),
),
single_example(
"Triangle Decoration",
DecoratedIcon::new(
Icon::new(IconName::FileDoc),
Some(decoration_triangle),
)
.into_any_element(),
),
single_example(
"Dot Decoration",
DecoratedIcon::new(Icon::new(IconName::FileDoc), Some(decoration_dot))
.into_any_element(),
),
],
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,169 @@
use std::sync::Arc;
use gpui::{Hsla, IntoElement, Point, svg};
use strum::{EnumIter, EnumString, IntoStaticStr};
use crate::prelude::*;
const ICON_DECORATION_SIZE: Pixels = px(11.);
/// An icon silhouette used to knockout the background of an element for an icon
/// to sit on top of it, emulating a stroke/border.
#[derive(Debug, PartialEq, Eq, Copy, Clone, EnumIter, EnumString, IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
pub enum KnockoutIconName {
XFg,
XBg,
DotFg,
DotBg,
TriangleFg,
TriangleBg,
}
impl KnockoutIconName {
/// Returns the path to this icon.
pub fn path(&self) -> Arc<str> {
let file_stem: &'static str = self.into();
format!("icons/knockouts/{file_stem}.svg").into()
}
}
#[derive(Debug, PartialEq, Eq, Copy, Clone, EnumIter, EnumString)]
pub enum IconDecorationKind {
X,
Dot,
Triangle,
}
impl IconDecorationKind {
fn fg(&self) -> KnockoutIconName {
match self {
Self::X => KnockoutIconName::XFg,
Self::Dot => KnockoutIconName::DotFg,
Self::Triangle => KnockoutIconName::TriangleFg,
}
}
fn bg(&self) -> KnockoutIconName {
match self {
Self::X => KnockoutIconName::XBg,
Self::Dot => KnockoutIconName::DotBg,
Self::Triangle => KnockoutIconName::TriangleBg,
}
}
}
/// The decoration for an icon.
///
/// For example, this can show an indicator, an "x", or a diagonal strikethrough
/// to indicate something is disabled.
#[derive(IntoElement)]
pub struct IconDecoration {
kind: IconDecorationKind,
color: Hsla,
knockout_color: Hsla,
knockout_hover_color: Hsla,
size: Pixels,
position: Point<Pixels>,
group_name: Option<SharedString>,
}
impl IconDecoration {
/// Creates a new [`IconDecoration`].
pub fn new(kind: IconDecorationKind, knockout_color: Hsla, cx: &App) -> Self {
let color = cx.theme().colors().icon;
let position = Point::default();
Self {
kind,
color,
knockout_color,
knockout_hover_color: knockout_color,
size: ICON_DECORATION_SIZE,
position,
group_name: None,
}
}
/// Sets the kind of decoration.
pub fn kind(mut self, kind: IconDecorationKind) -> Self {
self.kind = kind;
self
}
/// Sets the color of the decoration.
pub fn color(mut self, color: Hsla) -> Self {
self.color = color;
self
}
/// Sets the color of the decoration's knockout
///
/// Match this to the background of the element the icon will be rendered
/// on.
pub fn knockout_color(mut self, color: Hsla) -> Self {
self.knockout_color = color;
self
}
/// Sets the color of the decoration that is used on hover.
pub fn knockout_hover_color(mut self, color: Hsla) -> Self {
self.knockout_hover_color = color;
self
}
/// Sets the position of the decoration.
pub fn position(mut self, position: Point<Pixels>) -> Self {
self.position = position;
self
}
/// Sets the size of the decoration.
pub fn size(mut self, size: Pixels) -> Self {
self.size = size;
self
}
/// Sets the name of the group the decoration belongs to
pub fn group_name(mut self, name: Option<SharedString>) -> Self {
self.group_name = name;
self
}
}
impl RenderOnce for IconDecoration {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let size = self.size;
let foreground = svg()
.absolute()
.bottom_0()
.right_0()
.size(size)
.path(self.kind.fg().path())
.text_color(self.color);
let background = svg()
.absolute()
.bottom_0()
.right_0()
.size(size)
.path(self.kind.bg().path())
.text_color(self.knockout_color)
.map(|this| match self.group_name {
Some(group_name) => this.group_hover(group_name, |style| {
style.text_color(self.knockout_hover_color)
}),
None => this.hover(|style| style.text_color(self.knockout_hover_color)),
});
div()
.size(size)
.flex_none()
.absolute()
.bottom(self.position.y)
.right(self.position.x)
.child(background)
.child(foreground)
}
}

View File

@@ -0,0 +1,180 @@
use std::sync::Arc;
use gpui::Transformation;
use gpui::{App, IntoElement, Rems, RenderOnce, Size, Styled, Window, svg};
use serde::{Deserialize, Serialize};
use strum::{EnumIter, EnumString, IntoStaticStr};
use crate::prelude::*;
use crate::traits::transformable::Transformable;
#[derive(
Debug, PartialEq, Eq, Copy, Clone, EnumIter, EnumString, IntoStaticStr, Serialize, Deserialize,
)]
#[strum(serialize_all = "snake_case")]
pub enum VectorName {
BusinessStamp,
Grid,
ProTrialStamp,
ProUserStamp,
StudentStamp,
ZedLogo,
ZedXCopilot,
}
impl VectorName {
/// Returns the path to this vector image.
pub fn path(&self) -> Arc<str> {
let file_stem: &'static str = self.into();
format!("images/{file_stem}.svg").into()
}
}
/// A vector image, such as an SVG.
///
/// A [`Vector`] is different from an [`crate::Icon`] in that it is intended
/// to be displayed at a specific size, or series of sizes, rather
/// than conforming to the standard size of an icon.
#[derive(IntoElement, RegisterComponent)]
pub struct Vector {
path: Arc<str>,
color: Color,
size: Size<Rems>,
transformation: Transformation,
}
impl Vector {
/// Creates a new [`Vector`] image with the given [`VectorName`] and size.
pub fn new(vector: VectorName, width: Rems, height: Rems) -> Self {
Self {
path: vector.path(),
color: Color::default(),
size: Size { width, height },
transformation: Transformation::default(),
}
}
/// Creates a new [`Vector`] image where the width and height are the same.
pub fn square(vector: VectorName, size: Rems) -> Self {
Self::new(vector, size, size)
}
/// Sets the vector color.
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
/// Sets the vector size.
pub fn size(mut self, size: impl Into<Size<Rems>>) -> Self {
let size = size.into();
self.size = size;
self
}
}
impl Transformable for Vector {
fn transform(mut self, transformation: Transformation) -> Self {
self.transformation = transformation;
self
}
}
impl RenderOnce for Vector {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let width = self.size.width;
let height = self.size.height;
svg()
// By default, prevent the SVG from stretching
// to fill its container.
.flex_none()
.w(width)
.h(height)
.path(self.path)
.text_color(self.color.color(cx))
.with_transformation(self.transformation)
}
}
impl Component for Vector {
fn scope() -> ComponentScope {
ComponentScope::Images
}
fn name() -> &'static str {
"Vector"
}
fn description() -> Option<&'static str> {
Some("A vector image component that can be displayed at specific sizes.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let size = rems_from_px(60.);
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic Usage",
vec![
single_example(
"Default",
Vector::square(VectorName::ZedLogo, size).into_any_element(),
),
single_example(
"Custom Size",
h_flex()
.h(rems_from_px(120.))
.justify_center()
.child(Vector::new(
VectorName::ZedLogo,
rems_from_px(120.),
rems_from_px(200.),
))
.into_any_element(),
),
],
),
example_group_with_title(
"Colored",
vec![
single_example(
"Accent Color",
Vector::square(VectorName::ZedLogo, size)
.color(Color::Accent)
.into_any_element(),
),
single_example(
"Error Color",
Vector::square(VectorName::ZedLogo, size)
.color(Color::Error)
.into_any_element(),
),
],
),
example_group_with_title(
"Different Vectors",
vec![single_example(
"Zed X Copilot",
Vector::square(VectorName::ZedXCopilot, rems_from_px(100.))
.into_any_element(),
)],
),
])
.into_any_element(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vector_path() {
assert_eq!(VectorName::ZedLogo.path().as_ref(), "images/zed_logo.svg");
}
}

View File

@@ -0,0 +1,602 @@
use std::{cmp::Ordering, ops::Range, rc::Rc};
use gpui::{AnyElement, App, Bounds, Entity, Hsla, Point, fill, point, size};
use gpui::{DispatchPhase, Hitbox, HitboxBehavior, MouseButton, MouseDownEvent, MouseMoveEvent};
use smallvec::SmallVec;
use crate::prelude::*;
/// Represents the colors used for different states of indent guides.
#[derive(Debug, Clone)]
pub struct IndentGuideColors {
/// The color of the indent guide when it's neither active nor hovered.
pub default: Hsla,
/// The color of the indent guide when it's hovered.
pub hover: Hsla,
/// The color of the indent guide when it's active.
pub active: Hsla,
}
impl IndentGuideColors {
/// Returns the indent guide colors that should be used for panels.
pub fn panel(cx: &App) -> Self {
Self {
default: cx.theme().colors().panel_indent_guide,
hover: cx.theme().colors().panel_indent_guide_hover,
active: cx.theme().colors().panel_indent_guide_active,
}
}
}
pub struct IndentGuides {
colors: IndentGuideColors,
indent_size: Pixels,
compute_indents_fn:
Option<Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> SmallVec<[usize; 64]>>>,
render_fn: Option<
Box<
dyn Fn(
RenderIndentGuideParams,
&mut Window,
&mut App,
) -> SmallVec<[RenderedIndentGuide; 12]>,
>,
>,
on_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>>,
}
pub fn indent_guides(indent_size: Pixels, colors: IndentGuideColors) -> IndentGuides {
IndentGuides {
colors,
indent_size,
compute_indents_fn: None,
render_fn: None,
on_click: None,
}
}
impl IndentGuides {
/// Sets the callback that will be called when the user clicks on an indent guide.
pub fn on_click(
mut self,
on_click: impl Fn(&IndentGuideLayout, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(on_click));
self
}
/// Sets the function that computes indents for uniform list decoration.
pub fn with_compute_indents_fn<V: Render>(
mut self,
entity: Entity<V>,
compute_indents_fn: impl Fn(
&mut V,
Range<usize>,
&mut Window,
&mut Context<V>,
) -> SmallVec<[usize; 64]>
+ 'static,
) -> Self {
let compute_indents_fn = Box::new(move |range, window: &mut Window, cx: &mut App| {
entity.update(cx, |this, cx| compute_indents_fn(this, range, window, cx))
});
self.compute_indents_fn = Some(compute_indents_fn);
self
}
/// Sets a custom callback that will be called when the indent guides need to be rendered.
pub fn with_render_fn<V: Render>(
mut self,
entity: Entity<V>,
render_fn: impl Fn(
&mut V,
RenderIndentGuideParams,
&mut Window,
&mut App,
) -> SmallVec<[RenderedIndentGuide; 12]>
+ 'static,
) -> Self {
let render_fn = move |params, window: &mut Window, cx: &mut App| {
entity.update(cx, |this, cx| render_fn(this, params, window, cx))
};
self.render_fn = Some(Box::new(render_fn));
self
}
fn render_from_layout(
&self,
indent_guides: SmallVec<[IndentGuideLayout; 12]>,
bounds: Bounds<Pixels>,
item_height: Pixels,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let mut indent_guides = if let Some(ref custom_render) = self.render_fn {
let params = RenderIndentGuideParams {
indent_guides,
indent_size: self.indent_size,
item_height,
};
custom_render(params, window, cx)
} else {
indent_guides
.into_iter()
.map(|layout| RenderedIndentGuide {
bounds: Bounds::new(
point(
layout.offset.x * self.indent_size,
layout.offset.y * item_height,
),
size(px(1.), layout.length * item_height),
),
layout,
is_active: false,
hitbox: None,
})
.collect()
};
for guide in &mut indent_guides {
guide.bounds.origin += bounds.origin;
if let Some(hitbox) = guide.hitbox.as_mut() {
hitbox.origin += bounds.origin;
}
}
let indent_guides = IndentGuidesElement {
indent_guides: Rc::new(indent_guides),
colors: self.colors.clone(),
on_hovered_indent_guide_click: self.on_click.clone(),
};
indent_guides.into_any_element()
}
}
/// Parameters for rendering indent guides.
pub struct RenderIndentGuideParams {
/// The calculated layouts for the indent guides to be rendered.
pub indent_guides: SmallVec<[IndentGuideLayout; 12]>,
/// The size of each indentation level in pixels.
pub indent_size: Pixels,
/// The height of each item in pixels.
pub item_height: Pixels,
}
/// Represents a rendered indent guide with its visual properties and interaction areas.
pub struct RenderedIndentGuide {
/// The bounds of the rendered indent guide in pixels.
pub bounds: Bounds<Pixels>,
/// The layout information for the indent guide.
pub layout: IndentGuideLayout,
/// Indicates whether the indent guide is currently active.
pub is_active: bool,
/// Can be used to customize the hitbox of the indent guide,
/// if this is set to `None`, the bounds of the indent guide will be used.
pub hitbox: Option<Bounds<Pixels>>,
}
/// Represents the layout information for an indent guide.
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct IndentGuideLayout {
/// The starting position of the indent guide, where x is the indentation level
/// and y is the starting row.
pub offset: Point<usize>,
/// The length of the indent guide in rows.
pub length: usize,
/// Indicates whether the indent guide continues beyond the visible bounds.
pub continues_offscreen: bool,
}
/// Implements the necessary functionality for rendering indent guides inside a uniform list.
mod uniform_list {
use gpui::UniformListDecoration;
use super::*;
impl UniformListDecoration for IndentGuides {
fn compute(
&self,
mut visible_range: Range<usize>,
bounds: Bounds<Pixels>,
_scroll_offset: Point<Pixels>,
item_height: Pixels,
item_count: usize,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let includes_trailing_indent = visible_range.end < item_count;
// Check if we have entries after the visible range,
// if so extend the visible range so we can fetch a trailing indent,
// which is needed to compute indent guides correctly.
if includes_trailing_indent {
visible_range.end += 1;
}
let Some(ref compute_indents_fn) = self.compute_indents_fn else {
panic!("compute_indents_fn is required for UniformListDecoration");
};
let visible_entries = &compute_indents_fn(visible_range.clone(), window, cx);
let indent_guides = compute_indent_guides(
visible_entries,
visible_range.start,
includes_trailing_indent,
);
self.render_from_layout(indent_guides, bounds, item_height, window, cx)
}
}
}
/// Implements the necessary functionality for rendering indent guides inside a sticky items.
mod sticky_items {
use crate::StickyItemsDecoration;
use super::*;
impl StickyItemsDecoration for IndentGuides {
fn compute(
&self,
indents: &SmallVec<[usize; 8]>,
bounds: Bounds<Pixels>,
_scroll_offset: Point<Pixels>,
item_height: Pixels,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let indent_guides = compute_indent_guides(indents, 0, false);
self.render_from_layout(indent_guides, bounds, item_height, window, cx)
}
}
}
struct IndentGuidesElement {
colors: IndentGuideColors,
indent_guides: Rc<SmallVec<[RenderedIndentGuide; 12]>>,
on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>>,
}
enum IndentGuidesElementPrepaintState {
Static,
Interactive {
hitboxes: Rc<SmallVec<[Hitbox; 12]>>,
on_hovered_indent_guide_click: Rc<dyn Fn(&IndentGuideLayout, &mut Window, &mut App)>,
},
}
impl Element for IndentGuidesElement {
type RequestLayoutState = ();
type PrepaintState = IndentGuidesElementPrepaintState;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(gpui::Style::default(), [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
if let Some(on_hovered_indent_guide_click) = self.on_hovered_indent_guide_click.clone() {
let hitboxes = self
.indent_guides
.as_ref()
.iter()
.map(|guide| {
window
.insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), HitboxBehavior::Normal)
})
.collect();
Self::PrepaintState::Interactive {
hitboxes: Rc::new(hitboxes),
on_hovered_indent_guide_click,
}
} else {
Self::PrepaintState::Static
}
}
fn paint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
window: &mut Window,
_cx: &mut App,
) {
let current_view = window.current_view();
match prepaint {
IndentGuidesElementPrepaintState::Static => {
for indent_guide in self.indent_guides.as_ref() {
let fill_color = if indent_guide.is_active {
self.colors.active
} else {
self.colors.default
};
window.paint_quad(fill(
window.pixel_snap_bounds(indent_guide.bounds),
fill_color,
));
}
}
IndentGuidesElementPrepaintState::Interactive {
hitboxes,
on_hovered_indent_guide_click,
} => {
window.on_mouse_event({
let hitboxes = hitboxes.clone();
let indent_guides = self.indent_guides.clone();
let on_hovered_indent_guide_click = on_hovered_indent_guide_click.clone();
move |event: &MouseDownEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
let mut active_hitbox_ix = None;
for (i, hitbox) in hitboxes.iter().enumerate() {
if hitbox.is_hovered(window) {
active_hitbox_ix = Some(i);
break;
}
}
let Some(active_hitbox_ix) = active_hitbox_ix else {
return;
};
let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
on_hovered_indent_guide_click(active_indent_guide, window, cx);
cx.stop_propagation();
window.prevent_default();
}
}
});
let mut hovered_hitbox_id = None;
for (i, hitbox) in hitboxes.iter().enumerate() {
window.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
let indent_guide = &self.indent_guides[i];
let fill_color = if hitbox.is_hovered(window) {
hovered_hitbox_id = Some(hitbox.id);
self.colors.hover
} else if indent_guide.is_active {
self.colors.active
} else {
self.colors.default
};
window.paint_quad(fill(
window.pixel_snap_bounds(indent_guide.bounds),
fill_color,
));
}
window.on_mouse_event({
let prev_hovered_hitbox_id = hovered_hitbox_id;
let hitboxes = hitboxes.clone();
move |_: &MouseMoveEvent, phase, window, cx| {
let mut hovered_hitbox_id = None;
for hitbox in hitboxes.as_ref() {
if hitbox.is_hovered(window) {
hovered_hitbox_id = Some(hitbox.id);
break;
}
}
if phase == DispatchPhase::Capture {
// If the hovered hitbox has changed, we need to re-paint the indent guides.
match (prev_hovered_hitbox_id, hovered_hitbox_id) {
(Some(prev_id), Some(id)) => {
if prev_id != id {
cx.notify(current_view)
}
}
(None, Some(_)) => cx.notify(current_view),
(Some(_), None) => cx.notify(current_view),
(None, None) => {}
}
}
}
});
}
}
}
}
impl IntoElement for IndentGuidesElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
fn compute_indent_guides(
indents: &[usize],
offset: usize,
includes_trailing_indent: bool,
) -> SmallVec<[IndentGuideLayout; 12]> {
let mut indent_guides = SmallVec::<[IndentGuideLayout; 12]>::new();
let mut indent_stack = SmallVec::<[IndentGuideLayout; 8]>::new();
let mut min_depth = usize::MAX;
for (row, &depth) in indents.iter().enumerate() {
if includes_trailing_indent && row == indents.len() - 1 {
continue;
}
let current_row = row + offset;
let current_depth = indent_stack.len();
if depth < min_depth {
min_depth = depth;
}
match depth.cmp(&current_depth) {
Ordering::Less => {
for _ in 0..(current_depth - depth) {
if let Some(guide) = indent_stack.pop() {
indent_guides.push(guide);
}
}
}
Ordering::Greater => {
for new_depth in current_depth..depth {
indent_stack.push(IndentGuideLayout {
offset: Point::new(new_depth, current_row),
length: current_row,
continues_offscreen: false,
});
}
}
_ => {}
}
for indent in indent_stack.iter_mut() {
indent.length = current_row - indent.offset.y + 1;
}
}
indent_guides.extend(indent_stack);
for guide in indent_guides.iter_mut() {
if includes_trailing_indent
&& guide.offset.y + guide.length == offset + indents.len().saturating_sub(1)
{
guide.continues_offscreen = indents
.last()
.map(|last_indent| guide.offset.x < *last_indent)
.unwrap_or(false);
}
}
indent_guides
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_indent_guides() {
fn assert_compute_indent_guides(
input: &[usize],
offset: usize,
includes_trailing_indent: bool,
expected: Vec<IndentGuideLayout>,
) {
use std::collections::HashSet;
assert_eq!(
compute_indent_guides(input, offset, includes_trailing_indent)
.into_vec()
.into_iter()
.collect::<HashSet<_>>(),
expected.into_iter().collect::<HashSet<_>>(),
);
}
assert_compute_indent_guides(
&[0, 1, 2, 2, 1, 0],
0,
false,
vec![
IndentGuideLayout {
offset: Point::new(0, 1),
length: 4,
continues_offscreen: false,
},
IndentGuideLayout {
offset: Point::new(1, 2),
length: 2,
continues_offscreen: false,
},
],
);
assert_compute_indent_guides(
&[2, 2, 2, 1, 1],
0,
false,
vec![
IndentGuideLayout {
offset: Point::new(0, 0),
length: 5,
continues_offscreen: false,
},
IndentGuideLayout {
offset: Point::new(1, 0),
length: 3,
continues_offscreen: false,
},
],
);
assert_compute_indent_guides(
&[1, 2, 3, 2, 1],
0,
false,
vec![
IndentGuideLayout {
offset: Point::new(0, 0),
length: 5,
continues_offscreen: false,
},
IndentGuideLayout {
offset: Point::new(1, 1),
length: 3,
continues_offscreen: false,
},
IndentGuideLayout {
offset: Point::new(2, 2),
length: 1,
continues_offscreen: false,
},
],
);
assert_compute_indent_guides(
&[0, 1, 0],
0,
true,
vec![IndentGuideLayout {
offset: Point::new(0, 1),
length: 1,
continues_offscreen: false,
}],
);
assert_compute_indent_guides(
&[0, 1, 1],
0,
true,
vec![IndentGuideLayout {
offset: Point::new(0, 1),
length: 1,
continues_offscreen: true,
}],
);
assert_compute_indent_guides(
&[0, 1, 2],
0,
true,
vec![IndentGuideLayout {
offset: Point::new(0, 1),
length: 1,
continues_offscreen: true,
}],
);
}
}

View File

@@ -0,0 +1,177 @@
use super::AnyIcon;
use crate::prelude::*;
#[derive(Default)]
enum IndicatorKind {
#[default]
Dot,
Bar,
Icon(AnyIcon),
}
#[derive(IntoElement, RegisterComponent)]
pub struct Indicator {
kind: IndicatorKind,
border_color: Option<Color>,
pub color: Color,
}
impl Indicator {
pub fn dot() -> Self {
Self {
kind: IndicatorKind::Dot,
border_color: None,
color: Color::Default,
}
}
pub fn bar() -> Self {
Self {
kind: IndicatorKind::Bar,
border_color: None,
color: Color::Default,
}
}
pub fn icon(icon: impl Into<AnyIcon>) -> Self {
Self {
kind: IndicatorKind::Icon(icon.into()),
border_color: None,
color: Color::Default,
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = Some(color);
self
}
}
impl RenderOnce for Indicator {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let container = div().flex_none();
let container = if let Some(border_color) = self.border_color {
if matches!(self.kind, IndicatorKind::Dot | IndicatorKind::Bar) {
container.border_1().border_color(border_color.color(cx))
} else {
container
}
} else {
container
};
match self.kind {
IndicatorKind::Icon(icon) => container
.child(icon.map(|icon| icon.custom_size(rems_from_px(8.)).color(self.color))),
IndicatorKind::Dot => container
.w_1p5()
.h_1p5()
.rounded_full()
.bg(self.color.color(cx)),
IndicatorKind::Bar => container
.w_full()
.h_1p5()
.rounded_t_sm()
.bg(self.color.color(cx)),
}
}
}
impl Component for Indicator {
fn scope() -> ComponentScope {
ComponentScope::Status
}
fn description() -> Option<&'static str> {
Some(
"Visual indicators used to represent status, notifications, or draw attention to specific elements.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Dot Indicators",
vec![
single_example("Default", Indicator::dot().into_any_element()),
single_example(
"Success",
Indicator::dot().color(Color::Success).into_any_element(),
),
single_example(
"Warning",
Indicator::dot().color(Color::Warning).into_any_element(),
),
single_example(
"Error",
Indicator::dot().color(Color::Error).into_any_element(),
),
single_example(
"With Border",
Indicator::dot()
.color(Color::Accent)
.border_color(Color::Default)
.into_any_element(),
),
],
),
example_group_with_title(
"Bar Indicators",
vec![
single_example("Default", Indicator::bar().into_any_element()),
single_example(
"Success",
Indicator::bar().color(Color::Success).into_any_element(),
),
single_example(
"Warning",
Indicator::bar().color(Color::Warning).into_any_element(),
),
single_example(
"Error",
Indicator::bar().color(Color::Error).into_any_element(),
),
],
),
example_group_with_title(
"Icon Indicators",
vec![
single_example(
"Default",
Indicator::icon(Icon::new(IconName::Circle)).into_any_element(),
),
single_example(
"Success",
Indicator::icon(Icon::new(IconName::Check))
.color(Color::Success)
.into_any_element(),
),
single_example(
"Warning",
Indicator::icon(Icon::new(IconName::Warning))
.color(Color::Warning)
.into_any_element(),
),
single_example(
"Error",
Indicator::icon(Icon::new(IconName::Close))
.color(Color::Error)
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,740 @@
use std::rc::Rc;
use crate::PlatformStyle;
use crate::utils::capitalize;
use crate::{Icon, IconName, IconSize, h_flex, prelude::*};
use gpui::{
Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke,
Modifiers, Window, relative,
};
use itertools::Itertools;
#[derive(Debug)]
enum Source {
Action {
action: Box<dyn Action>,
focus_handle: Option<FocusHandle>,
},
Keystrokes {
/// A keybinding consists of a set of keystrokes,
/// where each keystroke is a key and a set of modifier keys.
/// More than one keystroke produces a chord.
///
/// This should always contain at least one keystroke.
keystrokes: Rc<[KeybindingKeystroke]>,
},
}
impl Clone for Source {
fn clone(&self) -> Self {
match self {
Source::Action {
action,
focus_handle,
} => Source::Action {
action: action.boxed_clone(),
focus_handle: focus_handle.clone(),
},
Source::Keystrokes { keystrokes } => Source::Keystrokes {
keystrokes: keystrokes.clone(),
},
}
}
}
#[derive(Clone, Debug, IntoElement, RegisterComponent)]
pub struct KeyBinding {
source: Source,
size: Option<AbsoluteLength>,
/// The [`PlatformStyle`] to use when displaying this keybinding.
platform_style: PlatformStyle,
/// Determines whether the keybinding is meant for vim mode.
vim_mode: bool,
/// Indicates whether the keybinding is currently disabled.
disabled: bool,
}
struct VimStyle(bool);
impl Global for VimStyle {}
impl KeyBinding {
/// Returns the highest precedence keybinding for an action. This is the last binding added to
/// the keymap. User bindings are added after built-in bindings so that they take precedence.
pub fn for_action(action: &dyn Action, cx: &App) -> Self {
Self::new(action, None, cx)
}
/// 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: &App) -> Self {
Self::new(action, Some(focus.clone()), cx)
}
pub fn has_binding(&self, window: &Window) -> bool {
match &self.source {
Source::Action {
action,
focus_handle: Some(focus),
} => window
.highest_precedence_binding_for_action_in(action.as_ref(), focus)
.or_else(|| window.highest_precedence_binding_for_action(action.as_ref()))
.is_some(),
_ => false,
}
}
pub fn set_vim_mode(cx: &mut App, enabled: bool) {
cx.set_global(VimStyle(enabled));
}
fn is_vim_mode(cx: &App) -> bool {
cx.try_global::<VimStyle>().is_some_and(|g| g.0)
}
pub fn new(action: &dyn Action, focus_handle: Option<FocusHandle>, cx: &App) -> Self {
Self {
source: Source::Action {
action: action.boxed_clone(),
focus_handle,
},
size: None,
vim_mode: KeyBinding::is_vim_mode(cx),
platform_style: PlatformStyle::platform(),
disabled: false,
}
}
pub fn from_keystrokes(keystrokes: Rc<[KeybindingKeystroke]>, vim_mode: bool) -> Self {
Self {
source: Source::Keystrokes { keystrokes },
size: None,
vim_mode,
platform_style: PlatformStyle::platform(),
disabled: false,
}
}
/// Sets the [`PlatformStyle`] for this [`KeyBinding`].
pub fn platform_style(mut self, platform_style: PlatformStyle) -> Self {
self.platform_style = platform_style;
self
}
/// Sets the size for this [`KeyBinding`].
pub fn size(mut self, size: impl Into<AbsoluteLength>) -> Self {
self.size = Some(size.into());
self
}
/// Sets whether this keybinding is currently disabled.
/// Disabled keybinds will be rendered in a dimmed state.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
fn render_key(
key: &str,
color: Option<Color>,
platform_style: PlatformStyle,
size: impl Into<Option<AbsoluteLength>>,
) -> AnyElement {
let key_icon = icon_for_key(key, platform_style);
match key_icon {
Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
None => {
let key = capitalize(key);
Key::new(&key, color).size(size).into_any_element()
}
}
}
impl RenderOnce for KeyBinding {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let render_keybinding = |keystrokes: &[KeybindingKeystroke]| {
let color = self.disabled.then_some(Color::Disabled);
h_flex()
.debug_selector(|| {
format!(
"KEY_BINDING-{}",
keystrokes
.iter()
.map(|k| k.key().to_string())
.collect::<Vec<_>>()
.join(" ")
)
})
.gap(DynamicSpacing::Base04.rems(cx))
.flex_none()
.children(keystrokes.iter().map(|keystroke| {
h_flex()
.flex_none()
.py_0p5()
.rounded_xs()
.text_color(cx.theme().colors().text_muted)
.children(render_keybinding_keystroke(
keystroke,
color,
self.size,
PlatformStyle::platform(),
self.vim_mode,
))
}))
.into_any_element()
};
match self.source {
Source::Action {
action,
focus_handle,
} => focus_handle
.or_else(|| window.focused(cx))
.and_then(|focus| {
window.highest_precedence_binding_for_action_in(action.as_ref(), &focus)
})
.or_else(|| window.highest_precedence_binding_for_action(action.as_ref()))
.map(|binding| render_keybinding(binding.keystrokes())),
Source::Keystrokes { keystrokes } => Some(render_keybinding(keystrokes.as_ref())),
}
.unwrap_or_else(|| gpui::Empty.into_any_element())
}
}
pub fn render_keybinding_keystroke(
keystroke: &KeybindingKeystroke,
color: Option<Color>,
size: impl Into<Option<AbsoluteLength>>,
platform_style: PlatformStyle,
vim_mode: bool,
) -> Vec<AnyElement> {
let use_text = vim_mode
|| matches!(
platform_style,
PlatformStyle::Linux | PlatformStyle::Windows
);
let size = size.into();
if use_text {
let element = Key::new(
keystroke_text(
keystroke.modifiers(),
keystroke.key(),
platform_style,
vim_mode,
),
color,
)
.size(size)
.into_any_element();
vec![element]
} else {
let mut elements = Vec::new();
elements.extend(render_modifiers(
keystroke.modifiers(),
platform_style,
color,
size,
true,
));
elements.push(render_key(keystroke.key(), color, platform_style, size));
elements
}
}
fn icon_for_key(key: &str, platform_style: PlatformStyle) -> Option<IconName> {
match key {
"left" => Some(IconName::ArrowLeft),
"right" => Some(IconName::ArrowRight),
"up" => Some(IconName::ArrowUp),
"down" => Some(IconName::ArrowDown),
"backspace" => Some(IconName::Backspace),
"delete" => Some(IconName::Backspace),
"return" => Some(IconName::Return),
"enter" => Some(IconName::Return),
"tab" => Some(IconName::Tab),
"space" => Some(IconName::Space),
"escape" => Some(IconName::Escape),
"pagedown" => Some(IconName::PageDown),
"pageup" => Some(IconName::PageUp),
"shift" if platform_style == PlatformStyle::Mac => Some(IconName::Shift),
"control" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
"platform" if platform_style == PlatformStyle::Mac => Some(IconName::Command),
"function" if platform_style == PlatformStyle::Mac => Some(IconName::Control),
"alt" if platform_style == PlatformStyle::Mac => Some(IconName::Option),
_ => None,
}
}
pub fn render_modifiers(
modifiers: &Modifiers,
platform_style: PlatformStyle,
color: Option<Color>,
size: Option<AbsoluteLength>,
trailing_separator: bool,
) -> impl Iterator<Item = AnyElement> {
#[derive(Clone)]
enum KeyOrIcon {
Key(&'static str),
Plus,
Icon(IconName),
}
struct Modifier {
enabled: bool,
mac: KeyOrIcon,
linux: KeyOrIcon,
windows: KeyOrIcon,
}
let table = {
use KeyOrIcon::*;
[
Modifier {
enabled: modifiers.function,
mac: Icon(IconName::Control),
linux: Key("Fn"),
windows: Key("Fn"),
},
Modifier {
enabled: modifiers.control,
mac: Icon(IconName::Control),
linux: Key("Ctrl"),
windows: Key("Ctrl"),
},
Modifier {
enabled: modifiers.alt,
mac: Icon(IconName::Option),
linux: Key("Alt"),
windows: Key("Alt"),
},
Modifier {
enabled: modifiers.platform,
mac: Icon(IconName::Command),
linux: Key("Super"),
windows: Key("Win"),
},
Modifier {
enabled: modifiers.shift,
mac: Icon(IconName::Shift),
linux: Key("Shift"),
windows: Key("Shift"),
},
]
};
let filtered = table
.into_iter()
.filter(|modifier| modifier.enabled)
.collect::<Vec<_>>();
let platform_keys = filtered
.into_iter()
.map(move |modifier| match platform_style {
PlatformStyle::Mac => Some(modifier.mac),
PlatformStyle::Linux => Some(modifier.linux),
PlatformStyle::Windows => Some(modifier.windows),
});
let separator = match platform_style {
PlatformStyle::Mac => None,
PlatformStyle::Linux => Some(KeyOrIcon::Plus),
PlatformStyle::Windows => Some(KeyOrIcon::Plus),
};
let platform_keys = itertools::intersperse(platform_keys, separator.clone());
platform_keys
.chain(if modifiers.modified() && trailing_separator {
Some(separator)
} else {
None
})
.flatten()
.map(move |key_or_icon| match key_or_icon {
KeyOrIcon::Key(key) => Key::new(key, color).size(size).into_any_element(),
KeyOrIcon::Icon(icon) => KeyIcon::new(icon, color).size(size).into_any_element(),
KeyOrIcon::Plus => "+".into_any_element(),
})
}
#[derive(IntoElement)]
pub struct Key {
key: SharedString,
color: Option<Color>,
size: Option<AbsoluteLength>,
}
impl RenderOnce for Key {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let single_char = self.key.len() == 1;
let size = self
.size
.unwrap_or_else(|| TextSize::default().rems(cx).into());
div()
.py_0()
.map(|this| {
if single_char {
this.w(size).flex().flex_none().justify_center()
} else {
this.px_0p5()
}
})
.h(size)
.text_size(size)
.line_height(relative(1.))
.text_color(self.color.unwrap_or(Color::Muted).color(cx))
.child(self.key)
}
}
impl Key {
pub fn new(key: impl Into<SharedString>, color: Option<Color>) -> Self {
Self {
key: key.into(),
color,
size: None,
}
}
pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
self.size = size.into();
self
}
}
#[derive(IntoElement)]
pub struct KeyIcon {
icon: IconName,
color: Option<Color>,
size: Option<AbsoluteLength>,
}
impl RenderOnce for KeyIcon {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let size = self.size.unwrap_or(IconSize::Small.rems().into());
Icon::new(self.icon)
.size(IconSize::Custom(size.to_rems(window.rem_size())))
.color(self.color.unwrap_or(Color::Muted))
}
}
impl KeyIcon {
pub fn new(icon: IconName, color: Option<Color>) -> Self {
Self {
icon,
color,
size: None,
}
}
pub fn size(mut self, size: impl Into<Option<AbsoluteLength>>) -> Self {
self.size = size.into();
self
}
}
/// Returns a textual representation of the key binding for the given [`Action`].
pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option<String> {
let key_binding = window.highest_precedence_binding_for_action(action)?;
Some(text_for_keybinding_keystrokes(key_binding.keystrokes(), cx))
}
pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String {
let platform_style = PlatformStyle::platform();
let vim_enabled = KeyBinding::is_vim_mode(cx);
keystrokes
.iter()
.map(|keystroke| {
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
platform_style,
vim_enabled,
)
})
.join(" ")
}
pub fn text_for_keybinding_keystrokes(keystrokes: &[KeybindingKeystroke], cx: &App) -> String {
let platform_style = PlatformStyle::platform();
let vim_enabled = KeyBinding::is_vim_mode(cx);
keystrokes
.iter()
.map(|keystroke| {
keystroke_text(
keystroke.modifiers(),
keystroke.key(),
platform_style,
vim_enabled,
)
})
.join(" ")
}
pub fn text_for_keystroke(modifiers: &Modifiers, key: &str, cx: &App) -> String {
let platform_style = PlatformStyle::platform();
keystroke_text(modifiers, key, platform_style, KeyBinding::is_vim_mode(cx))
}
/// Returns a textual representation of the given [`Keystroke`].
fn keystroke_text(
modifiers: &Modifiers,
key: &str,
platform_style: PlatformStyle,
vim_mode: bool,
) -> String {
let mut text = String::new();
let delimiter = '-';
if modifiers.function {
match vim_mode {
false => text.push_str("Fn"),
true => text.push_str("fn"),
}
text.push(delimiter);
}
if modifiers.control {
match (platform_style, vim_mode) {
(PlatformStyle::Mac, false) => text.push_str("Control"),
(PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"),
(_, true) => text.push_str("ctrl"),
}
text.push(delimiter);
}
if modifiers.platform {
match (platform_style, vim_mode) {
(PlatformStyle::Mac, false) => text.push_str("Command"),
(PlatformStyle::Mac, true) => text.push_str("cmd"),
(PlatformStyle::Linux, false) => text.push_str("Super"),
(PlatformStyle::Linux, true) => text.push_str("super"),
(PlatformStyle::Windows, false) => text.push_str("Win"),
(PlatformStyle::Windows, true) => text.push_str("win"),
}
text.push(delimiter);
}
if modifiers.alt {
match (platform_style, vim_mode) {
(PlatformStyle::Mac, false) => text.push_str("Option"),
(PlatformStyle::Mac, true) => text.push_str("option"),
(PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"),
(_, true) => text.push_str("alt"),
}
text.push(delimiter);
}
if modifiers.shift {
match (platform_style, vim_mode) {
(_, false) => text.push_str("Shift"),
(_, true) => text.push_str("shift"),
}
text.push(delimiter);
}
if vim_mode {
text.push_str(key)
} else {
let key = match key {
"pageup" => "PageUp",
"pagedown" => "PageDown",
key => &capitalize(key),
};
text.push_str(key);
}
text
}
impl Component for KeyBinding {
fn scope() -> ComponentScope {
ComponentScope::Typography
}
fn name() -> &'static str {
"KeyBinding"
}
fn description() -> Option<&'static str> {
Some(
"A component that displays a key binding, supporting different platform styles and vim mode.",
)
}
// fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
// Some(
// v_flex()
// .gap_6()
// .children(vec![
// example_group_with_title(
// "Basic Usage",
// vec![
// single_example(
// "Default",
// KeyBinding::new_from_gpui(
// gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
// cx,
// )
// .into_any_element(),
// ),
// single_example(
// "Mac Style",
// KeyBinding::new_from_gpui(
// gpui::KeyBinding::new("cmd-s", gpui::NoAction, None),
// cx,
// )
// .platform_style(PlatformStyle::Mac)
// .into_any_element(),
// ),
// single_example(
// "Windows Style",
// KeyBinding::new_from_gpui(
// gpui::KeyBinding::new("ctrl-s", gpui::NoAction, None),
// cx,
// )
// .platform_style(PlatformStyle::Windows)
// .into_any_element(),
// ),
// ],
// ),
// example_group_with_title(
// "Vim Mode",
// vec![single_example(
// "Vim Mode Enabled",
// KeyBinding::new_from_gpui(
// gpui::KeyBinding::new("dd", gpui::NoAction, None),
// cx,
// )
// .vim_mode(true)
// .into_any_element(),
// )],
// ),
// example_group_with_title(
// "Complex Bindings",
// vec![
// single_example(
// "Multiple Keys",
// KeyBinding::new_from_gpui(
// gpui::KeyBinding::new("ctrl-k ctrl-b", gpui::NoAction, None),
// cx,
// )
// .into_any_element(),
// ),
// single_example(
// "With Shift",
// KeyBinding::new_from_gpui(
// gpui::KeyBinding::new("shift-cmd-p", gpui::NoAction, None),
// cx,
// )
// .into_any_element(),
// ),
// ],
// ),
// ])
// .into_any_element(),
// )
// }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_for_keystroke() {
let keystroke = Keystroke::parse("cmd-c").unwrap();
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Mac,
false
),
"Command-C".to_string()
);
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Linux,
false
),
"Super-C".to_string()
);
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Windows,
false
),
"Win-C".to_string()
);
let keystroke = Keystroke::parse("ctrl-alt-delete").unwrap();
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Mac,
false
),
"Control-Option-Delete".to_string()
);
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Linux,
false
),
"Ctrl-Alt-Delete".to_string()
);
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Windows,
false
),
"Ctrl-Alt-Delete".to_string()
);
let keystroke = Keystroke::parse("shift-pageup").unwrap();
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Mac,
false
),
"Shift-PageUp".to_string()
);
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Linux,
false,
),
"Shift-PageUp".to_string()
);
assert_eq!(
keystroke_text(
&keystroke.modifiers,
&keystroke.key,
PlatformStyle::Windows,
false
),
"Shift-PageUp".to_string()
);
}
}

View File

@@ -0,0 +1,332 @@
use crate::KeyBinding;
use crate::prelude::*;
use gpui::{AnyElement, App, BoxShadow, FontStyle, Hsla, IntoElement, Window, point};
use theme::Appearance;
/// Represents a hint for a keybinding, optionally with a prefix and suffix.
///
/// This struct allows for the creation and customization of a keybinding hint,
/// which can be used to display keyboard shortcuts or commands in a user interface.
///
/// # Examples
///
/// ```no_run
/// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke};
/// use ui::prelude::*;
/// use ui::{KeyBinding, KeybindingHint};
///
/// # fn example(cx: &App) {
/// let hint = KeybindingHint::new(
/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-s").unwrap())].into(), false),
/// Hsla::black()
/// )
/// .prefix("Save:")
/// .size(Pixels::from(14.0));
/// # }
/// ```
#[derive(Debug, IntoElement, RegisterComponent)]
pub struct KeybindingHint {
prefix: Option<SharedString>,
suffix: Option<SharedString>,
keybinding: KeyBinding,
size: Option<Pixels>,
background_color: Hsla,
}
impl KeybindingHint {
/// Creates a new `KeybindingHint` with the specified keybinding.
///
/// This method initializes a new `KeybindingHint` instance with the given keybinding,
/// setting all other fields to their default values.
///
/// # Examples
///
/// ```no_run
/// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke};
/// use ui::prelude::*;
/// use ui::{KeyBinding, KeybindingHint};
///
/// # fn example(cx: &App) {
/// let hint = KeybindingHint::new(
/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), false),
/// Hsla::black()
/// );
/// # }
/// ```
pub fn new(keybinding: KeyBinding, background_color: Hsla) -> Self {
Self {
prefix: None,
suffix: None,
keybinding,
size: None,
background_color,
}
}
/// Creates a new `KeybindingHint` with a prefix and keybinding.
///
/// This method initializes a new `KeybindingHint` instance with the given prefix and keybinding,
/// setting all other fields to their default values.
///
/// # Examples
///
/// ```no_run
/// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke};
/// use ui::prelude::*;
/// use ui::{KeyBinding, KeybindingHint};
///
/// # fn example(cx: &App) {
/// let hint = KeybindingHint::with_prefix(
/// "Copy:",
/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-c").unwrap())].into(), false),
/// Hsla::black()
/// );
/// # }
/// ```
pub fn with_prefix(
prefix: impl Into<SharedString>,
keybinding: KeyBinding,
background_color: Hsla,
) -> Self {
Self {
prefix: Some(prefix.into()),
suffix: None,
keybinding,
size: None,
background_color,
}
}
/// Creates a new `KeybindingHint` with a keybinding and suffix.
///
/// This method initializes a new `KeybindingHint` instance with the given keybinding and suffix,
/// setting all other fields to their default values.
///
/// # Examples
///
/// ```no_run
/// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke};
/// use ui::prelude::*;
/// use ui::{KeyBinding, KeybindingHint};
///
/// # fn example(cx: &App) {
/// let hint = KeybindingHint::with_suffix(
/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-v").unwrap())].into(), false),
/// "Paste",
/// Hsla::black()
/// );
/// # }
/// ```
pub fn with_suffix(
keybinding: KeyBinding,
suffix: impl Into<SharedString>,
background_color: Hsla,
) -> Self {
Self {
prefix: None,
suffix: Some(suffix.into()),
keybinding,
size: None,
background_color,
}
}
/// Sets the prefix for the keybinding hint.
///
/// This method allows adding or changing the prefix text that appears before the keybinding.
///
/// # Examples
///
/// ```no_run
/// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke};
/// use ui::prelude::*;
/// use ui::{KeyBinding, KeybindingHint};
///
/// # fn example(cx: &App) {
/// let hint = KeybindingHint::new(
/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-x").unwrap())].into(), false),
/// Hsla::black()
/// )
/// .prefix("Cut:");
/// # }
/// ```
pub fn prefix(mut self, prefix: impl Into<SharedString>) -> Self {
self.prefix = Some(prefix.into());
self
}
/// Sets the suffix for the keybinding hint.
///
/// This method allows adding or changing the suffix text that appears after the keybinding.
///
/// # Examples
///
/// ```no_run
/// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke};
/// use ui::prelude::*;
/// use ui::{KeyBinding, KeybindingHint};
///
/// # fn example(cx: &App) {
/// let hint = KeybindingHint::new(
/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-f").unwrap())].into(), false),
/// Hsla::black()
/// )
/// .suffix("Find");
/// # }
/// ```
pub fn suffix(mut self, suffix: impl Into<SharedString>) -> Self {
self.suffix = Some(suffix.into());
self
}
/// Sets the size of the keybinding hint.
///
/// This method allows specifying the size of the keybinding hint in pixels.
///
/// # Examples
///
/// ```no_run
/// use gpui::{App, Hsla, KeybindingKeystroke, Keystroke};
/// use ui::prelude::*;
/// use ui::{KeyBinding, KeybindingHint};
///
/// # fn example(cx: &App) {
/// let hint = KeybindingHint::new(
/// KeyBinding::from_keystrokes(vec![KeybindingKeystroke::from_keystroke(Keystroke::parse("ctrl-z").unwrap())].into(), false),
/// Hsla::black()
/// )
/// .size(Pixels::from(16.0));
/// # }
/// ```
pub fn size(mut self, size: impl Into<Option<Pixels>>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for KeybindingHint {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let colors = cx.theme().colors();
let is_light = cx.theme().appearance() == Appearance::Light;
let border_color =
self.background_color
.blend(colors.text.alpha(if is_light { 0.08 } else { 0.16 }));
let bg_color = self
.background_color
.blend(colors.text_accent.alpha(if is_light { 0.05 } else { 0.1 }));
let shadow_color = colors.text.alpha(if is_light { 0.04 } else { 0.08 });
let size = self
.size
.unwrap_or(TextSize::Small.rems(cx).to_pixels(window.rem_size()));
let kb_size = size - px(2.0);
let mut base = h_flex();
base.text_style().font_style = Some(FontStyle::Italic);
base.gap_1()
.font_buffer(cx)
.text_size(size)
.text_color(colors.text_disabled)
.children(self.prefix)
.child(
h_flex()
.rounded_sm()
.px_0p5()
.mr_0p5()
.border_1()
.border_color(border_color)
.bg(bg_color)
.shadow(vec![BoxShadow {
color: shadow_color,
offset: point(px(0.), px(1.)),
blur_radius: px(0.),
spread_radius: px(0.),
}])
.child(self.keybinding.size(rems_from_px(kb_size))),
)
.children(self.suffix)
}
}
impl Component for KeybindingHint {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn description() -> Option<&'static str> {
Some("Displays a keyboard shortcut hint with optional prefix and suffix text")
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let enter = KeyBinding::for_action(&menu::Confirm, cx);
let bg_color = cx.theme().colors().surface_background;
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic",
vec![
single_example(
"With Prefix",
KeybindingHint::with_prefix(
"Go to Start:",
enter.clone(),
bg_color,
)
.into_any_element(),
),
single_example(
"With Suffix",
KeybindingHint::with_suffix(enter.clone(), "Go to End", bg_color)
.into_any_element(),
),
single_example(
"With Prefix and Suffix",
KeybindingHint::new(enter.clone(), bg_color)
.prefix("Confirm:")
.suffix("Execute selected action")
.into_any_element(),
),
],
),
example_group_with_title(
"Sizes",
vec![
single_example(
"Small",
KeybindingHint::new(enter.clone(), bg_color)
.size(Pixels::from(12.0))
.prefix("Small:")
.into_any_element(),
),
single_example(
"Medium",
KeybindingHint::new(enter.clone(), bg_color)
.size(Pixels::from(16.0))
.suffix("Medium")
.into_any_element(),
),
single_example(
"Large",
KeybindingHint::new(enter, bg_color)
.size(Pixels::from(20.0))
.prefix("Large:")
.suffix("Size")
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,11 @@
mod highlighted_label;
mod label;
mod label_like;
mod loading_label;
mod spinner_label;
pub use highlighted_label::*;
pub use label::*;
pub use label_like::*;
pub use loading_label::*;
pub use spinner_label::*;

View File

@@ -0,0 +1,303 @@
use std::ops::Range;
use gpui::{FontWeight, HighlightStyle, StyleRefinement, StyledText};
use crate::{LabelCommon, LabelLike, LabelSize, LineHeightStyle, prelude::*};
#[derive(IntoElement, RegisterComponent)]
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 {
let label = label.into();
for &run in &highlight_indices {
assert!(
label.is_char_boundary(run),
"highlight index {run} is not a valid UTF-8 boundary"
);
}
Self {
base: LabelLike::new(),
label,
highlight_indices,
}
}
/// Constructs a label with the given byte ranges highlighted.
/// Assumes that the highlight ranges are valid UTF-8 byte positions.
pub fn from_ranges(
label: impl Into<SharedString>,
highlight_ranges: Vec<Range<usize>>,
) -> Self {
let label = label.into();
let highlight_indices = highlight_ranges
.iter()
.flat_map(|range| {
let mut indices = Vec::new();
let mut index = range.start;
while index < range.end {
indices.push(index);
index += label[index..].chars().next().map_or(0, |c| c.len_utf8());
}
indices
})
.collect();
Self {
base: LabelLike::new(),
label,
highlight_indices,
}
}
pub fn text(&self) -> &str {
self.label.as_str()
}
pub fn highlight_indices(&self) -> &[usize] {
&self.highlight_indices
}
}
impl HighlightedLabel {
fn style(&mut self) -> &mut StyleRefinement {
self.base.base.style()
}
pub fn flex_1(mut self) -> Self {
self.style().flex_grow = Some(1.);
self.style().flex_shrink = Some(1.);
self.style().flex_basis = Some(gpui::relative(0.).into());
self
}
pub fn flex_none(mut self) -> Self {
self.style().flex_grow = Some(0.);
self.style().flex_shrink = Some(0.);
self
}
pub fn flex_grow(mut self) -> Self {
self.style().flex_grow = Some(1.);
self
}
pub fn flex_shrink(mut self) -> Self {
self.style().flex_shrink = Some(1.);
self
}
pub fn flex_shrink_0(mut self) -> Self {
self.style().flex_shrink = Some(0.);
self
}
}
impl LabelCommon for HighlightedLabel {
fn size(mut self, size: LabelSize) -> Self {
self.base = self.base.size(size);
self
}
fn weight(mut self, weight: FontWeight) -> Self {
self.base = self.base.weight(weight);
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) -> Self {
self.base = self.base.strikethrough();
self
}
fn italic(mut self) -> Self {
self.base = self.base.italic();
self
}
fn alpha(mut self, alpha: f32) -> Self {
self.base = self.base.alpha(alpha);
self
}
fn underline(mut self) -> Self {
self.base = self.base.underline();
self
}
fn truncate(mut self) -> Self {
self.base = self.base.truncate();
self
}
fn single_line(mut self) -> Self {
self.base = self.base.single_line();
self
}
fn buffer_font(mut self, cx: &App) -> Self {
self.base = self.base.buffer_font(cx);
self
}
fn inline_code(mut self, cx: &App) -> Self {
self.base = self.base.inline_code(cx);
self
}
}
pub fn highlight_ranges(
text: &str,
indices: &[usize],
style: HighlightStyle,
) -> Vec<(Range<usize>, HighlightStyle)> {
let mut highlight_indices = 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 += text[end_ix..].chars().next().map_or(0, |c| c.len_utf8());
if highlight_indices.next_if(|&ix| ix == end_ix).is_none() {
break;
}
}
highlights.push((start_ix..end_ix, style));
}
highlights
}
impl RenderOnce for HighlightedLabel {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let highlight_color = cx.theme().colors().text_accent;
let highlights = highlight_ranges(
&self.label,
&self.highlight_indices,
HighlightStyle {
color: Some(highlight_color),
..Default::default()
},
);
let mut text_style = window.text_style();
text_style.color = self.base.color.color(cx);
self.base
.child(StyledText::new(self.label).with_default_highlights(&text_style, highlights))
}
}
impl Component for HighlightedLabel {
fn scope() -> ComponentScope {
ComponentScope::Typography
}
fn name() -> &'static str {
"HighlightedLabel"
}
fn description() -> Option<&'static str> {
Some("A label with highlighted characters based on specified indices.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic Usage",
vec![
single_example(
"Default",
HighlightedLabel::new("Highlighted Text", vec![0, 1, 2, 3]).into_any_element(),
),
single_example(
"Custom Color",
HighlightedLabel::new("Colored Highlight", vec![0, 1, 7, 8, 9])
.color(Color::Accent)
.into_any_element(),
),
],
),
example_group_with_title(
"Styles",
vec![
single_example(
"Bold",
HighlightedLabel::new("Bold Highlight", vec![0, 1, 2, 3])
.weight(FontWeight::BOLD)
.into_any_element(),
),
single_example(
"Italic",
HighlightedLabel::new("Italic Highlight", vec![0, 1, 6, 7, 8])
.italic()
.into_any_element(),
),
single_example(
"Underline",
HighlightedLabel::new("Underlined Highlight", vec![0, 1, 10, 11, 12])
.underline()
.into_any_element(),
),
],
),
example_group_with_title(
"Sizes",
vec![
single_example(
"Small",
HighlightedLabel::new("Small Highlight", vec![0, 1, 5, 6, 7])
.size(LabelSize::Small)
.into_any_element(),
),
single_example(
"Large",
HighlightedLabel::new("Large Highlight", vec![0, 1, 5, 6, 7])
.size(LabelSize::Large)
.into_any_element(),
),
],
),
example_group_with_title(
"Special Cases",
vec![
single_example(
"Single Line",
HighlightedLabel::new("Single Line Highlight\nWith Newline", vec![0, 1, 7, 8, 9])
.single_line()
.into_any_element(),
),
single_example(
"Truncate",
HighlightedLabel::new("This is a very long text that should be truncated with highlights", vec![0, 1, 2, 3, 4, 5])
.truncate()
.into_any_element(),
),
],
),
])
.into_any_element()
)
}
}

View File

@@ -0,0 +1,410 @@
use std::ops::Range;
use crate::{LabelLike, prelude::*};
use gpui::{HighlightStyle, StyleRefinement, StyledText};
/// A struct representing a label element in the UI.
///
/// The `Label` struct stores the label text and common properties for a label element.
/// It provides methods for modifying these properties.
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// Label::new("Hello, World!");
/// ```
///
/// **A colored label**, for example labeling a dangerous action:
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Delete").color(Color::Error);
/// ```
///
/// **A label with a strikethrough**, for example labeling something that has been deleted:
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Deleted").strikethrough();
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct Label {
base: LabelLike,
label: SharedString,
render_code_spans: bool,
}
impl Label {
/// Creates a new [`Label`] with the given text.
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!");
/// ```
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
base: LabelLike::new(),
label: label.into(),
render_code_spans: false,
}
}
/// When enabled, text wrapped in backticks (e.g. `` `code` ``) will be
/// rendered in the buffer (monospace) font.
pub fn render_code_spans(mut self) -> Self {
self.render_code_spans = true;
self
}
/// Sets the text of the [`Label`].
pub fn set_text(&mut self, text: impl Into<SharedString>) {
self.label = text.into();
}
/// Truncates the label from the start, keeping the end visible.
pub fn truncate_start(mut self) -> Self {
self.base = self.base.truncate_start();
self
}
}
// Style methods.
impl Label {
fn style(&mut self) -> &mut StyleRefinement {
self.base.base.style()
}
gpui::margin_style_methods!({
visibility: pub
});
pub fn flex_1(mut self) -> Self {
self.style().flex_grow = Some(1.);
self.style().flex_shrink = Some(1.);
self.style().flex_basis = Some(gpui::relative(0.).into());
self
}
pub fn flex_none(mut self) -> Self {
self.style().flex_grow = Some(0.);
self.style().flex_shrink = Some(0.);
self
}
pub fn flex_grow(mut self) -> Self {
self.style().flex_grow = Some(1.);
self
}
pub fn flex_shrink(mut self) -> Self {
self.style().flex_shrink = Some(1.);
self
}
pub fn flex_shrink_0(mut self) -> Self {
self.style().flex_shrink = Some(0.);
self
}
}
impl LabelCommon for Label {
/// Sets the size of the label using a [`LabelSize`].
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").size(LabelSize::Small);
/// ```
fn size(mut self, size: LabelSize) -> Self {
self.base = self.base.size(size);
self
}
/// Sets the weight of the label using a [`FontWeight`].
///
/// # Examples
///
/// ```
/// use gpui::FontWeight;
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").weight(FontWeight::BOLD);
/// ```
fn weight(mut self, weight: gpui::FontWeight) -> Self {
self.base = self.base.weight(weight);
self
}
/// Sets the line height style of the label using a [`LineHeightStyle`].
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").line_height_style(LineHeightStyle::UiLabel);
/// ```
fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
self.base = self.base.line_height_style(line_height_style);
self
}
/// Sets the color of the label using a [`Color`].
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").color(Color::Accent);
/// ```
fn color(mut self, color: Color) -> Self {
self.base = self.base.color(color);
self
}
/// Sets the strikethrough property of the label.
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").strikethrough();
/// ```
fn strikethrough(mut self) -> Self {
self.base = self.base.strikethrough();
self
}
/// Sets the italic property of the label.
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").italic();
/// ```
fn italic(mut self) -> Self {
self.base = self.base.italic();
self
}
/// Sets the alpha property of the color of label.
///
/// # Examples
///
/// ```
/// use ui::prelude::*;
///
/// let my_label = Label::new("Hello, World!").alpha(0.5);
/// ```
fn alpha(mut self, alpha: f32) -> Self {
self.base = self.base.alpha(alpha);
self
}
fn underline(mut self) -> Self {
self.base = self.base.underline();
self
}
/// Truncates overflowing text with an ellipsis (`…`) if needed.
fn truncate(mut self) -> Self {
self.base = self.base.truncate();
self
}
fn single_line(mut self) -> Self {
self.label = SharedString::from(self.label.replace('\n', ""));
self.base = self.base.single_line();
self
}
fn buffer_font(mut self, cx: &App) -> Self {
self.base = self.base.buffer_font(cx);
self
}
/// Styles the label to look like inline code.
fn inline_code(mut self, cx: &App) -> Self {
self.base = self.base.inline_code(cx);
self
}
}
impl RenderOnce for Label {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.render_code_spans {
if let Some((stripped, code_ranges)) = parse_backtick_spans(&self.label) {
let buffer_font_family = theme::theme_settings(cx).buffer_font(cx).family.clone();
let background_color = cx.theme().colors().element_background;
let highlights = code_ranges.iter().map(|range| {
(
range.clone(),
HighlightStyle {
background_color: Some(background_color),
..Default::default()
},
)
});
let font_overrides = code_ranges
.iter()
.map(|range| (range.clone(), buffer_font_family.clone()));
return self.base.child(
StyledText::new(stripped)
.with_highlights(highlights)
.with_font_family_overrides(font_overrides),
);
}
}
self.base.child(self.label)
}
}
/// Parses backtick-delimited code spans from a string.
///
/// Returns `None` if there are no matched backtick pairs.
/// Otherwise returns the text with backticks stripped and the byte ranges
/// of the code spans in the stripped string.
fn parse_backtick_spans(text: &str) -> Option<(SharedString, Vec<Range<usize>>)> {
if !text.contains('`') {
return None;
}
let mut stripped = String::with_capacity(text.len());
let mut code_ranges = Vec::new();
let mut in_code = false;
let mut code_start = 0;
for ch in text.chars() {
if ch == '`' {
if in_code {
code_ranges.push(code_start..stripped.len());
} else {
code_start = stripped.len();
}
in_code = !in_code;
} else {
stripped.push(ch);
}
}
if code_ranges.is_empty() {
return None;
}
Some((SharedString::from(stripped), code_ranges))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_backtick_spans_no_backticks() {
assert_eq!(parse_backtick_spans("plain text"), None);
}
#[test]
fn test_parse_backtick_spans_single_span() {
let (text, ranges) = parse_backtick_spans("use `zed` to open").unwrap();
assert_eq!(text.as_ref(), "use zed to open");
assert_eq!(ranges, vec![4..7]);
}
#[test]
fn test_parse_backtick_spans_multiple_spans() {
let (text, ranges) = parse_backtick_spans("flags `-e` or `-n`").unwrap();
assert_eq!(text.as_ref(), "flags -e or -n");
assert_eq!(ranges, vec![6..8, 12..14]);
}
#[test]
fn test_parse_backtick_spans_unmatched_backtick() {
// A trailing unmatched backtick should not produce a code range
assert_eq!(parse_backtick_spans("trailing `backtick"), None);
}
#[test]
fn test_parse_backtick_spans_empty_span() {
let (text, ranges) = parse_backtick_spans("empty `` span").unwrap();
assert_eq!(text.as_ref(), "empty span");
assert_eq!(ranges, vec![6..6]);
}
}
impl Component for Label {
fn scope() -> ComponentScope {
ComponentScope::Typography
}
fn description() -> Option<&'static str> {
Some("A text label component that supports various styles, sizes, and formatting options.")
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Sizes",
vec![
single_example("Default", Label::new("Project Explorer").into_any_element()),
single_example("Small", Label::new("File: main.rs").size(LabelSize::Small).into_any_element()),
single_example("Large", Label::new("Welcome to Zed").size(LabelSize::Large).into_any_element()),
],
),
example_group_with_title(
"Colors",
vec![
single_example("Default", Label::new("Status: Ready").into_any_element()),
single_example("Accent", Label::new("New Update Available").color(Color::Accent).into_any_element()),
single_example("Error", Label::new("Build Failed").color(Color::Error).into_any_element()),
],
),
example_group_with_title(
"Styles",
vec![
single_example("Default", Label::new("Normal Text").into_any_element()),
single_example("Bold", Label::new("Important Notice").weight(gpui::FontWeight::BOLD).into_any_element()),
single_example("Italic", Label::new("Code Comment").italic().into_any_element()),
single_example("Strikethrough", Label::new("Deprecated Feature").strikethrough().into_any_element()),
single_example("Underline", Label::new("Clickable Link").underline().into_any_element()),
single_example("Inline Code", Label::new("fn main() {}").inline_code(cx).into_any_element()),
],
),
example_group_with_title(
"Line Height Styles",
vec![
single_example("Default", Label::new("Multi-line\nText\nExample").into_any_element()),
single_example("UI Label", Label::new("Compact\nUI\nLabel").line_height_style(LineHeightStyle::UiLabel).into_any_element()),
],
),
example_group_with_title(
"Special Cases",
vec![
single_example("Single Line", Label::new("Line 1\nLine 2\nLine 3").single_line().into_any_element()),
single_example("Regular Truncation", div().max_w_24().child(Label::new("This is a very long file name that should be truncated: very_long_file_name_with_many_words.rs").truncate()).into_any_element()),
single_example("Start Truncation", div().max_w_24().child(Label::new("zed/crates/ui/src/components/label/truncate/label/label.rs").truncate_start()).into_any_element()),
],
),
])
.into_any_element()
)
}
}

View File

@@ -0,0 +1,331 @@
use crate::prelude::*;
use gpui::{FontWeight, Rems, StyleRefinement, UnderlineStyle};
use smallvec::SmallVec;
/// Sets the size of a label
#[derive(Debug, PartialEq, Clone, Copy, Default)]
pub enum LabelSize {
/// The default size of a label.
#[default]
Default,
/// The large size of a label.
Large,
/// The small size of a label.
Small,
/// The extra small size of a label.
XSmall,
/// An arbitrary custom size specified in rems.
Custom(Rems),
}
/// Sets the line height of a label
#[derive(Default, PartialEq, Copy, Clone)]
pub enum LineHeightStyle {
/// The default line height style of a label,
/// set by either the UI's default line height,
/// or the developer's default buffer line height.
#[default]
TextLabel,
/// Sets the line height to 1.
UiLabel,
}
/// A common set of traits all labels must implement.
pub trait LabelCommon {
/// Sets the size of the label using a [`LabelSize`].
fn size(self, size: LabelSize) -> Self;
/// Sets the font weight of the label.
fn weight(self, weight: FontWeight) -> Self;
/// Sets the line height style of the label using a [`LineHeightStyle`].
fn line_height_style(self, line_height_style: LineHeightStyle) -> Self;
/// Sets the color of the label using a [`Color`].
fn color(self, color: Color) -> Self;
/// Sets the strikethrough property of the label.
fn strikethrough(self) -> Self;
/// Sets the italic property of the label.
fn italic(self) -> Self;
/// Sets the underline property of the label
fn underline(self) -> Self;
/// Sets the alpha property of the label, overwriting the alpha value of the color.
fn alpha(self, alpha: f32) -> Self;
/// Truncates overflowing text with an ellipsis (`…`) at the end if needed.
fn truncate(self) -> Self;
/// Sets the label to render as a single line.
fn single_line(self) -> Self;
/// Sets the font to the buffer's
fn buffer_font(self, cx: &App) -> Self;
/// Styles the label to look like inline code.
fn inline_code(self, cx: &App) -> Self;
}
/// A label-like element that can be used to create a custom label when
/// prebuilt labels 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 labels.
#[derive(IntoElement)]
pub struct LabelLike {
pub(super) base: Div,
size: LabelSize,
weight: Option<FontWeight>,
line_height_style: LineHeightStyle,
pub(crate) color: Color,
strikethrough: bool,
italic: bool,
children: SmallVec<[AnyElement; 2]>,
alpha: Option<f32>,
underline: bool,
single_line: bool,
truncate: bool,
truncate_start: bool,
}
impl Default for LabelLike {
fn default() -> Self {
Self::new()
}
}
impl LabelLike {
/// Creates a new, fully custom label.
/// Prefer using [`Label`] or [`HighlightedLabel`] where possible.
pub fn new() -> Self {
Self {
base: div(),
size: LabelSize::Default,
weight: None,
line_height_style: LineHeightStyle::default(),
color: Color::Default,
strikethrough: false,
italic: false,
children: SmallVec::new(),
alpha: None,
underline: false,
single_line: false,
truncate: false,
truncate_start: false,
}
}
}
// Style methods.
impl LabelLike {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
gpui::margin_style_methods!({
visibility: pub
});
/// Truncates overflowing text with an ellipsis (`…`) at the start if needed.
pub fn truncate_start(mut self) -> Self {
self.truncate_start = true;
self
}
}
impl LabelCommon for LabelLike {
fn size(mut self, size: LabelSize) -> Self {
self.size = size;
self
}
fn weight(mut self, weight: FontWeight) -> Self {
self.weight = Some(weight);
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) -> Self {
self.strikethrough = true;
self
}
fn italic(mut self) -> Self {
self.italic = true;
self
}
fn underline(mut self) -> Self {
self.underline = true;
self
}
fn alpha(mut self, alpha: f32) -> Self {
self.alpha = Some(alpha);
self
}
/// Truncates overflowing text with an ellipsis (`…`) at the end if needed.
fn truncate(mut self) -> Self {
self.truncate = true;
self
}
fn single_line(mut self) -> Self {
self.single_line = true;
self
}
fn buffer_font(mut self, cx: &App) -> Self {
let font = theme::theme_settings(cx).buffer_font(cx).clone();
self.weight = Some(font.weight);
self.base = self.base.font(font);
self
}
fn inline_code(mut self, cx: &App) -> Self {
self.base = self
.base
.font(theme::theme_settings(cx).buffer_font(cx).clone())
.bg(cx.theme().colors().element_background)
.rounded_sm()
.px_0p5();
self
}
}
impl ParentElement for LabelLike {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for LabelLike {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let mut color = self.color.color(cx);
if let Some(alpha) = self.alpha {
color.fade_out(1.0 - alpha);
}
self.base
.map(|this| match self.size {
LabelSize::Large => this.text_ui_lg(cx),
LabelSize::Default => this.text_ui(cx),
LabelSize::Small => this.text_ui_sm(cx),
LabelSize::XSmall => this.text_ui_xs(cx),
LabelSize::Custom(size) => this.text_size(size),
})
.when(self.line_height_style == LineHeightStyle::UiLabel, |this| {
this.line_height(relative(1.))
})
.when(self.italic, |this| this.italic())
.when(self.underline, |mut this| {
this.text_style().underline = Some(UnderlineStyle {
thickness: px(1.),
color: Some(cx.theme().colors().text_muted.opacity(0.4)),
wavy: false,
});
this
})
.when(self.strikethrough, |this| this.line_through())
.when(self.single_line, |this| this.whitespace_nowrap())
.when(self.truncate, |this| {
this.min_w_0()
.overflow_x_hidden()
.whitespace_nowrap()
.text_ellipsis()
})
.when(self.truncate_start, |this| {
this.min_w_0()
.overflow_x_hidden()
.whitespace_nowrap()
.text_ellipsis_start()
})
.text_color(color)
.font_weight(
self.weight
.unwrap_or(theme::theme_settings(cx).ui_font(cx).weight),
)
.children(self.children)
}
}
impl Component for LabelLike {
fn scope() -> ComponentScope {
ComponentScope::Typography
}
fn name() -> &'static str {
"LabelLike"
}
fn description() -> Option<&'static str> {
Some(
"A flexible, customizable label-like component that serves as a base for other label types.",
)
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Sizes",
vec![
single_example("Default", LabelLike::new().child("Default size").into_any_element()),
single_example("Large", LabelLike::new().size(LabelSize::Large).child("Large size").into_any_element()),
single_example("Small", LabelLike::new().size(LabelSize::Small).child("Small size").into_any_element()),
single_example("XSmall", LabelLike::new().size(LabelSize::XSmall).child("Extra small size").into_any_element()),
],
),
example_group_with_title(
"Styles",
vec![
single_example("Bold", LabelLike::new().weight(FontWeight::BOLD).child("Bold text").into_any_element()),
single_example("Italic", LabelLike::new().italic().child("Italic text").into_any_element()),
single_example("Underline", LabelLike::new().underline().child("Underlined text").into_any_element()),
single_example("Strikethrough", LabelLike::new().strikethrough().child("Strikethrough text").into_any_element()),
single_example("Inline Code", LabelLike::new().inline_code(cx).child("const value = 42;").into_any_element()),
],
),
example_group_with_title(
"Colors",
vec![
single_example("Default", LabelLike::new().child("Default color").into_any_element()),
single_example("Accent", LabelLike::new().color(Color::Accent).child("Accent color").into_any_element()),
single_example("Error", LabelLike::new().color(Color::Error).child("Error color").into_any_element()),
single_example("Alpha", LabelLike::new().alpha(0.5).child("50% opacity").into_any_element()),
],
),
example_group_with_title(
"Line Height",
vec![
single_example("Default", LabelLike::new().child("Default line height\nMulti-line text").into_any_element()),
single_example("UI Label", LabelLike::new().line_height_style(LineHeightStyle::UiLabel).child("UI label line height\nMulti-line text").into_any_element()),
],
),
example_group_with_title(
"Special Cases",
vec![
single_example("Single Line", LabelLike::new().single_line().child("This is a very long text that should be displayed in a single line").into_any_element()),
single_example("Truncate", LabelLike::new().truncate().child("This is a very long text that should be truncated with an ellipsis").into_any_element()),
],
),
])
.into_any_element()
)
}
}

View File

@@ -0,0 +1,113 @@
use crate::prelude::*;
use gpui::{Animation, AnimationExt, FontWeight};
use std::time::Duration;
#[derive(IntoElement)]
pub struct LoadingLabel {
base: Label,
text: SharedString,
}
impl LoadingLabel {
pub fn new(text: impl Into<SharedString>) -> Self {
let text = text.into();
LoadingLabel {
base: Label::new(text.clone()),
text,
}
}
}
impl LabelCommon for LoadingLabel {
fn size(mut self, size: LabelSize) -> Self {
self.base = self.base.size(size);
self
}
fn weight(mut self, weight: FontWeight) -> Self {
self.base = self.base.weight(weight);
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) -> Self {
self.base = self.base.strikethrough();
self
}
fn italic(mut self) -> Self {
self.base = self.base.italic();
self
}
fn alpha(mut self, alpha: f32) -> Self {
self.base = self.base.alpha(alpha);
self
}
fn underline(mut self) -> Self {
self.base = self.base.underline();
self
}
fn truncate(mut self) -> Self {
self.base = self.base.truncate();
self
}
fn single_line(mut self) -> Self {
self.base = self.base.single_line();
self
}
fn buffer_font(mut self, cx: &App) -> Self {
self.base = self.base.buffer_font(cx);
self
}
fn inline_code(mut self, cx: &App) -> Self {
self.base = self.base.inline_code(cx);
self
}
}
impl RenderOnce for LoadingLabel {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let text = self.text.clone();
self.base.color(Color::Muted).with_animations(
"loading_label",
vec![
Animation::new(Duration::from_secs(1)),
Animation::new(Duration::from_secs(1)).repeat(),
],
move |mut label, animation_ix, delta| {
match animation_ix {
0 => {
let byte_end =
text.floor_char_boundary((delta * text.len() as f32).ceil() as usize);
let visible_text = SharedString::new(&text[0..byte_end]);
label.set_text(visible_text);
}
1 => match delta {
..0.25 => label.set_text(text.clone()),
..0.5 => label.set_text(format!("{}.", text)),
..0.75 => label.set_text(format!("{}..", text)),
_ => label.set_text(format!("{}...", text)),
},
_ => {}
}
label
},
)
}
}

View File

@@ -0,0 +1,205 @@
use crate::prelude::*;
use gpui::{Animation, AnimationExt, FontWeight};
use std::time::Duration;
/// Different types of spinner animations
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub enum SpinnerVariant {
#[default]
Dots,
DotsVariant,
Sand,
}
/// A spinner indication, based on the label component, that loops through
/// frames of the specified animation. It implements `LabelCommon` as well.
///
/// # Default Example
///
/// ```
/// use ui::{SpinnerLabel};
///
/// SpinnerLabel::new();
/// ```
///
/// # Variant Example
///
/// ```
/// use ui::{SpinnerLabel};
///
/// SpinnerLabel::dots_variant();
/// ```
#[derive(IntoElement, RegisterComponent)]
pub struct SpinnerLabel {
base: Label,
variant: SpinnerVariant,
frames: Vec<&'static str>,
duration: Duration,
}
impl SpinnerVariant {
fn frames(&self) -> Vec<&'static str> {
match self {
SpinnerVariant::Dots => vec!["", "", "", "", "", "", "", "", "", ""],
SpinnerVariant::DotsVariant => vec!["", "", "", "", "", "", "", ""],
SpinnerVariant::Sand => vec![
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "",
],
}
}
fn duration(&self) -> Duration {
match self {
SpinnerVariant::Dots => Duration::from_millis(1000),
SpinnerVariant::DotsVariant => Duration::from_millis(1000),
SpinnerVariant::Sand => Duration::from_millis(2000),
}
}
fn animation_id(&self) -> &'static str {
match self {
SpinnerVariant::Dots => "spinner_label_dots",
SpinnerVariant::DotsVariant => "spinner_label_dots_variant",
SpinnerVariant::Sand => "spinner_label_dots_variant_2",
}
}
}
impl SpinnerLabel {
pub fn new() -> Self {
Self::with_variant(SpinnerVariant::default())
}
pub fn with_variant(variant: SpinnerVariant) -> Self {
let frames = variant.frames();
let duration = variant.duration();
SpinnerLabel {
base: Label::new(frames[0]).color(Color::Muted),
variant,
frames,
duration,
}
}
pub fn dots() -> Self {
Self::with_variant(SpinnerVariant::Dots)
}
pub fn dots_variant() -> Self {
Self::with_variant(SpinnerVariant::DotsVariant)
}
pub fn sand() -> Self {
Self::with_variant(SpinnerVariant::Sand)
}
}
impl LabelCommon for SpinnerLabel {
fn size(mut self, size: LabelSize) -> Self {
self.base = self.base.size(size);
self
}
fn weight(mut self, weight: FontWeight) -> Self {
self.base = self.base.weight(weight);
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) -> Self {
self.base = self.base.strikethrough();
self
}
fn italic(mut self) -> Self {
self.base = self.base.italic();
self
}
fn alpha(mut self, alpha: f32) -> Self {
self.base = self.base.alpha(alpha);
self
}
fn underline(mut self) -> Self {
self.base = self.base.underline();
self
}
fn truncate(mut self) -> Self {
self.base = self.base.truncate();
self
}
fn single_line(mut self) -> Self {
self.base = self.base.single_line();
self
}
fn buffer_font(mut self, cx: &App) -> Self {
self.base = self.base.buffer_font(cx);
self
}
fn inline_code(mut self, cx: &App) -> Self {
self.base = self.base.inline_code(cx);
self
}
}
impl RenderOnce for SpinnerLabel {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let frames = self.frames.clone();
let duration = self.duration;
self.base.with_animation(
self.variant.animation_id(),
Animation::new(duration).repeat(),
move |mut label, delta| {
let frame_index = (delta * frames.len() as f32) as usize % frames.len();
label.set_text(frames[frame_index]);
label
},
)
}
}
impl Component for SpinnerLabel {
fn scope() -> ComponentScope {
ComponentScope::Loading
}
fn name() -> &'static str {
"Spinner Label"
}
fn sort_name() -> &'static str {
"Spinner Label"
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let examples = vec![
single_example("Default", SpinnerLabel::new().into_any_element()),
single_example(
"Dots Variant",
SpinnerLabel::dots_variant().into_any_element(),
),
single_example("Sand Variant", SpinnerLabel::sand().into_any_element()),
];
Some(example_group(examples).vertical().into_any_element())
}
}

View File

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

View File

@@ -0,0 +1,142 @@
use component::{Component, ComponentScope, example_group_with_title, single_example};
use gpui::AnyElement;
use smallvec::SmallVec;
use crate::{Label, ListHeader, ListItem, prelude::*};
pub enum EmptyMessage {
Text(SharedString),
Element(AnyElement),
}
#[derive(IntoElement, RegisterComponent)]
pub struct List {
/// Message to display when the list is empty
/// Defaults to "No items"
empty_message: EmptyMessage,
header: Option<ListHeader>,
toggle: Option<bool>,
children: SmallVec<[AnyElement; 2]>,
}
impl Default for List {
fn default() -> Self {
Self::new()
}
}
impl List {
pub fn new() -> Self {
Self {
empty_message: EmptyMessage::Text("No items".into()),
header: None,
toggle: None,
children: SmallVec::new(),
}
}
pub fn empty_message(mut self, message: impl Into<EmptyMessage>) -> Self {
self.empty_message = 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 extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl From<String> for EmptyMessage {
fn from(s: String) -> Self {
EmptyMessage::Text(SharedString::from(s))
}
}
impl From<&str> for EmptyMessage {
fn from(s: &str) -> Self {
EmptyMessage::Text(SharedString::from(s.to_owned()))
}
}
impl From<AnyElement> for EmptyMessage {
fn from(e: AnyElement) -> Self {
EmptyMessage::Element(e)
}
}
impl RenderOnce for List {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.w_full()
.py(DynamicSpacing::Base04.rems(cx))
.children(self.header)
.map(|this| match (self.children.is_empty(), self.toggle) {
(false, _) => this.children(self.children),
(true, Some(false)) => this,
(true, _) => match self.empty_message {
EmptyMessage::Text(text) => {
this.px_2().child(Label::new(text).color(Color::Muted))
}
EmptyMessage::Element(element) => this.child(element),
},
})
}
}
impl Component for List {
fn scope() -> ComponentScope {
ComponentScope::Layout
}
fn description() -> Option<&'static str> {
Some(
"A container component for displaying a collection of list items with optional header and empty state.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![example_group_with_title(
"Basic Lists",
vec![
single_example(
"Simple List",
List::new()
.child(ListItem::new("item1").child(Label::new("Item 1")))
.child(ListItem::new("item2").child(Label::new("Item 2")))
.child(ListItem::new("item3").child(Label::new("Item 3")))
.into_any_element(),
),
single_example(
"With Header",
List::new()
.header(ListHeader::new("Section Header"))
.child(ListItem::new("item1").child(Label::new("Item 1")))
.child(ListItem::new("item2").child(Label::new("Item 2")))
.into_any_element(),
),
single_example(
"Empty List",
List::new()
.empty_message("No items to display")
.into_any_element(),
),
],
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,115 @@
use crate::{ButtonLink, ListItem, prelude::*};
use component::{Component, ComponentScope, example_group, single_example};
use gpui::{IntoElement, ParentElement, SharedString};
#[derive(IntoElement, RegisterComponent)]
pub struct ListBulletItem {
label: SharedString,
label_color: Option<Color>,
children: Vec<AnyElement>,
}
impl ListBulletItem {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
label_color: None,
children: Vec::new(),
}
}
pub fn label_color(mut self, color: Color) -> Self {
self.label_color = Some(color);
self
}
}
impl ParentElement for ListBulletItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ListBulletItem {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let line_height = window.line_height() * 0.85;
ListItem::new("list-item")
.selectable(false)
.child(
h_flex()
.w_full()
.min_w_0()
.gap_1()
.items_start()
.child(
h_flex().h(line_height).justify_center().child(
Icon::new(IconName::Dash)
.size(IconSize::XSmall)
.color(Color::Hidden),
),
)
.map(|this| {
if !self.children.is_empty() {
this.child(h_flex().gap_0p5().flex_wrap().children(self.children))
} else {
this.child(
div().w_full().min_w_0().child(
Label::new(self.label)
.color(self.label_color.unwrap_or(Color::Default)),
),
)
}
}),
)
.into_any_element()
}
}
impl Component for ListBulletItem {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn description() -> Option<&'static str> {
Some("A list item with a dash indicator for unordered lists.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let basic_examples = vec![
single_example(
"Simple",
ListBulletItem::new("First bullet item").into_any_element(),
),
single_example(
"Multiple Lines",
v_flex()
.child(ListBulletItem::new("First item"))
.child(ListBulletItem::new("Second item"))
.child(ListBulletItem::new("Third item"))
.into_any_element(),
),
single_example(
"Long Text",
ListBulletItem::new(
"A longer bullet item that demonstrates text wrapping behavior",
)
.into_any_element(),
),
single_example(
"With Link",
ListBulletItem::new("")
.child(Label::new("Create a Zed account by"))
.child(ButtonLink::new("visiting the website", "https://zed.dev"))
.into_any_element(),
),
];
Some(
v_flex()
.gap_6()
.child(example_group(basic_examples).vertical())
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,217 @@
use std::sync::Arc;
use crate::{Disclosure, prelude::*};
use component::{Component, ComponentScope, example_group_with_title, single_example};
use gpui::{AnyElement, ClickEvent};
use theme::UiDensity;
#[derive(IntoElement, RegisterComponent)]
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<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + '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 Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Arc::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 Toggleable for ListHeader {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl RenderOnce for ListHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let ui_density = theme::theme_settings(cx).ui_density(cx);
h_flex()
.id(self.label.clone())
.w_full()
.relative()
.group("list_header")
.child(
div()
.map(|this| match ui_density {
UiDensity::Comfortable => this.h_5(),
_ => this.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(DynamicSpacing::Base04.rems(cx))
.child(
h_flex()
.gap(DynamicSpacing::Base04.rems(cx))
.children(self.toggle.map(|is_open| {
Disclosure::new("toggle", is_open)
.on_toggle_expanded(self.on_toggle.clone())
}))
.child(
div()
.id("label_container")
.flex()
.gap(DynamicSpacing::Base04.rems(cx))
.items_center()
.children(self.start_slot)
.child(Label::new(self.label.clone()).color(Color::Muted))
.when_some(self.on_toggle, |this, on_toggle| {
this.on_click(move |event, window, cx| {
on_toggle(event, window, cx)
})
}),
),
)
.child(h_flex().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),
)
}),
)
}
}
impl Component for ListHeader {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn description() -> Option<&'static str> {
Some(
"A header component for lists with support for icons, actions, and collapsible sections.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic Headers",
vec![
single_example(
"Simple",
ListHeader::new("Section Header").into_any_element(),
),
single_example(
"With Icon",
ListHeader::new("Files")
.start_slot(Icon::new(IconName::File))
.into_any_element(),
),
single_example(
"With End Slot",
ListHeader::new("Recent")
.end_slot(Label::new("5").color(Color::Muted))
.into_any_element(),
),
],
),
example_group_with_title(
"Collapsible Headers",
vec![
single_example(
"Expanded",
ListHeader::new("Expanded Section")
.toggle(Some(true))
.into_any_element(),
),
single_example(
"Collapsed",
ListHeader::new("Collapsed Section")
.toggle(Some(false))
.into_any_element(),
),
],
),
example_group_with_title(
"States",
vec![
single_example(
"Selected",
ListHeader::new("Selected Header")
.toggle_state(true)
.into_any_element(),
),
single_example(
"Inset",
ListHeader::new("Inset Header")
.inset(true)
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,493 @@
use std::sync::Arc;
use component::{Component, ComponentScope, example_group_with_title, single_example};
use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px};
use smallvec::SmallVec;
use crate::{Disclosure, prelude::*};
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum ListItemSpacing {
#[default]
Dense,
ExtraDense,
Sparse,
}
#[derive(Default)]
enum EndSlotVisibility {
#[default]
Always,
OnHover,
SwapOnHover(AnyElement),
}
#[derive(IntoElement, RegisterComponent)]
pub struct ListItem {
id: ElementId,
group_name: Option<SharedString>,
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>,
end_slot_visibility: EndSlotVisibility,
toggle: Option<bool>,
inset: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>>,
children: SmallVec<[AnyElement; 2]>,
selectable: bool,
always_show_disclosure_icon: bool,
outlined: bool,
rounded: bool,
overflow_x: bool,
focused: Option<bool>,
docked_right: bool,
height: Option<DefiniteLength>,
}
impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
group_name: None,
disabled: false,
selected: false,
spacing: ListItemSpacing::Dense,
indent_level: 0,
indent_step_size: px(12.),
start_slot: None,
end_slot: None,
end_slot_visibility: EndSlotVisibility::default(),
toggle: None,
inset: false,
on_click: None,
on_secondary_mouse_down: None,
on_toggle: None,
on_hover: None,
tooltip: None,
children: SmallVec::new(),
selectable: true,
always_show_disclosure_icon: false,
outlined: false,
rounded: false,
overflow_x: false,
focused: None,
docked_right: false,
height: None,
}
}
pub fn group_name(mut self, group_name: impl Into<SharedString>) -> Self {
self.group_name = Some(group_name.into());
self
}
pub fn spacing(mut self, spacing: ListItemSpacing) -> Self {
self.spacing = spacing;
self
}
pub fn selectable(mut self, has_hover: bool) -> Self {
self.selectable = has_hover;
self
}
pub fn always_show_disclosure_icon(mut self, show: bool) -> Self {
self.always_show_disclosure_icon = show;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Some(Box::new(handler));
self
}
pub fn on_secondary_mouse_down(
mut self,
handler: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_secondary_mouse_down = Some(Box::new(handler));
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> 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 Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Arc::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_slot_on_hover<E: IntoElement>(mut self, end_slot_on_hover: E) -> Self {
self.end_slot_visibility =
EndSlotVisibility::SwapOnHover(end_slot_on_hover.into_any_element());
self
}
pub fn show_end_slot_on_hover(mut self) -> Self {
self.end_slot_visibility = EndSlotVisibility::OnHover;
self
}
pub fn outlined(mut self) -> Self {
self.outlined = true;
self
}
pub fn rounded(mut self) -> Self {
self.rounded = true;
self
}
pub fn overflow_x(mut self) -> Self {
self.overflow_x = true;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = Some(focused);
self
}
pub fn docked_right(mut self, docked_right: bool) -> Self {
self.docked_right = docked_right;
self
}
pub fn height(mut self, height: impl Into<DefiniteLength>) -> Self {
self.height = Some(height.into());
self
}
}
impl Disableable for ListItem {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Toggleable for ListItem {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl ParentElement for ListItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ListItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.id)
.when_some(self.group_name, |this, group| this.group(group))
.w_full()
.when_some(self.height, |this, height| this.h(height))
.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(DynamicSpacing::Base04.rems(cx))
})
.when(!self.inset, |this| {
this.when_some(self.focused, |this, focused| {
if focused && !self.disabled {
this.border_1()
.when(self.docked_right, |this| this.border_r_2())
.border_color(cx.theme().colors().border_focused)
} else {
this.border_1()
}
})
.when(self.selectable && !self.disabled, |this| {
this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
.active(|style| style.bg(cx.theme().colors().ghost_element_active))
.when(self.outlined, |this| this.rounded_sm())
.when(self.selected, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
})
})
.when(self.rounded, |this| this.rounded_sm())
.when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover))
.child(
h_flex()
.id("inner_list_item")
.group("list_item")
.w_full()
.relative()
.gap_1()
.px(DynamicSpacing::Base06.rems(cx))
.map(|this| match self.spacing {
ListItemSpacing::Dense => this,
ListItemSpacing::ExtraDense => this.py_neg_px(),
ListItemSpacing::Sparse => this.py_1(),
})
.when(self.inset, |this| {
this.when_some(self.focused, |this, focused| {
if focused && !self.disabled {
this.border_1()
.border_color(cx.theme().colors().border_focused)
} else {
this.border_1()
}
})
.when(self.selectable && !self.disabled, |this| {
this.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.filter(|_| !self.disabled),
|this, on_click| this.cursor_pointer().on_click(on_click),
)
.when(self.outlined, |this| {
this.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
.overflow_hidden()
})
.when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
this.on_mouse_down(MouseButton::Right, move |event, window, cx| {
(on_mouse_down)(event, window, cx)
})
})
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
.map(|this| {
if self.inset {
this.rounded_sm()
} 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 && !self.always_show_disclosure_icon, |this| {
this.visible_on_hover("")
})
.child(
Disclosure::new("toggle", is_open)
.on_toggle_expanded(self.on_toggle),
)
}))
.child(
h_flex()
.flex_grow()
.flex_shrink_0()
.flex_basis(relative(0.25))
.gap(DynamicSpacing::Base06.rems(cx))
.map(|list_content| {
if self.overflow_x {
list_content
} else {
list_content.overflow_hidden()
}
})
.children(self.start_slot)
.children(self.children),
)
.when(self.end_slot.is_some(), |this| this.justify_between())
.when_some(self.end_slot, |this, end_slot| {
this.child(match self.end_slot_visibility {
EndSlotVisibility::Always => {
h_flex().flex_shrink().overflow_hidden().child(end_slot)
}
EndSlotVisibility::OnHover => h_flex()
.flex_shrink()
.overflow_hidden()
.visible_on_hover("list_item")
.child(end_slot),
EndSlotVisibility::SwapOnHover(hover_slot) => h_flex()
.relative()
.flex_shrink()
.child(h_flex().visible_on_hover("list_item").child(hover_slot))
.child(
h_flex()
.absolute()
.inset_0()
.justify_end()
.overflow_hidden()
.group_hover("list_item", |this| this.invisible())
.child(end_slot),
),
})
}),
)
}
}
impl Component for ListItem {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn description() -> Option<&'static str> {
Some(
"A flexible list item component with support for icons, actions, disclosure toggles, and hierarchical display.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic List Items",
vec![
single_example(
"Simple",
ListItem::new("simple")
.child(Label::new("Simple list item"))
.into_any_element(),
),
single_example(
"With Icon",
ListItem::new("with_icon")
.start_slot(Icon::new(IconName::File))
.child(Label::new("List item with icon"))
.into_any_element(),
),
single_example(
"Selected",
ListItem::new("selected")
.toggle_state(true)
.start_slot(Icon::new(IconName::Check))
.child(Label::new("Selected item"))
.into_any_element(),
),
],
),
example_group_with_title(
"List Item Spacing",
vec![
single_example(
"Dense",
ListItem::new("dense")
.spacing(ListItemSpacing::Dense)
.child(Label::new("Dense spacing"))
.into_any_element(),
),
single_example(
"Extra Dense",
ListItem::new("extra_dense")
.spacing(ListItemSpacing::ExtraDense)
.child(Label::new("Extra dense spacing"))
.into_any_element(),
),
single_example(
"Sparse",
ListItem::new("sparse")
.spacing(ListItemSpacing::Sparse)
.child(Label::new("Sparse spacing"))
.into_any_element(),
),
],
),
example_group_with_title(
"With Slots",
vec![
single_example(
"End Slot",
ListItem::new("end_slot")
.child(Label::new("Item with end slot"))
.end_slot(Icon::new(IconName::ChevronRight))
.into_any_element(),
),
single_example(
"With Toggle",
ListItem::new("with_toggle")
.toggle(Some(true))
.child(Label::new("Expandable item"))
.into_any_element(),
),
],
),
example_group_with_title(
"States",
vec![
single_example(
"Disabled",
ListItem::new("disabled")
.disabled(true)
.child(Label::new("Disabled item"))
.into_any_element(),
),
single_example(
"Non-selectable",
ListItem::new("non_selectable")
.selectable(false)
.child(Label::new("Non-selectable item"))
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

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

View File

@@ -0,0 +1,149 @@
use crate::prelude::*;
use component::{Component, ComponentScope, example_group_with_title, single_example};
#[derive(IntoElement, RegisterComponent)]
pub struct ListSubHeader {
label: SharedString,
start_slot: Option<IconName>,
end_slot: Option<AnyElement>,
inset: bool,
selected: bool,
}
impl ListSubHeader {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
start_slot: None,
end_slot: None,
inset: false,
selected: false,
}
}
pub fn left_icon(mut self, left_icon: Option<IconName>) -> Self {
self.start_slot = left_icon;
self
}
pub fn end_slot(mut self, end_slot: AnyElement) -> Self {
self.end_slot = Some(end_slot);
self
}
pub fn inset(mut self, inset: bool) -> Self {
self.inset = inset;
self
}
}
impl Toggleable for ListSubHeader {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl RenderOnce for ListSubHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.flex_1()
.w_full()
.relative()
.pb(DynamicSpacing::Base04.rems(cx))
.px(DynamicSpacing::Base02.rems(cx))
.child(
div()
.h_5()
.when(self.inset, |this| this.px_2())
.when(self.selected, |this| {
this.bg(cx.theme().colors().ghost_element_selected)
})
.flex()
.flex_1()
.w_full()
.gap_1()
.items_center()
.justify_between()
.child(
div()
.flex()
.gap_1()
.items_center()
.children(
self.start_slot.map(|i| {
Icon::new(i).color(Color::Muted).size(IconSize::Small)
}),
)
.child(
Label::new(self.label.clone())
.color(Color::Muted)
.size(LabelSize::Small),
),
)
.children(self.end_slot),
)
}
}
impl Component for ListSubHeader {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn description() -> Option<&'static str> {
Some(
"A sub-header component for organizing list content into subsections with optional icons and end slots.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic Sub-headers",
vec![
single_example(
"Simple",
ListSubHeader::new("Subsection").into_any_element(),
),
single_example(
"With Icon",
ListSubHeader::new("Documents")
.left_icon(Some(IconName::File))
.into_any_element(),
),
single_example(
"With End Slot",
ListSubHeader::new("Recent")
.end_slot(
Label::new("3").color(Color::Muted).into_any_element(),
)
.into_any_element(),
),
],
),
example_group_with_title(
"States",
vec![
single_example(
"Selected",
ListSubHeader::new("Selected")
.toggle_state(true)
.into_any_element(),
),
single_example(
"Inset",
ListSubHeader::new("Inset Sub-header")
.inset(true)
.into_any_element(),
),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,473 @@
use crate::{IconButtonShape, prelude::*};
use gpui::{prelude::FluentBuilder, *};
use smallvec::SmallVec;
use theme::ActiveTheme;
#[derive(IntoElement)]
pub struct Modal {
id: ElementId,
header: ModalHeader,
children: SmallVec<[AnyElement; 2]>,
footer: Option<ModalFooter>,
container_id: ElementId,
container_scroll_handler: Option<ScrollHandle>,
}
impl Modal {
pub fn new(id: impl Into<SharedString>, scroll_handle: Option<ScrollHandle>) -> Self {
let id = id.into();
let container_id = ElementId::Name(format!("{}_container", id).into());
Self {
id: ElementId::Name(id),
header: ModalHeader::new(),
children: SmallVec::new(),
footer: None,
container_id,
container_scroll_handler: scroll_handle,
}
}
pub fn header(mut self, header: ModalHeader) -> Self {
self.header = header;
self
}
pub fn section(mut self, section: Section) -> Self {
self.children.push(section.into_any_element());
self
}
pub fn footer(mut self, footer: ModalFooter) -> Self {
self.footer = Some(footer);
self
}
pub fn show_dismiss(mut self, show: bool) -> Self {
self.header.show_dismiss_button = show;
self
}
pub fn show_back(mut self, show: bool) -> Self {
self.header.show_back_button = show;
self
}
}
impl ParentElement for Modal {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for Modal {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
v_flex()
.id(self.id.clone())
.size_full()
.flex_1()
.overflow_hidden()
.child(self.header)
.child(
v_flex()
.id(self.container_id.clone())
.w_full()
.flex_1()
.gap(DynamicSpacing::Base08.rems(cx))
.when_some(
self.container_scroll_handler,
|this, container_scroll_handle| {
this.overflow_y_scroll()
.track_scroll(&container_scroll_handle)
},
)
.children(self.children),
)
.children(self.footer)
}
}
#[derive(IntoElement)]
pub struct ModalHeader {
icon: Option<Icon>,
headline: Option<SharedString>,
description: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>,
show_dismiss_button: bool,
show_back_button: bool,
}
impl Default for ModalHeader {
fn default() -> Self {
Self::new()
}
}
impl ModalHeader {
pub fn new() -> Self {
Self {
icon: None,
headline: None,
description: None,
children: SmallVec::new(),
show_dismiss_button: false,
show_back_button: false,
}
}
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
/// Set the headline of the modal.
///
/// This will insert the headline as the first item
/// of `children` if it is not already present.
pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
self.headline = Some(headline.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn show_dismiss_button(mut self, show: bool) -> Self {
self.show_dismiss_button = show;
self
}
pub fn show_back_button(mut self, show: bool) -> Self {
self.show_back_button = show;
self
}
}
impl ParentElement for ModalHeader {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ModalHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let mut children = self.children;
if let Some(headline) = self.headline {
children.insert(
0,
Headline::new(headline)
.size(HeadlineSize::Small)
.color(Color::Muted)
.into_any_element(),
);
}
h_flex()
.min_w_0()
.flex_none()
.justify_between()
.w_full()
.px(DynamicSpacing::Base12.rems(cx))
.pt(DynamicSpacing::Base08.rems(cx))
.pb(DynamicSpacing::Base04.rems(cx))
.gap(DynamicSpacing::Base08.rems(cx))
.when(self.show_back_button, |this| {
this.child(
IconButton::new("back", IconName::ArrowLeft)
.shape(IconButtonShape::Square)
.on_click(|_, window, cx| {
window.dispatch_action(menu::Cancel.boxed_clone(), cx);
}),
)
})
.child(
v_flex()
.min_w_0()
.flex_1()
.child(
h_flex()
.w_full()
.gap_1()
.justify_between()
.child(
h_flex()
.gap_1()
.when_some(self.icon, |this, icon| this.child(icon))
.children(children),
)
.when(self.show_dismiss_button, |this| {
this.child(
IconButton::new("dismiss", IconName::Close)
.icon_size(IconSize::Small)
.on_click(|_, window, cx| {
window.dispatch_action(menu::Cancel.boxed_clone(), cx);
}),
)
}),
)
.when_some(self.description, |this, description| {
this.child(Label::new(description).color(Color::Muted).mb_2().flex_1())
}),
)
}
}
#[derive(IntoElement)]
pub struct ModalRow {
children: SmallVec<[AnyElement; 2]>,
}
impl Default for ModalRow {
fn default() -> Self {
Self::new()
}
}
impl ModalRow {
pub fn new() -> Self {
Self {
children: SmallVec::new(),
}
}
}
impl ParentElement for ModalRow {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ModalRow {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
h_flex().w_full().py_1().children(self.children)
}
}
#[derive(IntoElement)]
pub struct ModalFooter {
start_slot: Option<AnyElement>,
end_slot: Option<AnyElement>,
}
impl Default for ModalFooter {
fn default() -> Self {
Self::new()
}
}
impl ModalFooter {
pub fn new() -> Self {
Self {
start_slot: None,
end_slot: None,
}
}
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
}
}
impl RenderOnce for ModalFooter {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.w_full()
.p(DynamicSpacing::Base08.rems(cx))
.flex_none()
.justify_between()
.gap_1()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(div().when_some(self.start_slot, |this, start_slot| this.child(start_slot)))
.child(div().when_some(self.end_slot, |this, end_slot| this.child(end_slot)))
}
}
#[derive(IntoElement)]
pub struct Section {
contained: bool,
padded: bool,
header: Option<SectionHeader>,
meta: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>,
}
impl Default for Section {
fn default() -> Self {
Self::new()
}
}
impl Section {
pub fn new() -> Self {
Self {
contained: false,
padded: true,
header: None,
meta: None,
children: SmallVec::new(),
}
}
pub fn new_contained() -> Self {
Self {
contained: true,
padded: true,
header: None,
meta: None,
children: SmallVec::new(),
}
}
pub fn contained(mut self, contained: bool) -> Self {
self.contained = contained;
self
}
pub fn header(mut self, header: SectionHeader) -> Self {
self.header = Some(header);
self
}
pub fn meta(mut self, meta: impl Into<SharedString>) -> Self {
self.meta = Some(meta.into());
self
}
pub fn padded(mut self, padded: bool) -> Self {
self.padded = padded;
self
}
}
impl ParentElement for Section {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for Section {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let mut section_bg = cx.theme().colors().text;
section_bg.fade_out(0.96);
let children = if self.contained {
v_flex()
.flex_1()
.when(self.padded, |this| this.px(DynamicSpacing::Base12.rems(cx)))
.child(
v_flex()
.w_full()
.rounded_sm()
.border_1()
.border_color(cx.theme().colors().border)
.bg(section_bg)
.child(
div()
.flex()
.flex_1()
.pb_2()
.size_full()
.children(self.children),
),
)
} else {
v_flex()
.w_full()
.flex_1()
.gap_y(DynamicSpacing::Base04.rems(cx))
.pb_2()
.when(self.padded, |this| {
this.px(DynamicSpacing::Base06.rems(cx) + DynamicSpacing::Base06.rems(cx))
})
.children(self.children)
};
v_flex()
.size_full()
.flex_1()
.child(
v_flex()
.flex_none()
.px(DynamicSpacing::Base12.rems(cx))
.children(self.header)
.when_some(self.meta, |this, meta| {
this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
}),
)
.child(children)
// fill any leftover space
.child(div().flex().flex_1())
}
}
#[derive(IntoElement)]
pub struct SectionHeader {
/// The label of the header.
label: SharedString,
/// 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>,
}
impl SectionHeader {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
end_slot: None,
}
}
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
}
}
impl RenderOnce for SectionHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.id(self.label.clone())
.w_full()
.px(DynamicSpacing::Base08.rems(cx))
.child(
div()
.h_7()
.flex()
.items_center()
.justify_between()
.w_full()
.gap(DynamicSpacing::Base04.rems(cx))
.child(
div().flex_1().child(
Label::new(self.label.clone())
.size(LabelSize::Small)
.into_element(),
),
)
.child(h_flex().children(self.end_slot)),
)
}
}
impl From<SharedString> for SectionHeader {
fn from(val: SharedString) -> Self {
SectionHeader::new(val)
}
}
impl From<&'static str> for SectionHeader {
fn from(val: &'static str) -> Self {
let label: SharedString = val.into();
SectionHeader::new(label)
}
}

View File

@@ -0,0 +1,102 @@
use crate::prelude::*;
use gpui::{AnyElement, FocusHandle, ScrollAnchor, ScrollHandle};
/// An element that can be navigated through via keyboard. Intended for use with scrollable views that want to use
#[derive(IntoElement)]
pub struct Navigable {
child: AnyElement,
selectable_children: Vec<NavigableEntry>,
}
/// An entry of [Navigable] that can be navigated to.
#[derive(Clone)]
pub struct NavigableEntry {
#[allow(missing_docs)]
pub focus_handle: FocusHandle,
#[allow(missing_docs)]
pub scroll_anchor: Option<ScrollAnchor>,
}
impl NavigableEntry {
/// Creates a new [NavigableEntry] for a given scroll handle.
pub fn new(scroll_handle: &ScrollHandle, cx: &App) -> Self {
Self {
focus_handle: cx.focus_handle(),
scroll_anchor: Some(ScrollAnchor::for_handle(scroll_handle.clone())),
}
}
/// Create a new [NavigableEntry] that cannot be scrolled to.
pub fn focusable(cx: &App) -> Self {
Self {
focus_handle: cx.focus_handle(),
scroll_anchor: None,
}
}
}
impl Navigable {
/// Creates new empty [Navigable] wrapper.
pub fn new(child: AnyElement) -> Self {
Self {
child,
selectable_children: vec![],
}
}
/// Add a new entry that can be navigated to via keyboard.
///
/// The order of calls to [Navigable::entry] determines the order of traversal of
/// elements via successive uses of `menu:::SelectNext/SelectPrevious`
pub fn entry(mut self, child: NavigableEntry) -> Self {
self.selectable_children.push(child);
self
}
fn find_focused(
selectable_children: &[NavigableEntry],
window: &mut Window,
cx: &mut App,
) -> Option<usize> {
selectable_children
.iter()
.position(|entry| entry.focus_handle.contains_focused(window, cx))
}
}
impl RenderOnce for Navigable {
fn render(self, _window: &mut Window, _: &mut App) -> impl crate::IntoElement {
div()
.on_action({
let children = self.selectable_children.clone();
move |_: &menu::SelectNext, window, cx| {
let target = Self::find_focused(&children, window, cx)
.and_then(|index| {
index.checked_add(1).filter(|index| *index < children.len())
})
.unwrap_or(0);
if let Some(entry) = children.get(target) {
entry.focus_handle.focus(window, cx);
if let Some(anchor) = &entry.scroll_anchor {
anchor.scroll_to(window, cx);
}
}
}
})
.on_action({
let children = self.selectable_children;
move |_: &menu::SelectPrevious, window, cx| {
let target = Self::find_focused(&children, window, cx)
.and_then(|index| index.checked_sub(1))
.or(children.len().checked_sub(1));
if let Some(entry) = target.and_then(|target| children.get(target)) {
entry.focus_handle.focus(window, cx);
if let Some(anchor) = &entry.scroll_anchor {
anchor.scroll_to(window, cx);
}
}
}
})
.size_full()
.child(self.child)
}
}

View File

@@ -0,0 +1,5 @@
mod alert_modal;
mod announcement_toast;
pub use alert_modal::*;
pub use announcement_toast::*;

View File

@@ -0,0 +1,252 @@
use crate::component_prelude::*;
use crate::prelude::*;
use crate::{Checkbox, ListBulletItem, ToggleState};
use gpui::Action;
use gpui::FocusHandle;
use gpui::IntoElement;
use gpui::Stateful;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
type ActionHandler = Box<dyn FnOnce(Stateful<Div>) -> Stateful<Div>>;
#[derive(IntoElement, RegisterComponent)]
pub struct AlertModal {
id: ElementId,
header: Option<AnyElement>,
children: SmallVec<[AnyElement; 2]>,
footer: Option<AnyElement>,
title: Option<SharedString>,
primary_action: Option<SharedString>,
dismiss_label: Option<SharedString>,
width: Option<DefiniteLength>,
key_context: Option<String>,
action_handlers: Vec<ActionHandler>,
focus_handle: Option<FocusHandle>,
}
impl AlertModal {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
header: None,
children: smallvec![],
footer: None,
title: None,
primary_action: None,
dismiss_label: None,
width: None,
key_context: None,
action_handlers: Vec::new(),
focus_handle: None,
}
}
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
pub fn header(mut self, header: impl IntoElement) -> Self {
self.header = Some(header.into_any_element());
self
}
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
pub fn primary_action(mut self, primary_action: impl Into<SharedString>) -> Self {
self.primary_action = Some(primary_action.into());
self
}
pub fn dismiss_label(mut self, dismiss_label: impl Into<SharedString>) -> Self {
self.dismiss_label = Some(dismiss_label.into());
self
}
pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
self.width = Some(width.into());
self
}
pub fn key_context(mut self, key_context: impl Into<String>) -> Self {
self.key_context = Some(key_context.into());
self
}
pub fn on_action<A: Action>(
mut self,
listener: impl Fn(&A, &mut Window, &mut App) + 'static,
) -> Self {
self.action_handlers
.push(Box::new(move |div| div.on_action(listener)));
self
}
pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
self.focus_handle = Some(focus_handle.clone());
self
}
}
impl RenderOnce for AlertModal {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let width = self.width.unwrap_or_else(|| px(440.).into());
let has_default_footer = self.primary_action.is_some() || self.dismiss_label.is_some();
let mut modal = v_flex()
.when_some(self.key_context, |this, key_context| {
this.key_context(key_context.as_str())
})
.when_some(self.focus_handle, |this, focus_handle| {
this.track_focus(&focus_handle)
})
.id(self.id)
.elevation_3(cx)
.w(width)
.bg(cx.theme().colors().elevated_surface_background)
.overflow_hidden();
for handler in self.action_handlers {
modal = handler(modal);
}
if let Some(header) = self.header {
modal = modal.child(header);
} else if let Some(title) = self.title {
modal = modal.child(
v_flex()
.pt_3()
.pr_3()
.pl_3()
.pb_1()
.child(Headline::new(title).size(HeadlineSize::Small)),
);
}
if !self.children.is_empty() {
modal = modal.child(
v_flex()
.p_3()
.text_ui(cx)
.text_color(Color::Muted.color(cx))
.gap_1()
.children(self.children),
);
}
if let Some(footer) = self.footer {
modal = modal.child(footer);
} else if has_default_footer {
let primary_action = self.primary_action.unwrap_or_else(|| "Ok".into());
let dismiss_label = self.dismiss_label.unwrap_or_else(|| "Cancel".into());
modal = modal.child(
h_flex()
.p_3()
.items_center()
.justify_end()
.gap_1()
.child(Button::new(dismiss_label.clone(), dismiss_label).color(Color::Muted))
.child(Button::new(primary_action.clone(), primary_action)),
);
}
modal
}
}
impl ParentElement for AlertModal {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Component for AlertModal {
fn scope() -> ComponentScope {
ComponentScope::Notification
}
fn status() -> ComponentStatus {
ComponentStatus::WorkInProgress
}
fn description() -> Option<&'static str> {
Some("A modal dialog that presents an alert message with primary and dismiss actions.")
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.p_4()
.children(vec![
example_group(vec![single_example(
"Basic Alert",
AlertModal::new("simple-modal")
.title("Do you want to leave the current call?")
.child(
"The current window will be closed, and connections to any shared projects will be terminated."
)
.primary_action("Leave Call")
.dismiss_label("Cancel")
.into_any_element(),
)]),
example_group(vec![single_example(
"Custom Header",
AlertModal::new("custom-header-modal")
.header(
v_flex()
.p_3()
.bg(cx.theme().colors().background)
.gap_1()
.child(
h_flex()
.gap_1()
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Headline::new("Unrecognized Workspace").size(HeadlineSize::Small))
)
.child(
h_flex()
.pl(IconSize::default().rems() + rems(0.5))
.child(Label::new("~/projects/my-project").color(Color::Muted))
)
)
.child(
"Untrusted workspaces are opened in Restricted Mode to protect your system.
Review .zed/settings.json for any extensions or commands configured by this project.",
)
.child(
v_flex()
.mt_1()
.child(Label::new("Restricted mode prevents:").color(Color::Muted))
.child(ListBulletItem::new("Project settings from being applied"))
.child(ListBulletItem::new("Language servers from running"))
.child(ListBulletItem::new("MCP integrations from installing"))
)
.footer(
h_flex()
.p_3()
.justify_between()
.child(
Checkbox::new("trust-parent", ToggleState::Unselected)
.label("Trust all projects in parent directory")
)
.child(
h_flex()
.gap_1()
.child(Button::new("restricted", "Stay in Restricted Mode").color(Color::Muted))
.child(Button::new("trust", "Trust and Continue").style(ButtonStyle::Filled))
)
)
.width(rems(40.))
.into_any_element(),
)]),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,198 @@
use crate::{ListBulletItem, prelude::*};
use component::{Component, ComponentScope, example_group, single_example};
use gpui::{AnyElement, ClickEvent, IntoElement, ParentElement, SharedString};
use smallvec::SmallVec;
#[derive(IntoElement, RegisterComponent)]
pub struct AnnouncementToast {
illustration: Option<AnyElement>,
heading: Option<SharedString>,
description: Option<SharedString>,
bullet_items: SmallVec<[AnyElement; 6]>,
primary_action_label: SharedString,
primary_on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
secondary_action_label: SharedString,
secondary_on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
dismiss_on_click: Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>,
}
impl AnnouncementToast {
pub fn new() -> Self {
Self {
illustration: None,
heading: None,
description: None,
bullet_items: SmallVec::new(),
primary_action_label: "Try Now".into(),
primary_on_click: Box::new(|_, _, _| {}),
secondary_action_label: "Learn More".into(),
secondary_on_click: Box::new(|_, _, _| {}),
dismiss_on_click: Box::new(|_, _, _| {}),
}
}
pub fn illustration(mut self, illustration: impl IntoElement) -> Self {
self.illustration = Some(illustration.into_any_element());
self
}
pub fn heading(mut self, heading: impl Into<SharedString>) -> Self {
self.heading = Some(heading.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn bullet_item(mut self, item: impl IntoElement) -> Self {
self.bullet_items.push(item.into_any_element());
self
}
pub fn bullet_items(mut self, items: impl IntoIterator<Item = impl IntoElement>) -> Self {
self.bullet_items
.extend(items.into_iter().map(IntoElement::into_any_element));
self
}
pub fn primary_action_label(mut self, primary_action_label: impl Into<SharedString>) -> Self {
self.primary_action_label = primary_action_label.into();
self
}
pub fn primary_on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.primary_on_click = Box::new(handler);
self
}
pub fn secondary_action_label(
mut self,
secondary_action_label: impl Into<SharedString>,
) -> Self {
self.secondary_action_label = secondary_action_label.into();
self
}
pub fn secondary_on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.secondary_on_click = Box::new(handler);
self
}
pub fn dismiss_on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.dismiss_on_click = Box::new(handler);
self
}
}
impl RenderOnce for AnnouncementToast {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let has_illustration = self.illustration.is_some();
let illustration = self.illustration;
v_flex()
.id("announcement-toast")
.occlude()
.relative()
.w_full()
.elevation_3(cx)
.when_some(illustration, |this, i| this.child(i))
.child(
v_flex()
.p_4()
.gap_4()
.when(has_illustration, |s| {
s.border_t_1()
.border_color(cx.theme().colors().border_variant)
})
.child(
v_flex()
.min_w_0()
.when_some(self.heading, |this, heading| {
this.child(Headline::new(heading).size(HeadlineSize::Small))
})
.when_some(self.description, |this, description| {
this.child(Label::new(description).color(Color::Muted))
}),
)
.when(!self.bullet_items.is_empty(), |this| {
this.child(v_flex().min_w_0().gap_1().children(self.bullet_items))
})
.child(
v_flex()
.gap_1()
.child(
Button::new("try-now", self.primary_action_label)
.style(ButtonStyle::Tinted(crate::TintColor::Accent))
.full_width()
.on_click(self.primary_on_click),
)
.child(
Button::new("release-notes", self.secondary_action_label)
.style(ButtonStyle::OutlinedGhost)
.full_width()
.on_click(self.secondary_on_click),
),
),
)
.child(
div().absolute().top_1().right_1().child(
IconButton::new("dismiss", IconName::Close)
.icon_size(IconSize::Small)
.on_click(self.dismiss_on_click),
),
)
}
}
impl Component for AnnouncementToast {
fn scope() -> ComponentScope {
ComponentScope::Notification
}
fn description() -> Option<&'static str> {
Some("A special toast for announcing new and exciting features.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
let examples = vec![single_example(
"Basic",
div()
.w_80()
.child(
AnnouncementToast::new()
.heading("Introducing Parallel Agents")
.description("Run multiple agent threads simultaneously across projects.")
.bullet_item(ListBulletItem::new(
"Mix and match Zed's agent with any ACP-compatible agent",
))
.bullet_item(ListBulletItem::new(
"Optional worktree isolation keeps agents from conflicting",
))
.bullet_item(ListBulletItem::new(
"Updated workspace layout designed for agentic workflows",
))
.primary_action_label("Try Now")
.secondary_action_label("Learn More"),
)
.into_any_element(),
)];
Some(
v_flex()
.gap_6()
.child(example_group(examples).vertical())
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,94 @@
use crate::prelude::*;
use crate::v_flex;
use gpui::{
AnyElement, App, Element, IntoElement, ParentElement, Pixels, RenderOnce, Styled, Window, div,
};
use smallvec::SmallVec;
/// Y height added beyond the size of the contents.
pub const POPOVER_Y_PADDING: Pixels = px(8.);
/// 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 appears when you click your avatar.
///
/// Related elements:
///
/// [`ContextMenu`](crate::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, _: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.flex()
.gap_1()
.child(
v_flex()
.elevation_2(cx)
.py(POPOVER_Y_PADDING / 2.)
.child(div().children(self.children)),
)
.when_some(self.aside, |this, aside| {
this.child(
v_flex()
.elevation_2(cx)
.bg(cx.theme().colors().surface_background)
.px_1()
.child(aside),
)
})
}
}
impl Default for Popover {
fn default() -> Self {
Self::new()
}
}
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 extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}

View File

@@ -0,0 +1,508 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Anchor, AnyElement, AnyView, App, Bounds, DismissEvent, DispatchPhase, Element, ElementId,
Entity, Focusable as _, GlobalElementId, HitboxBehavior, HitboxId, InteractiveElement,
IntoElement, LayoutId, Length, ManagedView, MouseDownEvent, ParentElement, Pixels, Point,
Style, Window, anchored, deferred, div, point, prelude::FluentBuilder, px, size,
};
use crate::prelude::*;
pub trait PopoverTrigger: IntoElement + Clickable + Toggleable + 'static {}
impl<T: IntoElement + Clickable + Toggleable + 'static> PopoverTrigger for T {}
impl<T: Clickable> Clickable for gpui::AnimationElement<T>
where
T: Clickable + 'static,
{
fn on_click(
self,
handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.map_element(|e| e.on_click(handler))
}
fn cursor_style(self, cursor_style: gpui::CursorStyle) -> Self {
self.map_element(|e| e.cursor_style(cursor_style))
}
}
impl<T: Toggleable> Toggleable for gpui::AnimationElement<T>
where
T: Toggleable + 'static,
{
fn toggle_state(self, selected: bool) -> Self {
self.map_element(|e| e.toggle_state(selected))
}
}
pub struct PopoverMenuHandle<M>(Rc<RefCell<Option<PopoverMenuHandleState<M>>>>);
impl<M> Clone for PopoverMenuHandle<M> {
fn clone(&self) -> Self {
Self(self.0.clone())
}
}
impl<M> Default for PopoverMenuHandle<M> {
fn default() -> Self {
Self(Rc::default())
}
}
struct PopoverMenuHandleState<M> {
menu_builder: Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
menu: Rc<RefCell<Option<Entity<M>>>>,
on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
}
impl<M: ManagedView> PopoverMenuHandle<M> {
pub fn show(&self, window: &mut Window, cx: &mut App) {
if let Some(state) = self.0.borrow().as_ref() {
show_menu(
&state.menu_builder,
&state.menu,
state.on_open.clone(),
window,
cx,
);
}
}
pub fn hide(&self, cx: &mut App) {
if let Some(state) = self.0.borrow().as_ref()
&& let Some(menu) = state.menu.borrow().as_ref()
{
menu.update(cx, |_, cx| cx.emit(DismissEvent));
}
}
pub fn toggle(&self, window: &mut Window, cx: &mut App) {
if let Some(state) = self.0.borrow().as_ref() {
if state.menu.borrow().is_some() {
self.hide(cx);
} else {
self.show(window, cx);
}
}
}
pub fn is_deployed(&self) -> bool {
self.0
.borrow()
.as_ref()
.is_some_and(|state| state.menu.borrow().as_ref().is_some())
}
pub fn is_focused(&self, window: &Window, cx: &App) -> bool {
self.0.borrow().as_ref().is_some_and(|state| {
state
.menu
.borrow()
.as_ref()
.is_some_and(|model| model.focus_handle(cx).is_focused(window))
})
}
pub fn refresh_menu(
&self,
window: &mut Window,
cx: &mut App,
new_menu_builder: Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
) {
let show_menu = if let Some(state) = self.0.borrow_mut().as_mut() {
state.menu_builder = new_menu_builder;
state.menu.borrow().is_some()
} else {
false
};
if show_menu {
self.show(window, cx);
}
}
}
pub struct PopoverMenu<M: ManagedView> {
id: ElementId,
child_builder: Option<
Box<
dyn FnOnce(
Rc<RefCell<Option<Entity<M>>>>,
Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
) -> AnyElement
+ 'static,
>,
>,
menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
anchor: Anchor,
attach: Option<Anchor>,
offset: Option<Point<Pixels>>,
trigger_handle: Option<PopoverMenuHandle<M>>,
on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
full_width: bool,
}
impl<M: ManagedView> PopoverMenu<M> {
/// Returns a new [`PopoverMenu`].
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
child_builder: None,
menu_builder: None,
anchor: Anchor::TopLeft,
attach: None,
offset: None,
trigger_handle: None,
on_open: None,
full_width: false,
}
}
pub fn full_width(mut self, full_width: bool) -> Self {
self.full_width = full_width;
self
}
pub fn menu(
mut self,
f: impl Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static,
) -> Self {
self.menu_builder = Some(Rc::new(f));
self
}
pub fn with_handle(mut self, handle: PopoverMenuHandle<M>) -> Self {
self.trigger_handle = Some(handle);
self
}
pub fn trigger<T: PopoverTrigger>(mut self, t: T) -> Self {
let on_open = self.on_open.clone();
self.child_builder = Some(Box::new(move |menu, builder| {
let open = menu.borrow().is_some();
t.toggle_state(open)
.when_some(builder, |el, builder| {
el.on_click(move |_event, window, cx| {
show_menu(&builder, &menu, on_open.clone(), window, cx)
})
})
.into_any_element()
}));
self
}
/// This method prevents the trigger button tooltip from being seen when the menu is open.
pub fn trigger_with_tooltip<T: PopoverTrigger + ButtonCommon>(
mut self,
t: T,
tooltip_builder: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
) -> Self {
let on_open = self.on_open.clone();
self.child_builder = Some(Box::new(move |menu, builder| {
let open = menu.borrow().is_some();
t.toggle_state(open)
.when_some(builder, |el, builder| {
el.on_click(move |_, window, cx| {
show_menu(&builder, &menu, on_open.clone(), window, cx)
})
.when(!open, |t| {
t.tooltip(move |window, cx| tooltip_builder(window, cx))
})
})
.into_any_element()
}));
self
}
/// Defines which corner of the menu to anchor to the attachment point.
/// By default, it uses the cursor position. Also see the `attach` method.
pub fn anchor(mut self, anchor: Anchor) -> Self {
self.anchor = anchor;
self
}
/// Defines which corner of the handle to attach the menu's anchor to.
pub fn attach(mut self, attach: Anchor) -> Self {
self.attach = Some(attach);
self
}
/// Offsets the position of the content by that many pixels.
pub fn offset(mut self, offset: Point<Pixels>) -> Self {
self.offset = Some(offset);
self
}
/// Attaches something upon opening the menu.
pub fn on_open(mut self, on_open: Rc<dyn Fn(&mut Window, &mut App)>) -> Self {
self.on_open = Some(on_open);
self
}
fn resolved_attach(&self) -> Anchor {
self.attach
.unwrap_or(self.attach.unwrap_or(match self.anchor {
Anchor::TopLeft => Anchor::BottomLeft,
Anchor::TopCenter => Anchor::BottomCenter,
Anchor::TopRight => Anchor::BottomRight,
Anchor::BottomLeft => Anchor::TopLeft,
Anchor::BottomCenter => Anchor::TopCenter,
Anchor::BottomRight => Anchor::TopRight,
Anchor::LeftCenter => Anchor::LeftCenter,
Anchor::RightCenter => Anchor::RightCenter,
}))
}
fn resolved_offset(&self, window: &mut Window) -> Point<Pixels> {
self.offset.unwrap_or_else(|| {
// Default offset = 4px padding + 1px border
let offset = rems_from_px(5.) * window.rem_size();
match self.anchor {
Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter => {
point(offset, px(0.))
}
Anchor::TopLeft | Anchor::BottomLeft | Anchor::LeftCenter => point(-offset, px(0.)),
Anchor::TopCenter | Anchor::BottomCenter => point(px(0.), px(0.)),
}
})
}
}
fn show_menu<M: ManagedView>(
builder: &Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
menu: &Rc<RefCell<Option<Entity<M>>>>,
on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
window: &mut Window,
cx: &mut App,
) {
let previous_focus_handle = window.focused(cx);
let Some(new_menu) = (builder)(window, cx) else {
return;
};
let menu2 = menu.clone();
window
.subscribe(&new_menu, cx, move |modal, _: &DismissEvent, window, cx| {
if modal.focus_handle(cx).contains_focused(window, cx)
&& let Some(previous_focus_handle) = previous_focus_handle.as_ref()
{
window.focus(previous_focus_handle, cx);
}
*menu2.borrow_mut() = None;
window.refresh();
})
.detach();
// Since menus are rendered in a deferred fashion, their focus handles are
// not linked in the dispatch tree until after the deferred draw callback
// runs. We need to wait for that to happen before focusing it, so that
// calling `contains_focused` on the parent's focus handle returns `true`
// when the menu is focused. This prevents the pane's tab bar buttons from
// flickering when opening popover menus.
let focus_handle = new_menu.focus_handle(cx);
window.on_next_frame(move |window, _cx| {
window.on_next_frame(move |window, cx| {
window.focus(&focus_handle, cx);
});
});
*menu.borrow_mut() = Some(new_menu);
window.refresh();
if let Some(on_open) = on_open {
on_open(window, cx);
}
}
pub struct PopoverMenuElementState<M> {
menu: Rc<RefCell<Option<Entity<M>>>>,
child_bounds: Option<Bounds<Pixels>>,
}
impl<M> Clone for PopoverMenuElementState<M> {
fn clone(&self) -> Self {
Self {
menu: Rc::clone(&self.menu),
child_bounds: self.child_bounds,
}
}
}
impl<M> Default for PopoverMenuElementState<M> {
fn default() -> Self {
Self {
menu: Rc::default(),
child_bounds: None,
}
}
}
pub struct PopoverMenuFrameState<M: ManagedView> {
child_layout_id: Option<LayoutId>,
child_element: Option<AnyElement>,
menu_element: Option<AnyElement>,
menu_handle: Rc<RefCell<Option<Entity<M>>>>,
}
impl<M: ManagedView> Element for PopoverMenu<M> {
type RequestLayoutState = PopoverMenuFrameState<M>;
type PrepaintState = Option<HitboxId>;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
window.with_element_state(
global_id.unwrap(),
|element_state: Option<PopoverMenuElementState<M>>, window| {
let element_state = element_state.unwrap_or_default();
let mut menu_layout_id = None;
let menu_element = element_state.menu.borrow_mut().as_mut().map(|menu| {
let offset = self.resolved_offset(window);
let mut anchored = anchored()
.snap_to_window_with_margin(px(8.))
.anchor(self.anchor)
.offset(offset);
if let Some(child_bounds) = element_state.child_bounds {
anchored =
anchored.position(child_bounds.corner(self.resolved_attach()) + offset);
}
let mut element = deferred(anchored.child(div().occlude().child(menu.clone())))
.with_priority(1)
.into_any();
menu_layout_id = Some(element.request_layout(window, cx));
element
});
let mut child_element = self.child_builder.take().map(|child_builder| {
(child_builder)(element_state.menu.clone(), self.menu_builder.clone())
});
if let Some(trigger_handle) = self.trigger_handle.take()
&& let Some(menu_builder) = self.menu_builder.clone()
{
*trigger_handle.0.borrow_mut() = Some(PopoverMenuHandleState {
menu_builder,
menu: element_state.menu.clone(),
on_open: self.on_open.clone(),
});
}
let child_layout_id = child_element
.as_mut()
.map(|child_element| child_element.request_layout(window, cx));
let mut style = Style::default();
if self.full_width {
style.size = size(relative(1.).into(), Length::Auto);
}
let layout_id = window.request_layout(
style,
menu_layout_id.into_iter().chain(child_layout_id),
cx,
);
(
(
layout_id,
PopoverMenuFrameState {
child_element,
child_layout_id,
menu_element,
menu_handle: element_state.menu.clone(),
},
),
element_state,
)
},
)
}
fn prepaint(
&mut self,
global_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Option<HitboxId> {
if let Some(child) = request_layout.child_element.as_mut() {
child.prepaint(window, cx);
}
if let Some(menu) = request_layout.menu_element.as_mut() {
menu.prepaint(window, cx);
}
request_layout.child_layout_id.map(|layout_id| {
let bounds = window.layout_bounds(layout_id);
window.with_element_state(global_id.unwrap(), |element_state, _cx| {
let mut element_state: PopoverMenuElementState<M> = element_state.unwrap();
element_state.child_bounds = Some(bounds);
((), element_state)
});
window.insert_hitbox(bounds, HitboxBehavior::Normal).id
})
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_: Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
child_hitbox: &mut Option<HitboxId>,
window: &mut Window,
cx: &mut App,
) {
if let Some(mut child) = request_layout.child_element.take() {
child.paint(window, cx);
}
if let Some(mut menu) = request_layout.menu_element.take() {
menu.paint(window, cx);
if let Some(child_hitbox) = *child_hitbox {
let menu_handle = request_layout.menu_handle.clone();
// Mouse-downing outside the menu dismisses it, so we don't
// want a click on the toggle to re-open it.
window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble && child_hitbox.is_hovered(window) {
if let Some(menu) = menu_handle.borrow().as_ref() {
menu.update(cx, |_, cx| {
cx.emit(DismissEvent);
});
}
cx.stop_propagation();
}
})
}
}
}
}
impl<M: ManagedView> IntoElement for PopoverMenu<M> {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}

View File

@@ -0,0 +1,5 @@
mod circular_progress;
mod progress_bar;
pub use circular_progress::*;
pub use progress_bar::*;

View File

@@ -0,0 +1,223 @@
use documented::Documented;
use gpui::{Hsla, PathBuilder, canvas, point};
use std::f32::consts::PI;
use crate::prelude::*;
/// A circular progress indicator that displays progress as an arc growing clockwise from the top.
#[derive(IntoElement, RegisterComponent, Documented)]
pub struct CircularProgress {
value: f32,
max_value: f32,
size: Pixels,
stroke_width: Pixels,
bg_color: Hsla,
progress_color: Hsla,
}
impl CircularProgress {
pub fn new(value: f32, max_value: f32, size: Pixels, cx: &App) -> Self {
Self {
value,
max_value,
size,
stroke_width: px(4.0),
bg_color: cx.theme().colors().border_variant,
progress_color: cx.theme().status().info,
}
}
/// Sets the current progress value.
pub fn value(mut self, value: f32) -> Self {
self.value = value;
self
}
/// Sets the maximum value for the progress indicator.
pub fn max_value(mut self, max_value: f32) -> Self {
self.max_value = max_value;
self
}
/// Sets the size (diameter) of the circular progress indicator.
pub fn size(mut self, size: Pixels) -> Self {
self.size = size;
self
}
/// Sets the stroke width of the circular progress indicator.
pub fn stroke_width(mut self, stroke_width: Pixels) -> Self {
self.stroke_width = stroke_width;
self
}
/// Sets the background circle color.
pub fn bg_color(mut self, color: Hsla) -> Self {
self.bg_color = color;
self
}
/// Sets the progress arc color.
pub fn progress_color(mut self, color: Hsla) -> Self {
self.progress_color = color;
self
}
}
impl RenderOnce for CircularProgress {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let value = self.value;
let max_value = self.max_value;
let size = self.size;
let bg_color = self.bg_color;
let progress_color = self.progress_color;
canvas(
|_, _, _| {},
move |bounds, _, window, _cx| {
let current_value = value;
let center_x = bounds.origin.x + bounds.size.width / 2.0;
let center_y = bounds.origin.y + bounds.size.height / 2.0;
let stroke_width = self.stroke_width;
let radius = (size / 2.0) - stroke_width;
// Draw background circle (full 360 degrees)
let mut bg_builder = PathBuilder::stroke(stroke_width);
// Start at rightmost point
bg_builder.move_to(point(center_x + radius, center_y));
// Draw full circle using two 180-degree arcs
bg_builder.arc_to(
point(radius, radius),
px(0.),
false,
true,
point(center_x - radius, center_y),
);
bg_builder.arc_to(
point(radius, radius),
px(0.),
false,
true,
point(center_x + radius, center_y),
);
bg_builder.close();
if let Ok(path) = bg_builder.build() {
window.paint_path(path, bg_color);
}
// Draw progress arc if there's any progress
let progress = (current_value / max_value).clamp(0.0, 1.0);
if progress > 0.0 {
let mut progress_builder = PathBuilder::stroke(stroke_width);
// Handle 100% progress as a special case by drawing a full circle
if progress >= 0.999 {
// Start at rightmost point
progress_builder.move_to(point(center_x + radius, center_y));
// Draw full circle using two 180-degree arcs
progress_builder.arc_to(
point(radius, radius),
px(0.),
false,
true,
point(center_x - radius, center_y),
);
progress_builder.arc_to(
point(radius, radius),
px(0.),
false,
true,
point(center_x + radius, center_y),
);
progress_builder.close();
} else {
// Start at 12 o'clock (top) position
let start_x = center_x;
let start_y = center_y - radius;
progress_builder.move_to(point(start_x, start_y));
// Calculate the end point of the arc based on progress
// Progress sweeps clockwise from -90° (top)
let angle = -PI / 2.0 + (progress * 2.0 * PI);
let end_x = center_x + radius * angle.cos();
let end_y = center_y + radius * angle.sin();
// Use large_arc flag when progress > 0.5 (more than 180 degrees)
let large_arc = progress > 0.5;
progress_builder.arc_to(
point(radius, radius),
px(0.),
large_arc,
true, // sweep clockwise
point(end_x, end_y),
);
}
if let Ok(path) = progress_builder.build() {
window.paint_path(path, progress_color);
}
}
},
)
.size(size)
}
}
impl Component for CircularProgress {
fn scope() -> ComponentScope {
ComponentScope::Status
}
fn description() -> Option<&'static str> {
Some(
"A circular progress indicator that displays progress as an arc growing clockwise from the top.",
)
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let max_value = 100.0;
let container = || v_flex().items_center().gap_1();
Some(
example_group(vec![single_example(
"Examples",
h_flex()
.gap_6()
.child(
container()
.child(CircularProgress::new(0.0, max_value, px(48.0), cx))
.child(Label::new("0%").size(LabelSize::Small)),
)
.child(
container()
.child(CircularProgress::new(25.0, max_value, px(48.0), cx))
.child(Label::new("25%").size(LabelSize::Small)),
)
.child(
container()
.child(CircularProgress::new(50.0, max_value, px(48.0), cx))
.child(Label::new("50%").size(LabelSize::Small)),
)
.child(
container()
.child(CircularProgress::new(75.0, max_value, px(48.0), cx))
.child(Label::new("75%").size(LabelSize::Small)),
)
.child(
container()
.child(CircularProgress::new(100.0, max_value, px(48.0), cx))
.child(Label::new("100%").size(LabelSize::Small)),
)
.into_any_element(),
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,145 @@
use documented::Documented;
use gpui::{Hsla, point};
use crate::components::Label;
use crate::prelude::*;
/// A progress bar is a horizontal bar that communicates the status of a process.
///
/// A progress bar should not be used to represent indeterminate progress.
#[derive(IntoElement, RegisterComponent, Documented)]
pub struct ProgressBar {
id: ElementId,
value: f32,
max_value: f32,
bg_color: Hsla,
over_color: Hsla,
fg_color: Hsla,
}
impl ProgressBar {
pub fn new(id: impl Into<ElementId>, value: f32, max_value: f32, cx: &App) -> Self {
Self {
id: id.into(),
value,
max_value,
bg_color: cx.theme().colors().background,
over_color: cx.theme().status().error,
fg_color: cx.theme().status().info,
}
}
/// Sets the current value of the progress bar.
pub fn value(mut self, value: f32) -> Self {
self.value = value;
self
}
/// Sets the maximum value of the progress bar.
pub fn max_value(mut self, max_value: f32) -> Self {
self.max_value = max_value;
self
}
/// Sets the background color of the progress bar.
pub fn bg_color(mut self, color: Hsla) -> Self {
self.bg_color = color;
self
}
/// Sets the foreground color of the progress bar.
pub fn fg_color(mut self, color: Hsla) -> Self {
self.fg_color = color;
self
}
/// Sets the over limit color of the progress bar.
pub fn over_color(mut self, color: Hsla) -> Self {
self.over_color = color;
self
}
}
impl RenderOnce for ProgressBar {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let fill_width = (self.value / self.max_value).clamp(0.02, 1.0);
div()
.id(self.id.clone())
.w_full()
.h_2()
.p_0p5()
.rounded_full()
.bg(self.bg_color)
.shadow(vec![gpui::BoxShadow {
color: gpui::black().opacity(0.08),
offset: point(px(0.), px(1.)),
blur_radius: px(0.),
spread_radius: px(0.),
}])
.child(
div()
.h_full()
.rounded_full()
.when(self.value > self.max_value, |div| div.bg(self.over_color))
.when(self.value <= self.max_value, |div| div.bg(self.fg_color))
.w(relative(fill_width)),
)
}
}
impl Component for ProgressBar {
fn scope() -> ComponentScope {
ComponentScope::Status
}
fn description() -> Option<&'static str> {
Some(Self::DOCS)
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let max_value = 180.0;
let container = || v_flex().w_full().gap_1();
Some(
example_group(vec![single_example(
"Examples",
v_flex()
.w_full()
.gap_2()
.child(
container()
.child(
h_flex()
.justify_between()
.child(Label::new("0%"))
.child(Label::new("Empty")),
)
.child(ProgressBar::new("empty", 0.0, max_value, cx)),
)
.child(
container()
.child(
h_flex()
.justify_between()
.child(Label::new("38%"))
.child(Label::new("Partial")),
)
.child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)),
)
.child(
container()
.child(
h_flex()
.justify_between()
.child(Label::new("100%"))
.child(Label::new("Complete")),
)
.child(ProgressBar::new("filled", max_value, max_value, cx)),
)
.into_any_element(),
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,577 @@
use std::rc::Rc;
use gpui::{
AbsoluteLength, AppContext as _, Bounds, DefiniteLength, DragMoveEvent, Empty, Entity,
EntityId, Length, Stateful, WeakEntity,
};
use itertools::intersperse_with;
use super::data_table::{
ResizableColumnsState,
table_row::{IntoTableRow as _, TableRow},
};
use crate::{
ActiveTheme as _, AnyElement, App, Context, Div, FluentBuilder as _, InteractiveElement,
IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, h_flex,
px,
};
pub(crate) const RESIZE_COLUMN_WIDTH: f32 = 8.0;
pub(crate) const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
/// Drag payload for column resize handles.
/// Includes the `EntityId` of the owning column state so that
/// `on_drag_move` handlers on unrelated tables ignore the event.
#[derive(Debug)]
pub(crate) struct DraggedColumn {
pub(crate) col_idx: usize,
pub(crate) state_id: EntityId,
}
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum TableResizeBehavior {
None,
Resizable,
MinSize(f32),
}
impl TableResizeBehavior {
pub fn is_resizable(&self) -> bool {
*self != TableResizeBehavior::None
}
pub fn min_size(&self) -> Option<f32> {
match self {
TableResizeBehavior::None => None,
TableResizeBehavior::Resizable => Some(0.05),
TableResizeBehavior::MinSize(min_size) => Some(*min_size),
}
}
}
#[derive(Clone)]
pub(crate) enum ColumnsStateRef {
Redistributable(WeakEntity<RedistributableColumnsState>),
Resizable(WeakEntity<ResizableColumnsState>),
}
#[derive(Clone)]
pub struct HeaderResizeInfo {
pub(crate) columns_state: ColumnsStateRef,
pub resize_behavior: TableRow<TableResizeBehavior>,
}
impl HeaderResizeInfo {
pub fn from_redistributable(
columns_state: &Entity<RedistributableColumnsState>,
cx: &App,
) -> Self {
let resize_behavior = columns_state.read(cx).resize_behavior().clone();
Self {
columns_state: ColumnsStateRef::Redistributable(columns_state.downgrade()),
resize_behavior,
}
}
pub fn from_resizable(columns_state: &Entity<ResizableColumnsState>, cx: &App) -> Self {
let resize_behavior = columns_state.read(cx).resize_behavior().clone();
Self {
columns_state: ColumnsStateRef::Resizable(columns_state.downgrade()),
resize_behavior,
}
}
pub fn reset_column(&self, col_idx: usize, window: &mut Window, cx: &mut App) {
match &self.columns_state {
ColumnsStateRef::Redistributable(weak) => {
weak.update(cx, |state, cx| {
state.reset_column_to_initial_width(col_idx, window);
cx.notify();
})
.ok();
}
ColumnsStateRef::Resizable(weak) => {
weak.update(cx, |state, cx| {
state.reset_column_to_initial_width(col_idx);
cx.notify();
})
.ok();
}
}
}
}
pub struct RedistributableColumnsState {
pub(crate) initial_widths: TableRow<DefiniteLength>,
pub(crate) committed_widths: TableRow<DefiniteLength>,
pub(crate) preview_widths: TableRow<DefiniteLength>,
pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
pub(crate) cached_container_width: Pixels,
}
impl RedistributableColumnsState {
pub fn new(
cols: usize,
initial_widths: Vec<impl Into<DefiniteLength>>,
resize_behavior: Vec<TableResizeBehavior>,
) -> Self {
let widths: TableRow<DefiniteLength> = initial_widths
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into_table_row(cols);
Self {
initial_widths: widths.clone(),
committed_widths: widths.clone(),
preview_widths: widths,
resize_behavior: resize_behavior.into_table_row(cols),
cached_container_width: Default::default(),
}
}
pub fn cols(&self) -> usize {
self.committed_widths.cols()
}
pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
&self.initial_widths
}
pub fn preview_widths(&self) -> &TableRow<DefiniteLength> {
&self.preview_widths
}
pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
&self.resize_behavior
}
pub fn widths_to_render(&self) -> TableRow<Length> {
self.preview_widths.map_cloned(Length::Definite)
}
pub fn preview_fractions(&self, rem_size: Pixels) -> TableRow<f32> {
if self.cached_container_width > px(0.) {
self.preview_widths
.map_ref(|length| Self::get_fraction(length, self.cached_container_width, rem_size))
} else {
self.preview_widths.map_ref(|length| match length {
DefiniteLength::Fraction(fraction) => *fraction,
DefiniteLength::Absolute(_) => 0.0,
})
}
}
pub fn preview_column_width(&self, column_index: usize, window: &Window) -> Option<Pixels> {
let width = self.preview_widths().as_slice().get(column_index)?;
match width {
DefiniteLength::Fraction(fraction) if self.cached_container_width > px(0.) => {
Some(self.cached_container_width * *fraction)
}
DefiniteLength::Fraction(_) => None,
DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => Some(*pixels),
DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
Some(rems_width.to_pixels(window.rem_size()))
}
}
}
pub fn cached_container_width(&self) -> Pixels {
self.cached_container_width
}
pub fn set_cached_container_width(&mut self, width: Pixels) {
self.cached_container_width = width;
}
pub fn commit_preview(&mut self) {
self.committed_widths = self.preview_widths.clone();
}
pub fn reset_column_to_initial_width(&mut self, column_index: usize, window: &Window) {
let bounds_width = self.cached_container_width;
if bounds_width <= px(0.) {
return;
}
let rem_size = window.rem_size();
let initial_sizes = self
.initial_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
let widths = self
.committed_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
let updated_widths =
Self::reset_to_initial_size(column_index, widths, initial_sizes, &self.resize_behavior);
self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
self.preview_widths = self.committed_widths.clone();
}
fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
match length {
DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
rems_width.to_pixels(rem_size) / bounds_width
}
DefiniteLength::Fraction(fraction) => *fraction,
}
}
pub(crate) fn reset_to_initial_size(
col_idx: usize,
mut widths: TableRow<f32>,
initial_sizes: TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
) -> TableRow<f32> {
let diff = initial_sizes[col_idx] - widths[col_idx];
let left_diff =
initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
- widths[col_idx + 1..].iter().sum::<f32>();
let go_left_first = if diff < 0.0 {
left_diff > right_diff
} else {
left_diff < right_diff
};
if !go_left_first {
let diff_remaining =
Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
if diff_remaining != 0.0 && col_idx > 0 {
Self::propagate_resize_diff(
diff_remaining,
col_idx,
&mut widths,
resize_behavior,
-1,
);
}
} else {
let diff_remaining =
Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
if diff_remaining != 0.0 {
Self::propagate_resize_diff(
diff_remaining,
col_idx,
&mut widths,
resize_behavior,
1,
);
}
}
widths
}
fn on_drag_move(
&mut self,
drag_event: &DragMoveEvent<DraggedColumn>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let drag_position = drag_event.event.position;
let bounds = drag_event.bounds;
let bounds_width = bounds.right() - bounds.left();
if bounds_width <= px(0.) {
return;
}
let mut col_position = 0.0;
let rem_size = window.rem_size();
let col_idx = drag_event.drag(cx).col_idx;
let divider_width = Self::get_fraction(
&DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
bounds_width,
rem_size,
);
let mut widths = self
.committed_widths
.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
for length in widths[0..=col_idx].iter() {
col_position += length + divider_width;
}
let mut total_length_ratio = col_position;
for length in widths[col_idx + 1..].iter() {
total_length_ratio += length;
}
let cols = self.resize_behavior.cols();
total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
let drag_fraction = drag_fraction * total_length_ratio;
let diff = drag_fraction - col_position - divider_width / 2.0;
Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
self.preview_widths = widths.map(DefiniteLength::Fraction);
}
pub(crate) fn drag_column_handle(
diff: f32,
col_idx: usize,
widths: &mut TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
) {
if diff > 0.0 {
Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
} else {
Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
}
}
pub(crate) fn propagate_resize_diff(
diff: f32,
col_idx: usize,
widths: &mut TableRow<f32>,
resize_behavior: &TableRow<TableResizeBehavior>,
direction: i8,
) -> f32 {
let mut diff_remaining = diff;
if resize_behavior[col_idx].min_size().is_none() {
return diff;
}
let step_right;
let step_left;
if direction < 0 {
step_right = 0;
step_left = 1;
} else {
step_right = 1;
step_left = 0;
}
if col_idx == 0 && direction < 0 {
return diff;
}
let mut curr_column = col_idx + step_right - step_left;
while diff_remaining != 0.0 && curr_column < widths.cols() {
let Some(min_size) = resize_behavior[curr_column].min_size() else {
if curr_column == 0 {
break;
}
curr_column -= step_left;
curr_column += step_right;
continue;
};
let curr_width = widths[curr_column] - diff_remaining;
widths[curr_column] = curr_width;
if min_size > curr_width {
diff_remaining = min_size - curr_width;
widths[curr_column] = min_size;
} else {
diff_remaining = 0.0;
break;
}
if curr_column == 0 {
break;
}
curr_column -= step_left;
curr_column += step_right;
}
widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
diff_remaining
}
}
pub fn bind_redistributable_columns(
container: Div,
columns_state: Entity<RedistributableColumnsState>,
) -> Div {
container
.on_drag_move::<DraggedColumn>({
let columns_state = columns_state.clone();
move |event, window, cx| {
if event.drag(cx).state_id != columns_state.entity_id() {
return;
}
columns_state.update(cx, |columns, cx| {
columns.on_drag_move(event, window, cx);
});
}
})
.on_children_prepainted({
let columns_state = columns_state.clone();
move |bounds, _, cx| {
if let Some(width) = child_bounds_width(&bounds) {
columns_state.update(cx, |columns, _| {
columns.set_cached_container_width(width);
});
}
}
})
.on_drop::<DraggedColumn>(move |_, _, cx| {
columns_state.update(cx, |columns, _| {
columns.commit_preview();
});
})
}
pub fn render_redistributable_columns_resize_handles(
columns_state: &Entity<RedistributableColumnsState>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let (column_widths, resize_behavior) = {
let state = columns_state.read(cx);
(state.widths_to_render(), state.resize_behavior().clone())
};
let mut column_ix = 0;
let resize_behavior = Rc::new(resize_behavior);
let dividers = intersperse_with(
column_widths
.as_slice()
.iter()
.copied()
.map(|width| resize_spacer(width).into_any_element()),
|| {
let current_column_ix = column_ix;
let resize_behavior = Rc::clone(&resize_behavior);
let columns_state = columns_state.clone();
column_ix += 1;
{
let divider = div().id(current_column_ix).relative().top_0();
let entity_id = columns_state.entity_id();
let on_reset: Rc<dyn Fn(&mut Window, &mut App)> = {
let columns_state = columns_state.clone();
Rc::new(move |window, cx| {
columns_state.update(cx, |columns, cx| {
columns.reset_column_to_initial_width(current_column_ix, window);
cx.notify();
});
})
};
let on_drag_end: Option<Rc<dyn Fn(&mut App)>> = {
Some(Rc::new(move |cx| {
columns_state.update(cx, |state, _| state.commit_preview());
}))
};
render_column_resize_divider(
divider,
current_column_ix,
resize_behavior[current_column_ix].is_resizable(),
entity_id,
on_reset,
on_drag_end,
window,
cx,
)
}
},
);
h_flex()
.id("resize-handles")
.absolute()
.inset_0()
.w_full()
.children(dividers)
.into_any_element()
}
/// Builds a single column resize divider with an interactive drag handle.
///
/// The caller provides:
/// - `divider`: a pre-positioned divider element (with absolute or relative positioning)
/// - `col_idx`: which column this divider is for
/// - `is_resizable`: whether the column supports resizing
/// - `entity_id`: the `EntityId` of the owning column state (for the drag payload)
/// - `on_reset`: called on double-click to reset the column to its initial width
/// - `on_drag_end`: called when the drag ends (e.g. to commit preview widths)
pub(crate) fn render_column_resize_divider(
divider: Stateful<Div>,
col_idx: usize,
is_resizable: bool,
entity_id: EntityId,
on_reset: Rc<dyn Fn(&mut Window, &mut App)>,
on_drag_end: Option<Rc<dyn Fn(&mut App)>>,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
window.with_id(col_idx, |window| {
let mut resize_divider = divider.w(px(RESIZE_DIVIDER_WIDTH)).h_full().bg(cx
.theme()
.colors()
.border
.opacity(0.8));
let mut resize_handle = div()
.id("column-resize-handle")
.absolute()
.left_neg_0p5()
.w(px(RESIZE_COLUMN_WIDTH))
.h_full();
if is_resizable {
let is_highlighted = window.use_state(cx, |_window, _cx| false);
resize_divider = resize_divider.when(*is_highlighted.read(cx), |div| {
div.bg(cx.theme().colors().border_focused)
});
resize_handle = resize_handle
.on_hover({
let is_highlighted = is_highlighted.clone();
move |&was_hovered, _, cx| is_highlighted.write(cx, was_hovered)
})
.cursor_col_resize()
.on_click(move |event, window, cx| {
if event.click_count() >= 2 {
on_reset(window, cx);
}
cx.stop_propagation();
})
.on_drag(
DraggedColumn {
col_idx,
state_id: entity_id,
},
{
let is_highlighted = is_highlighted.clone();
move |_, _offset, _window, cx| {
is_highlighted.write(cx, true);
cx.new(|_cx| Empty)
}
},
)
.on_drop::<DraggedColumn>(move |_, _, cx| {
is_highlighted.write(cx, false);
if let Some(on_drag_end) = &on_drag_end {
on_drag_end(cx);
}
});
}
resize_divider.child(resize_handle).into_any_element()
})
}
fn resize_spacer(width: Length) -> Div {
div().w(width).h_full()
}
fn child_bounds_width(bounds: &[Bounds<Pixels>]) -> Option<Pixels> {
let first_bounds = bounds.first()?;
let mut left = first_bounds.left();
let mut right = first_bounds.right();
for bound in bounds.iter().skip(1) {
left = left.min(bound.left());
right = right.max(bound.right());
}
Some(right - left)
}

View File

@@ -0,0 +1,299 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
Anchor, AnyElement, App, Bounds, DismissEvent, DispatchPhase, Element, ElementId, Entity,
Focusable as _, GlobalElementId, Hitbox, HitboxBehavior, InteractiveElement, IntoElement,
LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Window,
anchored, deferred, div, px,
};
pub struct RightClickMenu<M: ManagedView> {
id: ElementId,
child_builder: Option<Box<dyn FnOnce(bool, &mut Window, &mut App) -> AnyElement + 'static>>,
menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Entity<M> + 'static>>,
anchor: Option<Anchor>,
attach: Option<Anchor>,
}
impl<M: ManagedView> RightClickMenu<M> {
pub fn menu(mut self, f: impl Fn(&mut Window, &mut App) -> Entity<M> + 'static) -> Self {
self.menu_builder = Some(Rc::new(f));
self
}
pub fn trigger<F, E>(mut self, e: F) -> Self
where
F: FnOnce(bool, &mut Window, &mut App) -> E + 'static,
E: IntoElement + 'static,
{
self.child_builder = Some(Box::new(move |is_menu_active, window, cx| {
e(is_menu_active, window, cx).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: Anchor) -> 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: Anchor) -> Self {
self.attach = Some(attach);
self
}
fn with_element_state<R>(
&mut self,
global_id: &GlobalElementId,
window: &mut Window,
cx: &mut App,
f: impl FnOnce(&mut Self, &mut MenuHandleElementState<M>, &mut Window, &mut App) -> R,
) -> R {
window.with_optional_element_state::<MenuHandleElementState<M>, _>(
Some(global_id),
|element_state, window| {
let mut element_state = element_state.unwrap().unwrap_or_default();
let result = f(self, &mut element_state, window, cx);
(result, Some(element_state))
},
)
}
}
/// Creates a [`RightClickMenu`]
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 MenuHandleElementState<M> {
menu: Rc<RefCell<Option<Entity<M>>>>,
position: Rc<RefCell<Point<Pixels>>>,
}
impl<M> Clone for MenuHandleElementState<M> {
fn clone(&self) -> Self {
Self {
menu: Rc::clone(&self.menu),
position: Rc::clone(&self.position),
}
}
}
impl<M> Default for MenuHandleElementState<M> {
fn default() -> Self {
Self {
menu: Rc::default(),
position: Rc::default(),
}
}
}
pub struct RequestLayoutState {
child_layout_id: Option<LayoutId>,
child_element: Option<AnyElement>,
menu_element: Option<AnyElement>,
}
pub struct PrepaintState {
hitbox: Hitbox,
child_bounds: Option<Bounds<Pixels>>,
}
impl<M: ManagedView> Element for RightClickMenu<M> {
type RequestLayoutState = RequestLayoutState;
type PrepaintState = PrepaintState;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
self.with_element_state(
id.unwrap(),
window,
cx,
|this, element_state, window, cx| {
let mut menu_layout_id = None;
let menu_element = element_state.menu.borrow_mut().as_mut().map(|menu| {
let mut anchored = anchored().snap_to_window_with_margin(px(8.));
if let Some(anchor) = this.anchor {
anchored = anchored.anchor(anchor);
}
anchored = anchored.position(*element_state.position.borrow());
let mut element = deferred(anchored.child(div().occlude().child(menu.clone())))
.with_priority(1)
.into_any();
menu_layout_id = Some(element.request_layout(window, cx));
element
});
let mut child_element = this.child_builder.take().map(|child_builder| {
(child_builder)(element_state.menu.borrow().is_some(), window, cx)
});
let child_layout_id = child_element
.as_mut()
.map(|child_element| child_element.request_layout(window, cx));
let layout_id = window.request_layout(
gpui::Style::default(),
menu_layout_id.into_iter().chain(child_layout_id),
cx,
);
(
layout_id,
RequestLayoutState {
child_element,
child_layout_id,
menu_element,
},
)
},
)
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> PrepaintState {
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
if let Some(child) = request_layout.child_element.as_mut() {
child.prepaint(window, cx);
}
if let Some(menu) = request_layout.menu_element.as_mut() {
menu.prepaint(window, cx);
}
PrepaintState {
hitbox,
child_bounds: request_layout
.child_layout_id
.map(|layout_id| window.layout_bounds(layout_id)),
}
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_inspector_id: Option<&gpui::InspectorElementId>,
_bounds: Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
prepaint_state: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
self.with_element_state(
id.unwrap(),
window,
cx,
|this, element_state, window, cx| {
if let Some(mut child) = request_layout.child_element.take() {
child.paint(window, cx);
}
if let Some(mut menu) = request_layout.menu_element.take() {
menu.paint(window, cx);
}
let Some(builder) = this.menu_builder.take() else {
return;
};
let attach = this.attach;
let menu = element_state.menu.clone();
let position = element_state.position.clone();
let child_bounds = prepaint_state.child_bounds;
let hitbox_id = prepaint_state.hitbox.id;
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
if phase == DispatchPhase::Bubble
&& event.button == MouseButton::Right
&& hitbox_id.is_hovered(window)
{
cx.stop_propagation();
window.prevent_default();
let new_menu = (builder)(window, cx);
let menu2 = menu.clone();
let previous_focus_handle = window.focused(cx);
window
.subscribe(&new_menu, cx, move |modal, _: &DismissEvent, window, cx| {
if modal.focus_handle(cx).contains_focused(window, cx)
&& let Some(previous_focus_handle) =
previous_focus_handle.as_ref()
{
window.focus(previous_focus_handle, cx);
}
*menu2.borrow_mut() = None;
window.refresh();
})
.detach();
// Since menus are rendered in a deferred fashion, their focus handles are
// not linked in the dispatch tree until after the deferred draw callback
// runs. We need to wait for that to happen before focusing it, so that
// calling `contains_focused` on the parent's focus handle returns `true`
// when the menu is focused. This prevents the pane's tab bar buttons from
// flickering when opening menus.
let focus_handle = new_menu.focus_handle(cx);
window.on_next_frame(move |window, _cx| {
window.on_next_frame(move |window, cx| {
window.focus(&focus_handle, cx);
});
});
*menu.borrow_mut() = Some(new_menu);
*position.borrow_mut() = if let Some(child_bounds) = child_bounds {
if let Some(attach) = attach {
child_bounds.corner(attach)
} else {
window.mouse_position()
}
} else {
window.mouse_position()
};
window.refresh();
}
});
},
)
}
}
impl<M: ManagedView> IntoElement for RightClickMenu<M> {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,335 @@
use std::{ops::Range, rc::Rc};
use gpui::{
AnyElement, App, AvailableSpace, Bounds, Context, Element, ElementId, Entity, GlobalElementId,
InspectorElementId, IntoElement, LayoutId, Pixels, Point, Render, Style, UniformListDecoration,
Window, point, px, size,
};
use smallvec::SmallVec;
pub trait StickyCandidate {
fn depth(&self) -> usize;
}
pub struct StickyItems<T> {
compute_fn: Rc<dyn Fn(Range<usize>, &mut Window, &mut App) -> SmallVec<[T; 8]>>,
render_fn: Rc<dyn Fn(T, &mut Window, &mut App) -> SmallVec<[AnyElement; 8]>>,
decorations: Vec<Box<dyn StickyItemsDecoration>>,
}
pub fn sticky_items<V, T>(
entity: Entity<V>,
compute_fn: impl Fn(&mut V, Range<usize>, &mut Window, &mut Context<V>) -> SmallVec<[T; 8]>
+ 'static,
render_fn: impl Fn(&mut V, T, &mut Window, &mut Context<V>) -> SmallVec<[AnyElement; 8]> + 'static,
) -> StickyItems<T>
where
V: Render,
T: StickyCandidate + Clone + 'static,
{
let entity_compute = entity.clone();
let entity_render = entity;
let compute_fn = Rc::new(
move |range: Range<usize>, window: &mut Window, cx: &mut App| -> SmallVec<[T; 8]> {
entity_compute.update(cx, |view, cx| compute_fn(view, range, window, cx))
},
);
let render_fn = Rc::new(
move |entry: T, window: &mut Window, cx: &mut App| -> SmallVec<[AnyElement; 8]> {
entity_render.update(cx, |view, cx| render_fn(view, entry, window, cx))
},
);
StickyItems {
compute_fn,
render_fn,
decorations: Vec::new(),
}
}
impl<T> StickyItems<T>
where
T: StickyCandidate + Clone + 'static,
{
/// Adds a decoration element to the sticky items.
pub fn with_decoration(mut self, decoration: impl StickyItemsDecoration + 'static) -> Self {
self.decorations.push(Box::new(decoration));
self
}
}
struct StickyItemsElement {
drifting_element: Option<AnyElement>,
drifting_decoration: Option<AnyElement>,
rest_elements: SmallVec<[AnyElement; 8]>,
rest_decorations: SmallVec<[AnyElement; 1]>,
}
impl IntoElement for StickyItemsElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for StickyItemsElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), [], cx), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_prepaint: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
if let Some(ref mut drifting_element) = self.drifting_element {
drifting_element.paint(window, cx);
}
if let Some(ref mut drifting_decoration) = self.drifting_decoration {
drifting_decoration.paint(window, cx);
}
for item in self.rest_elements.iter_mut().rev() {
item.paint(window, cx);
}
for item in self.rest_decorations.iter_mut() {
item.paint(window, cx);
}
}
}
impl<T> UniformListDecoration for StickyItems<T>
where
T: StickyCandidate + Clone + 'static,
{
fn compute(
&self,
visible_range: Range<usize>,
bounds: Bounds<Pixels>,
scroll_offset: Point<Pixels>,
item_height: Pixels,
_item_count: usize,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let entries = (self.compute_fn)(visible_range.clone(), window, cx);
let Some(sticky_anchor) = find_sticky_anchor(&entries, visible_range.start) else {
return StickyItemsElement {
drifting_element: None,
drifting_decoration: None,
rest_elements: SmallVec::new(),
rest_decorations: SmallVec::new(),
}
.into_any_element();
};
let anchor_depth = sticky_anchor.entry.depth();
let mut elements = (self.render_fn)(sticky_anchor.entry, window, cx);
let items_count = elements.len();
let indents: SmallVec<[usize; 8]> = (0..items_count)
.map(|ix| anchor_depth.saturating_sub(items_count.saturating_sub(ix)))
.collect();
let mut last_decoration_element = None;
let mut rest_decoration_elements = SmallVec::new();
let expanded_width = bounds.size.width + scroll_offset.x.abs();
let decor_available_space = size(
AvailableSpace::Definite(expanded_width),
AvailableSpace::Definite(bounds.size.height),
);
let drifting_y_offset = if sticky_anchor.drifting {
let scroll_top = -scroll_offset.y;
let anchor_top = item_height * (sticky_anchor.index + 1);
let sticky_area_height = item_height * items_count;
(anchor_top - scroll_top - sticky_area_height).min(Pixels::ZERO)
} else {
Pixels::ZERO
};
let (drifting_indent, rest_indents) = if sticky_anchor.drifting && !indents.is_empty() {
let last = indents[indents.len() - 1];
let rest: SmallVec<[usize; 8]> = indents[..indents.len() - 1].iter().copied().collect();
(Some(last), rest)
} else {
(None, indents)
};
let base_origin = bounds.origin - point(px(0.), scroll_offset.y);
for decoration in &self.decorations {
if let Some(drifting_indent) = drifting_indent {
let drifting_indent_vec: SmallVec<[usize; 8]> =
[drifting_indent].into_iter().collect();
let sticky_origin = base_origin
+ point(px(0.), item_height * rest_indents.len() + drifting_y_offset);
let decoration_bounds = Bounds::new(sticky_origin, bounds.size);
let mut drifting_dec = decoration.as_ref().compute(
&drifting_indent_vec,
decoration_bounds,
scroll_offset,
item_height,
window,
cx,
);
drifting_dec.layout_as_root(decor_available_space, window, cx);
drifting_dec.prepaint_at(sticky_origin, window, cx);
last_decoration_element = Some(drifting_dec);
}
if !rest_indents.is_empty() {
let decoration_bounds = Bounds::new(base_origin, bounds.size);
let mut rest_dec = decoration.as_ref().compute(
&rest_indents,
decoration_bounds,
scroll_offset,
item_height,
window,
cx,
);
rest_dec.layout_as_root(decor_available_space, window, cx);
rest_dec.prepaint_at(bounds.origin, window, cx);
rest_decoration_elements.push(rest_dec);
}
}
let (mut drifting_element, mut rest_elements) =
if sticky_anchor.drifting && !elements.is_empty() {
let last = elements.pop().unwrap();
(Some(last), elements)
} else {
(None, elements)
};
let element_available_space = size(
AvailableSpace::Definite(expanded_width),
AvailableSpace::Definite(item_height),
);
// order of prepaint is important here
// mouse events checks hitboxes in reverse insertion order
if let Some(ref mut drifting_element) = drifting_element {
let sticky_origin = base_origin
+ point(
px(0.),
item_height * rest_elements.len() + drifting_y_offset,
);
drifting_element.layout_as_root(element_available_space, window, cx);
drifting_element.prepaint_at(sticky_origin, window, cx);
}
for (ix, element) in rest_elements.iter_mut().enumerate() {
let sticky_origin = base_origin + point(px(0.), item_height * ix);
element.layout_as_root(element_available_space, window, cx);
element.prepaint_at(sticky_origin, window, cx);
}
StickyItemsElement {
drifting_element,
drifting_decoration: last_decoration_element,
rest_elements,
rest_decorations: rest_decoration_elements,
}
.into_any_element()
}
}
struct StickyAnchor<T> {
entry: T,
index: usize,
drifting: bool,
}
fn find_sticky_anchor<T: StickyCandidate + Clone>(
entries: &SmallVec<[T; 8]>,
visible_range_start: usize,
) -> Option<StickyAnchor<T>> {
let mut iter = entries.iter().enumerate().peekable();
while let Some((ix, current_entry)) = iter.next() {
let depth = current_entry.depth();
if depth < ix {
return Some(StickyAnchor {
entry: current_entry.clone(),
index: visible_range_start + ix,
drifting: false,
});
}
if let Some(&(_next_ix, next_entry)) = iter.peek() {
let next_depth = next_entry.depth();
let next_item_outdented = next_depth + 1 == depth;
let depth_same_as_index = depth == ix;
let depth_greater_than_index = depth == ix + 1;
if next_item_outdented && (depth_same_as_index || depth_greater_than_index) {
return Some(StickyAnchor {
entry: current_entry.clone(),
index: visible_range_start + ix,
drifting: depth_greater_than_index,
});
}
}
}
None
}
/// A decoration for a [`StickyItems`]. This can be used for various things,
/// such as rendering indent guides, or other visual effects.
pub trait StickyItemsDecoration {
/// Compute the decoration element, given the visible range of list items,
/// the bounds of the list, and the height of each item.
fn compute(
&self,
indents: &SmallVec<[usize; 8]>,
bounds: Bounds<Pixels>,
scroll_offset: Point<Pixels>,
item_height: Pixels,
window: &mut Window,
cx: &mut App,
) -> AnyElement;
}

View File

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

View File

@@ -0,0 +1,238 @@
use std::cmp::Ordering;
use gpui::{AnyElement, IntoElement, Stateful};
use smallvec::SmallVec;
use crate::prelude::*;
const START_TAB_SLOT_SIZE: Pixels = px(12.);
const END_TAB_SLOT_SIZE: Pixels = px(14.);
/// 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, RegisterComponent)]
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 {
let id = id.into();
Self {
div: div()
.id(id.clone())
.debug_selector(|| format!("TAB-{}", id)),
selected: false,
position: TabPosition::First,
close_side: TabCloseSide::End,
start_slot: None,
end_slot: None,
children: SmallVec::new(),
}
}
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
}
pub fn content_height(cx: &App) -> Pixels {
DynamicSpacing::Base32.px(cx) - px(1.)
}
pub fn container_height(cx: &App) -> Pixels {
DynamicSpacing::Base32.px(cx)
}
}
impl InteractiveElement for Tab {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.div.interactivity()
}
}
impl StatefulInteractiveElement for Tab {}
impl Toggleable for Tab {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl ParentElement for Tab {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for Tab {
#[allow(refining_impl_trait)]
fn render(self, _: &mut Window, cx: &mut App) -> 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,
),
};
let (start_slot, end_slot) = {
let start_slot = h_flex()
.size(START_TAB_SLOT_SIZE)
.justify_center()
.children(self.start_slot);
let end_slot = h_flex()
.size(END_TAB_SLOT_SIZE)
.justify_center()
.children(self.end_slot);
match self.close_side {
TabCloseSide::End => (start_slot, end_slot),
TabCloseSide::Start => (end_slot, start_slot),
}
};
self.div
.h(Tab::container_height(cx))
.bg(tab_bg)
.border_color(cx.theme().colors().border)
.map(|this| match self.position {
TabPosition::First => {
if self.selected {
this.pl_px().border_r_1().pb_px()
} else {
this.pl_px().pr_px().border_b_1()
}
}
TabPosition::Last => {
if self.selected {
this.border_l_1().border_r_1().pb_px()
} else {
this.pl_px().border_b_1().border_r_1()
}
}
TabPosition::Middle(Ordering::Equal) => this.border_l_1().border_r_1().pb_px(),
TabPosition::Middle(Ordering::Less) => this.border_l_1().pr_px().border_b_1(),
TabPosition::Middle(Ordering::Greater) => this.border_r_1().pl_px().border_b_1(),
})
.cursor_pointer()
.child(
h_flex()
.group("")
.relative()
.h(Tab::content_height(cx))
.px(DynamicSpacing::Base04.px(cx))
.gap(DynamicSpacing::Base04.rems(cx))
.text_color(text_color)
.child(start_slot)
.children(self.children)
.child(end_slot),
)
}
}
impl Component for Tab {
fn scope() -> ComponentScope {
ComponentScope::Navigation
}
fn description() -> Option<&'static str> {
Some(
"A tab component that can be used in a tabbed interface, supporting different positions and states.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![example_group_with_title(
"Variations",
vec![
single_example(
"Default",
Tab::new("default").child("Default Tab").into_any_element(),
),
single_example(
"Selected",
Tab::new("selected")
.toggle_state(true)
.child("Selected Tab")
.into_any_element(),
),
single_example(
"First",
Tab::new("first")
.position(TabPosition::First)
.child("First Tab")
.into_any_element(),
),
single_example(
"Middle",
Tab::new("middle")
.position(TabPosition::Middle(Ordering::Equal))
.child("Middle Tab")
.into_any_element(),
),
single_example(
"Last",
Tab::new("last")
.position(TabPosition::Last)
.child("Last Tab")
.into_any_element(),
),
],
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,207 @@
use gpui::{AnyElement, ScrollHandle};
use smallvec::SmallVec;
use crate::Tab;
use crate::prelude::*;
#[derive(IntoElement, RegisterComponent)]
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.clone());
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 extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for TabBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
div()
.id(self.id)
.group("tab_bar")
.flex()
.flex_none()
.w_full()
.h(Tab::container_height(cx))
.bg(cx.theme().colors().tab_bar_background)
.when(!self.start_children.is_empty(), |this| {
this.child(
h_flex()
.flex_none()
.gap(DynamicSpacing::Base04.rems(cx))
.px(DynamicSpacing::Base06.rems(cx))
.border_b_1()
.border_r_1()
.border_color(cx.theme().colors().border)
.children(self.start_children),
)
})
.child(
div()
.relative()
.flex_1()
.h_full()
.overflow_x_hidden()
.child(
div()
.absolute()
.top_0()
.left_0()
.size_full()
.border_b_1()
.border_color(cx.theme().colors().border),
)
.child(
h_flex()
.id("tabs")
.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_flex()
.flex_none()
.gap(DynamicSpacing::Base04.rems(cx))
.px(DynamicSpacing::Base06.rems(cx))
.border_color(cx.theme().colors().border)
.border_b_1()
.border_l_1()
.children(self.end_children),
)
})
}
}
impl Component for TabBar {
fn scope() -> ComponentScope {
ComponentScope::Navigation
}
fn name() -> &'static str {
"TabBar"
}
fn description() -> Option<&'static str> {
Some("A horizontal bar containing tabs for navigation between different views or sections.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Basic Usage",
vec![
single_example(
"Empty TabBar",
TabBar::new("empty_tab_bar").into_any_element(),
),
single_example(
"With Tabs",
TabBar::new("tab_bar_with_tabs")
.child(Tab::new("tab1"))
.child(Tab::new("tab2"))
.child(Tab::new("tab3"))
.into_any_element(),
),
],
),
example_group_with_title(
"With Start and End Children",
vec![single_example(
"Full TabBar",
TabBar::new("full_tab_bar")
.start_child(Button::new("start_button", "Start"))
.child(Tab::new("tab1"))
.child(Tab::new("tab2"))
.child(Tab::new("tab3"))
.end_child(Button::new("end_button", "End"))
.into_any_element(),
)],
),
])
.into_any_element(),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
use std::borrow::Borrow;
use std::rc::Rc;
use crate::prelude::*;
use crate::{Color, KeyBinding, Label, LabelSize, StyledExt, h_flex, v_flex};
use gpui::{Action, AnyElement, AnyView, AppContext, FocusHandle, IntoElement, Render};
#[derive(RegisterComponent)]
pub struct Tooltip {
title: Title,
meta: Option<SharedString>,
key_binding: Option<KeyBinding>,
}
#[derive(Clone, IntoElement)]
enum Title {
Str(SharedString),
Callback(Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>),
}
impl From<SharedString> for Title {
fn from(value: SharedString) -> Self {
Title::Str(value)
}
}
impl RenderOnce for Title {
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
match self {
Title::Str(title) => title.into_any_element(),
Title::Callback(element) => element(window, cx),
}
}
}
impl Tooltip {
pub fn simple(title: impl Into<SharedString>, cx: &mut App) -> AnyView {
cx.new(|_| Self {
title: Title::Str(title.into()),
meta: None,
key_binding: None,
})
.into()
}
pub fn text(title: impl Into<SharedString>) -> impl Fn(&mut Window, &mut App) -> AnyView {
let title = title.into();
move |_, cx| {
cx.new(|_| Self {
title: title.clone().into(),
meta: None,
key_binding: None,
})
.into()
}
}
pub fn for_action_title<T: Into<SharedString>>(
title: T,
action: &dyn Action,
) -> impl Fn(&mut Window, &mut App) -> AnyView + use<T> {
let title = title.into();
let action = action.boxed_clone();
move |_, cx| {
cx.new(|cx| Self {
title: Title::Str(title.clone()),
meta: None,
key_binding: Some(KeyBinding::for_action(action.as_ref(), cx)),
})
.into()
}
}
pub fn for_action_title_in<Str: Into<SharedString>>(
title: Str,
action: &dyn Action,
focus_handle: &FocusHandle,
) -> impl Fn(&mut Window, &mut App) -> AnyView + use<Str> {
let title = title.into();
let action = action.boxed_clone();
let focus_handle = focus_handle.clone();
move |_, cx| {
cx.new(|cx| Self {
title: Title::Str(title.clone()),
meta: None,
key_binding: Some(KeyBinding::for_action_in(
action.as_ref(),
&focus_handle,
cx,
)),
})
.into()
}
}
pub fn for_action(
title: impl Into<SharedString>,
action: &dyn Action,
cx: &mut App,
) -> AnyView {
cx.new(|cx| Self {
title: Title::Str(title.into()),
meta: None,
key_binding: Some(KeyBinding::for_action(action, cx)),
})
.into()
}
pub fn for_action_in(
title: impl Into<SharedString>,
action: &dyn Action,
focus_handle: &FocusHandle,
cx: &mut App,
) -> AnyView {
cx.new(|cx| Self {
title: title.into().into(),
meta: None,
key_binding: Some(KeyBinding::for_action_in(action, focus_handle, cx)),
})
.into()
}
pub fn with_meta(
title: impl Into<SharedString>,
action: Option<&dyn Action>,
meta: impl Into<SharedString>,
cx: &mut App,
) -> AnyView {
cx.new(|cx| Self {
title: title.into().into(),
meta: Some(meta.into()),
key_binding: action.map(|action| KeyBinding::for_action(action, cx)),
})
.into()
}
pub fn with_meta_in(
title: impl Into<SharedString>,
action: Option<&dyn Action>,
meta: impl Into<SharedString>,
focus_handle: &FocusHandle,
cx: &mut App,
) -> AnyView {
cx.new(|cx| Self {
title: title.into().into(),
meta: Some(meta.into()),
key_binding: action.map(|action| KeyBinding::for_action_in(action, focus_handle, cx)),
})
.into()
}
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into().into(),
meta: None,
key_binding: None,
}
}
pub fn new_element(title: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self {
Self {
title: Title::Callback(Rc::new(title)),
meta: None,
key_binding: None,
}
}
pub fn element(
title: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> impl Fn(&mut Window, &mut App) -> AnyView {
let title = Title::Callback(Rc::new(title));
move |_, cx| {
let title = title.clone();
cx.new(|_| Self {
title,
meta: None,
key_binding: None,
})
.into()
}
}
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, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
tooltip_container(cx, |el, _| {
el.child(
h_flex()
.gap_4()
.child(div().max_w_72().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(
div()
.max_w_72()
.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted)),
)
})
})
}
}
pub fn tooltip_container<C>(cx: &mut C, f: impl FnOnce(Div, &mut C) -> Div) -> impl IntoElement
where
C: AppContext + Borrow<App>,
{
let app = (*cx).borrow();
let ui_font = theme::theme_settings(app).ui_font(app).clone();
// padding to avoid tooltip appearing right below the mouse cursor
div().pl_2().pt_2p5().child(
v_flex()
.elevation_2(app)
.font(ui_font)
.text_ui(app)
.text_color(app.theme().colors().text)
.py_1()
.px_2()
.map(|el| f(el, cx)),
)
}
pub struct LinkPreview {
link: SharedString,
}
impl LinkPreview {
pub fn new(url: &str, cx: &mut App) -> AnyView {
let mut wrapped_url = String::new();
for (i, ch) in url.chars().enumerate() {
if i == 500 {
wrapped_url.push('…');
break;
}
if i % 100 == 0 && i != 0 {
wrapped_url.push('\n');
}
wrapped_url.push(ch);
}
cx.new(|_| LinkPreview {
link: wrapped_url.into(),
})
.into()
}
}
impl Render for LinkPreview {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
tooltip_container(cx, |el, _| {
el.child(
Label::new(self.link.clone())
.size(LabelSize::XSmall)
.color(Color::Muted),
)
})
}
}
impl Component for Tooltip {
fn scope() -> ComponentScope {
ComponentScope::DataDisplay
}
fn description() -> Option<&'static str> {
Some(
"A tooltip that appears when hovering over an element, optionally showing a keybinding or additional metadata.",
)
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
example_group(vec![single_example(
"Text only",
Button::new("delete-example", "Delete")
.tooltip(Tooltip::text("This is a tooltip!"))
.into_any_element(),
)])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,296 @@
use std::sync::Arc;
use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent};
use crate::{Disclosure, prelude::*};
#[derive(IntoElement, RegisterComponent)]
pub struct TreeViewItem {
id: ElementId,
group_name: Option<SharedString>,
label: SharedString,
expanded: bool,
selected: bool,
disabled: bool,
focused: bool,
default_expanded: bool,
root_item: bool,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>>,
tab_index: Option<isize>,
focus_handle: Option<gpui::FocusHandle>,
}
impl TreeViewItem {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
group_name: None,
label: label.into(),
expanded: false,
selected: false,
disabled: false,
focused: false,
default_expanded: false,
root_item: false,
tooltip: None,
on_click: None,
on_hover: None,
on_toggle: None,
on_secondary_mouse_down: None,
tab_index: None,
focus_handle: None,
}
}
pub fn group_name(mut self, group_name: impl Into<SharedString>) -> Self {
self.group_name = Some(group_name.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
self.on_hover = Some(Box::new(handler));
self
}
pub fn on_secondary_mouse_down(
mut self,
handler: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_secondary_mouse_down = Some(Box::new(handler));
self
}
pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
self.tooltip = Some(Box::new(tooltip));
self
}
pub fn tab_index(mut self, tab_index: isize) -> Self {
self.tab_index = Some(tab_index);
self
}
pub fn expanded(mut self, toggle: bool) -> Self {
self.expanded = toggle;
self
}
pub fn default_expanded(mut self, default_expanded: bool) -> Self {
self.default_expanded = default_expanded;
self
}
pub fn on_toggle(
mut self,
on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_toggle = Some(Arc::new(on_toggle));
self
}
pub fn root_item(mut self, root_item: bool) -> Self {
self.root_item = root_item;
self
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn track_focus(mut self, focus_handle: &gpui::FocusHandle) -> Self {
self.focus_handle = Some(focus_handle.clone());
self
}
}
impl Disableable for TreeViewItem {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Toggleable for TreeViewItem {
fn toggle_state(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl RenderOnce for TreeViewItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let selected_bg = cx.theme().colors().element_active.opacity(0.5);
let transparent_border = cx.theme().colors().border.opacity(0.);
let selected_border = cx.theme().colors().border.opacity(0.4);
let focused_border = cx.theme().colors().border_focused;
let item_size = rems_from_px(28.);
let indentation_line = h_flex()
.h(item_size)
.w(px(22.))
.flex_none()
.justify_center()
.child(
div()
.w_px()
.h_full()
.bg(cx.theme().colors().border.opacity(0.5)),
);
h_flex()
.id(self.id)
.when_some(self.group_name, |this, group| this.group(group))
.w_full()
.child(
h_flex()
.id("inner_tree_view_item")
.cursor_pointer()
.size_full()
.h(item_size)
.pl_0p5()
.pr_1()
.gap_2()
.rounded_sm()
.border_1()
.border_color(transparent_border)
.focus_visible(|s| s.border_color(focused_border))
.when(self.selected, |this| {
this.border_color(selected_border).bg(selected_bg)
})
.hover(|s| s.bg(cx.theme().colors().element_hover))
.map(|this| {
let label = self.label;
if self.root_item {
this.child(
Disclosure::new("toggle", self.expanded)
.when_some(self.on_toggle.clone(), |disclosure, on_toggle| {
disclosure.on_toggle_expanded(on_toggle)
})
.opened_icon(IconName::ChevronDown)
.closed_icon(IconName::ChevronRight),
)
.child(
Label::new(label)
.when(!self.selected, |this| this.color(Color::Muted)),
)
} else {
this.child(indentation_line).child(
h_flex()
.id("nested_inner_tree_view_item")
.w_full()
.flex_grow()
.child(
Label::new(label)
.when(!self.selected, |this| this.color(Color::Muted)),
),
)
}
})
.when_some(self.focus_handle, |this, handle| this.track_focus(&handle))
.when_some(self.tab_index, |this, index| this.tab_index(index))
.when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover))
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| this.on_click(on_click),
)
.when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
this.on_mouse_down(MouseButton::Right, move |event, window, cx| {
(on_mouse_down)(event, window, cx)
})
})
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
)
}
}
impl Component for TreeViewItem {
fn scope() -> ComponentScope {
ComponentScope::Navigation
}
fn description() -> Option<&'static str> {
Some(
"A hierarchical list of items that may have a parent-child relationship where children can be toggled into view by expanding or collapsing their parent item.",
)
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let container = || {
v_flex()
.p_2()
.w_64()
.border_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
};
Some(
example_group(vec![
single_example(
"Basic Tree View",
container()
.child(
TreeViewItem::new("index-1", "Tree Item Root #1")
.root_item(true)
.toggle_state(true),
)
.child(TreeViewItem::new("index-2", "Tree Item #2"))
.child(TreeViewItem::new("index-3", "Tree Item #3"))
.child(TreeViewItem::new("index-4", "Tree Item Root #2").root_item(true))
.child(TreeViewItem::new("index-5", "Tree Item #5"))
.child(TreeViewItem::new("index-6", "Tree Item #6"))
.into_any_element(),
),
single_example(
"Active Child",
container()
.child(TreeViewItem::new("index-1", "Tree Item Root #1").root_item(true))
.child(TreeViewItem::new("index-2", "Tree Item #2").toggle_state(true))
.child(TreeViewItem::new("index-3", "Tree Item #3"))
.into_any_element(),
),
single_example(
"Focused Parent",
container()
.child(
TreeViewItem::new("index-1", "Tree Item Root #1")
.root_item(true)
.focused(true)
.toggle_state(true),
)
.child(TreeViewItem::new("index-2", "Tree Item #2"))
.child(TreeViewItem::new("index-3", "Tree Item #3"))
.into_any_element(),
),
single_example(
"Focused Child",
container()
.child(
TreeViewItem::new("index-1", "Tree Item Root #1")
.root_item(true)
.toggle_state(true),
)
.child(TreeViewItem::new("index-2", "Tree Item #2").focused(true))
.child(TreeViewItem::new("index-3", "Tree Item #3"))
.into_any_element(),
),
])
.into_any_element(),
)
}
}

35
crates/ui/src/prelude.rs Normal file
View File

@@ -0,0 +1,35 @@
//! The prelude of this crate. When building UI in Zed you almost always want to import this.
pub use gpui::prelude::*;
pub use gpui::{
AbsoluteLength, AnyElement, App, Context, DefiniteLength, Div, Element, ElementId,
InteractiveElement, ParentElement, Pixels, Rems, RenderOnce, SharedString, Styled, Window, div,
px, relative, rems,
};
pub use component::{
Component, ComponentScope, example_group, example_group_with_title, single_example,
};
pub use ui_macros::RegisterComponent;
pub use crate::DynamicSpacing;
pub use crate::animation::{AnimationDirection, AnimationDuration, DefaultAnimations};
pub use crate::styles::{
PlatformStyle, Severity, StyledTypography, TextSize, rems_from_px, vh, vw,
};
pub use crate::traits::clickable::*;
pub use crate::traits::disableable::*;
pub use crate::traits::fixed::*;
pub use crate::traits::styled_ext::*;
pub use crate::traits::toggleable::*;
pub use crate::traits::visible_on_hover::*;
pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton};
pub use crate::{ButtonCommon, Color};
pub use crate::{Headline, HeadlineSize};
pub use crate::{Icon, IconName, IconPosition, IconSize};
pub use crate::{Label, LabelCommon, LabelSize, LineHeightStyle, LoadingLabel};
pub use crate::{h_flex, v_flex};
pub use crate::{
h_group, h_group_lg, h_group_sm, h_group_xl, v_group, v_group_lg, v_group_sm, v_group_xl,
};
pub use theme::ActiveTheme;

18
crates/ui/src/styles.rs Normal file
View File

@@ -0,0 +1,18 @@
pub mod animation;
mod appearance;
mod color;
mod elevation;
mod platform;
mod severity;
mod spacing;
mod typography;
mod units;
pub use appearance::*;
pub use color::*;
pub use elevation::*;
pub use platform::*;
pub use severity::*;
pub use spacing::*;
pub use typography::*;
pub use units::*;

View File

@@ -0,0 +1,277 @@
use crate::prelude::*;
use gpui::{AnimationElement, AnimationExt, Styled};
use std::time::Duration;
use gpui::ease_out_quint;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AnimationDuration {
Instant = 50,
Fast = 150,
Slow = 300,
}
impl AnimationDuration {
pub fn duration(&self) -> Duration {
Duration::from_millis(*self as u64)
}
}
impl Into<std::time::Duration> for AnimationDuration {
fn into(self) -> Duration {
self.duration()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AnimationDirection {
FromBottom,
FromLeft,
FromRight,
FromTop,
}
pub trait DefaultAnimations: Styled + Sized + Element {
fn animate_in(
self,
animation_type: AnimationDirection,
fade_in: bool,
) -> AnimationElement<Self> {
let animation_name = match animation_type {
AnimationDirection::FromBottom => "animate_from_bottom",
AnimationDirection::FromLeft => "animate_from_left",
AnimationDirection::FromRight => "animate_from_right",
AnimationDirection::FromTop => "animate_from_top",
};
let animation_id = self.id().map_or_else(
|| ElementId::from(animation_name),
|id| (id, animation_name).into(),
);
self.with_animation(
animation_id,
gpui::Animation::new(AnimationDuration::Fast.into()).with_easing(ease_out_quint()),
move |mut this, delta| {
let start_opacity = 0.4;
let start_pos = 0.0;
let end_pos = 40.0;
if fade_in {
this = this.opacity(start_opacity + delta * (1.0 - start_opacity));
}
match animation_type {
AnimationDirection::FromBottom => {
this.bottom(px(start_pos + delta * (end_pos - start_pos)))
}
AnimationDirection::FromLeft => {
this.left(px(start_pos + delta * (end_pos - start_pos)))
}
AnimationDirection::FromRight => {
this.right(px(start_pos + delta * (end_pos - start_pos)))
}
AnimationDirection::FromTop => {
this.top(px(start_pos + delta * (end_pos - start_pos)))
}
}
},
)
}
fn animate_in_from_bottom(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromBottom, fade)
}
fn animate_in_from_left(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromLeft, fade)
}
fn animate_in_from_right(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromRight, fade)
}
fn animate_in_from_top(self, fade: bool) -> AnimationElement<Self> {
self.animate_in(AnimationDirection::FromTop, fade)
}
}
impl<E: Styled + Element> DefaultAnimations for E {}
// Don't use this directly, it only exists to show animation previews
#[derive(RegisterComponent)]
struct Animation {}
impl Component for Animation {
fn scope() -> ComponentScope {
ComponentScope::Utilities
}
fn description() -> Option<&'static str> {
Some("Demonstrates various animation patterns and transitions available in the UI system.")
}
fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let container_size = 128.0;
let element_size = 32.0;
let offset = container_size / 2.0 - element_size / 2.0;
let container = || {
h_flex()
.relative()
.justify_center()
.bg(cx.theme().colors().text.opacity(0.05))
.border_1()
.border_color(cx.theme().colors().border)
.rounded_sm()
};
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Animate In",
vec![
single_example(
"From Bottom",
container()
.size(px(container_size))
.child(
div()
.id("animate-in-from-bottom")
.absolute()
.size(px(element_size))
.left(px(offset))
.rounded_md()
.bg(gpui::red())
.animate_in_from_bottom(false),
)
.into_any_element(),
),
single_example(
"From Top",
container()
.size(px(container_size))
.child(
div()
.id("animate-in-from-top")
.absolute()
.size(px(element_size))
.left(px(offset))
.rounded_md()
.bg(gpui::blue())
.animate_in_from_top(false),
)
.into_any_element(),
),
single_example(
"From Left",
container()
.size(px(container_size))
.child(
div()
.id("animate-in-from-left")
.absolute()
.size(px(element_size))
.top(px(offset))
.rounded_md()
.bg(gpui::green())
.animate_in_from_left(false),
)
.into_any_element(),
),
single_example(
"From Right",
container()
.size(px(container_size))
.child(
div()
.id("animate-in-from-right")
.absolute()
.size(px(element_size))
.top(px(offset))
.rounded_md()
.bg(gpui::yellow())
.animate_in_from_right(false),
)
.into_any_element(),
),
],
)
.grow(),
example_group_with_title(
"Fade and Animate In",
vec![
single_example(
"From Bottom",
container()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-bottom")
.absolute()
.size(px(element_size))
.left(px(offset))
.rounded_md()
.bg(gpui::red())
.animate_in_from_bottom(true),
)
.into_any_element(),
),
single_example(
"From Top",
container()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-top")
.absolute()
.size(px(element_size))
.left(px(offset))
.rounded_md()
.bg(gpui::blue())
.animate_in_from_top(true),
)
.into_any_element(),
),
single_example(
"From Left",
container()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-left")
.absolute()
.size(px(element_size))
.top(px(offset))
.rounded_md()
.bg(gpui::green())
.animate_in_from_left(true),
)
.into_any_element(),
),
single_example(
"From Right",
container()
.size(px(container_size))
.child(
div()
.id("fade-animate-in-from-right")
.absolute()
.size(px(element_size))
.top(px(offset))
.rounded_md()
.bg(gpui::yellow())
.animate_in_from_right(true),
)
.into_any_element(),
),
],
)
.grow(),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,19 @@
use crate::prelude::*;
use gpui::{App, WindowBackgroundAppearance};
/// Returns the [WindowBackgroundAppearance].
fn window_appearance(cx: &mut App) -> WindowBackgroundAppearance {
cx.theme().styles.window_background_appearance
}
/// Returns if the window and it's surfaces are expected
/// to be transparent.
///
/// Helps determine if you need to take extra steps to prevent
/// transparent backgrounds.
pub fn theme_is_transparent(cx: &mut App) -> bool {
matches!(
window_appearance(cx),
WindowBackgroundAppearance::Transparent | WindowBackgroundAppearance::Blurred
)
}

View File

@@ -0,0 +1,244 @@
use crate::{Label, LabelCommon, component_prelude::*, v_flex};
use documented::{DocumentedFields, DocumentedVariants};
use gpui::{App, Hsla, IntoElement, ParentElement, Styled};
use theme::ActiveTheme;
/// Sets a color that has a consistent meaning across all themes.
#[derive(
Debug,
Default,
Eq,
PartialEq,
Copy,
Clone,
RegisterComponent,
Documented,
DocumentedFields,
DocumentedVariants,
)]
pub enum Color {
#[default]
/// The default text color. Might be known as "foreground" or "primary" in
/// some theme systems.
///
/// For less emphasis, consider using [`Color::Muted`] or [`Color::Hidden`].
Default,
/// A text color used for accents, such as links or highlights.
Accent,
/// A color used to indicate a conflict, such as a version control merge conflict, or a conflict between a file in the editor and the file system.
Conflict,
/// A color used to indicate a newly created item, such as a new file in
/// version control, or a new file on disk.
Created,
/// It is highly, HIGHLY recommended not to use this! Using this color
/// means detaching it from any semantic meaning across themes.
///
/// A custom color specified by an HSLA value.
Custom(Hsla),
/// A color used for all debugger UI elements.
Debugger,
/// A color used to indicate a deleted item, such as a file removed from version control.
Deleted,
/// A color used for disabled UI elements or text, like a disabled button or menu item.
Disabled,
/// A color used to indicate an error condition, or something the user
/// cannot do. In very rare cases, it might be used to indicate dangerous or
/// destructive action.
Error,
/// A color used for elements that represent something that is hidden, like
/// a hidden file, or an element that should be visually de-emphasized.
Hidden,
/// A color used for hint or suggestion text, often a blue color. Use this
/// color to represent helpful, or semantically neutral information.
Hint,
/// A color used for items that are intentionally ignored, such as files ignored by version control.
Ignored,
/// A color used for informational messages or status indicators, often a blue color.
Info,
/// A color used to indicate a modified item, such as an edited file, or a modified entry in version control.
Modified,
/// A color used for text or UI elements that should be visually muted or de-emphasized.
///
/// For more emphasis, consider using [`Color::Default`].
///
/// For less emphasis, consider using [`Color::Hidden`].
Muted,
/// A color used for placeholder text in input fields.
Placeholder,
/// A color associated with a specific player number.
Player(u32),
/// A color used to indicate selected text or UI elements.
Selected,
/// A color used to indicate a successful operation or status.
Success,
/// A version control color used to indicate a newly added file or content in version control.
VersionControlAdded,
/// A version control color used to indicate conflicting changes that need resolution.
VersionControlConflict,
/// A version control color used to indicate a file or content that has been deleted in version control.
VersionControlDeleted,
/// A version control color used to indicate files or content that is being ignored by version control.
VersionControlIgnored,
/// A version control color used to indicate modified files or content in version control.
VersionControlModified,
/// A color used to indicate a warning condition.
Warning,
}
impl Color {
/// Returns the Color's HSLA value.
pub fn color(&self, cx: &App) -> 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::Ignored => cx.theme().status().ignored,
Color::Debugger => cx.theme().colors().debugger_accent,
Color::Deleted => cx.theme().status().deleted,
Color::Disabled => cx.theme().colors().text_disabled,
Color::Hidden => cx.theme().status().hidden,
Color::Hint => cx.theme().status().hint,
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.color_for_participant(*i).cursor,
Color::Error => cx.theme().status().error,
Color::Selected => cx.theme().colors().text_accent,
Color::Success => cx.theme().status().success,
Color::VersionControlAdded => cx.theme().colors().version_control_added,
Color::VersionControlConflict => cx.theme().colors().version_control_conflict,
Color::VersionControlDeleted => cx.theme().colors().version_control_deleted,
Color::VersionControlIgnored => cx.theme().colors().version_control_ignored,
Color::VersionControlModified => cx.theme().colors().version_control_modified,
Color::Warning => cx.theme().status().warning,
Color::Custom(color) => *color,
}
}
}
impl From<Hsla> for Color {
fn from(color: Hsla) -> Self {
Color::Custom(color)
}
}
impl Component for Color {
fn scope() -> ComponentScope {
ComponentScope::Utilities
}
fn description() -> Option<&'static str> {
Some(Color::DOCS)
}
fn preview(_window: &mut gpui::Window, _cx: &mut App) -> Option<gpui::AnyElement> {
Some(
v_flex()
.gap_6()
.children(vec![
example_group_with_title(
"Text Colors",
vec![
single_example(
"Default",
Label::new("Default text color")
.color(Color::Default)
.into_any_element(),
)
.description(Color::Default.get_variant_docs()),
single_example(
"Muted",
Label::new("Muted text color")
.color(Color::Muted)
.into_any_element(),
)
.description(Color::Muted.get_variant_docs()),
single_example(
"Accent",
Label::new("Accent text color")
.color(Color::Accent)
.into_any_element(),
)
.description(Color::Accent.get_variant_docs()),
single_example(
"Disabled",
Label::new("Disabled text color")
.color(Color::Disabled)
.into_any_element(),
)
.description(Color::Disabled.get_variant_docs()),
],
),
example_group_with_title(
"Status Colors",
vec![
single_example(
"Success",
Label::new("Success status")
.color(Color::Success)
.into_any_element(),
)
.description(Color::Success.get_variant_docs()),
single_example(
"Warning",
Label::new("Warning status")
.color(Color::Warning)
.into_any_element(),
)
.description(Color::Warning.get_variant_docs()),
single_example(
"Error",
Label::new("Error status")
.color(Color::Error)
.into_any_element(),
)
.description(Color::Error.get_variant_docs()),
single_example(
"Info",
Label::new("Info status")
.color(Color::Info)
.into_any_element(),
)
.description(Color::Info.get_variant_docs()),
],
),
example_group_with_title(
"Version Control Colors",
vec![
single_example(
"Created",
Label::new("Created item")
.color(Color::Created)
.into_any_element(),
)
.description(Color::Created.get_variant_docs()),
single_example(
"Modified",
Label::new("Modified item")
.color(Color::Modified)
.into_any_element(),
)
.description(Color::Modified.get_variant_docs()),
single_example(
"Deleted",
Label::new("Deleted item")
.color(Color::Deleted)
.into_any_element(),
)
.description(Color::Deleted.get_variant_docs()),
single_example(
"Conflict",
Label::new("Conflict item")
.color(Color::Conflict)
.into_any_element(),
)
.description(Color::Conflict.get_variant_docs()),
],
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,129 @@
use std::fmt::{self, Display, Formatter};
use gpui::{App, BoxShadow, Hsla, hsla, point, px};
use theme::{ActiveTheme, Appearance};
/// Today, elevation is primarily used to add shadows to elements, and set the correct background for elements like buttons.
///
/// Elevation can be thought of as the physical closeness of an element to the
/// user. Elements with lower elevations are physically further away on the
/// z-axis and appear to be underneath elements with higher elevations.
///
/// In the future, a more complete approach to elevation may be added.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ElevationIndex {
/// On the layer of the app background. This is under panels, panes, and
/// other surfaces.
Background,
/// The primary surface Contains panels, panes, containers, etc.
Surface,
/// The same elevation as the primary surface, but used for the editable areas, like buffers
EditorSurface,
/// A surface that is elevated above the primary surface. but below washes, models, and dragged elements.
ElevatedSurface,
/// A surface above the [ElevationIndex::ElevatedSurface] that is used for dialogs, alerts, modals, etc.
ModalSurface,
}
impl Display for ElevationIndex {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
ElevationIndex::Background => write!(f, "Background"),
ElevationIndex::Surface => write!(f, "Surface"),
ElevationIndex::EditorSurface => write!(f, "Editor Surface"),
ElevationIndex::ElevatedSurface => write!(f, "Elevated Surface"),
ElevationIndex::ModalSurface => write!(f, "Modal Surface"),
}
}
}
impl ElevationIndex {
/// Returns an appropriate shadow for the given elevation index.
pub fn shadow(self, cx: &App) -> Vec<BoxShadow> {
let is_light = cx.theme().appearance() == Appearance::Light;
match self {
ElevationIndex::Surface => vec![],
ElevationIndex::EditorSurface => vec![],
ElevationIndex::ElevatedSurface => vec![
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., if is_light { 0.03 } else { 0.06 }),
offset: point(px(0.), px(1.)),
blur_radius: px(0.),
spread_radius: px(0.),
},
],
ElevationIndex::ModalSurface => vec![
BoxShadow {
color: hsla(0., 0., 0., if is_light { 0.06 } else { 0.12 }),
offset: point(px(0.), px(2.)),
blur_radius: px(3.),
spread_radius: px(0.),
},
BoxShadow {
color: hsla(0., 0., 0., if is_light { 0.06 } else { 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.),
},
BoxShadow {
color: hsla(0., 0., 0., if is_light { 0.04 } else { 0.12 }),
offset: point(px(0.), px(1.)),
blur_radius: px(0.),
spread_radius: px(0.),
},
],
_ => vec![],
}
}
/// Returns the background color for the given elevation index.
pub fn bg(&self, cx: &mut App) -> Hsla {
match self {
ElevationIndex::Background => cx.theme().colors().background,
ElevationIndex::Surface => cx.theme().colors().surface_background,
ElevationIndex::EditorSurface => cx.theme().colors().editor_background,
ElevationIndex::ElevatedSurface => cx.theme().colors().elevated_surface_background,
ElevationIndex::ModalSurface => cx.theme().colors().elevated_surface_background,
}
}
/// Returns a color that is appropriate a filled element on this elevation
pub fn on_elevation_bg(&self, cx: &App) -> Hsla {
match self {
ElevationIndex::Background => cx.theme().colors().surface_background,
ElevationIndex::Surface => cx.theme().colors().background,
ElevationIndex::EditorSurface => cx.theme().colors().surface_background,
ElevationIndex::ElevatedSurface => cx.theme().colors().background,
ElevationIndex::ModalSurface => cx.theme().colors().background,
}
}
/// Attempts to return a darker background color than the current elevation index's background.
///
/// If the current background color is already dark, it will return a lighter color instead.
pub fn darker_bg(&self, cx: &App) -> Hsla {
match self {
ElevationIndex::Background => cx.theme().colors().surface_background,
ElevationIndex::Surface => cx.theme().colors().editor_background,
ElevationIndex::EditorSurface => cx.theme().colors().surface_background,
ElevationIndex::ElevatedSurface => cx.theme().colors().editor_background,
ElevationIndex::ModalSurface => cx.theme().colors().editor_background,
}
}
}

View File

@@ -0,0 +1,25 @@
/// The platform style to use when rendering UI.
///
/// This can be used to abstract over platform differences.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
pub enum PlatformStyle {
/// Display in macOS style.
Mac,
/// Display in Linux style.
Linux,
/// Display in Windows style.
Windows,
}
impl PlatformStyle {
/// Returns the [`PlatformStyle`] for the current platform.
pub const fn platform() -> Self {
if cfg!(any(target_os = "linux", target_os = "freebsd")) {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Mac
}
}
}

View File

@@ -0,0 +1,10 @@
/// Severity levels that determine the style of the component.
/// Usually, it affects the background. Most of the time,
/// it also follows with an icon corresponding the severity level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Info,
Success,
Warning,
Error,
}

View File

@@ -0,0 +1,54 @@
use gpui::{App, Pixels, Rems, px, rems};
use theme::UiDensity;
use ui_macros::derive_dynamic_spacing;
// Derives [DynamicSpacing]. See [ui_macros::derive_dynamic_spacing].
//
// There are 3 UI density settings: Compact, Default, and Comfortable.
//
// When a tuple of three values is provided, the values are used directly.
//
// Example: (1, 2, 4) => Compact: 1px, Default: 2px, Comfortable: 4px
//
// When a single value is provided, the standard spacing formula is
// used to derive the of spacing values. This formula can be found in
// the macro.
//
// Example:
//
// Assuming the standard formula is (n-4, n, n+4)
//
// 24 => Compact: 20px, Default: 24px, Comfortable: 28px
//
// The [DynamicSpacing] enum variants use a BaseXX format,
// where XX = the pixel value @ default rem size and the default UI density.
//
// Example:
//
// DynamicSpacing::Base16 would return 16px at the default UI scale & density.
derive_dynamic_spacing![
(0, 0, 0),
(1, 1, 2),
(1, 2, 4),
(2, 3, 4),
(2, 4, 6),
(3, 6, 8),
(4, 8, 10),
(10, 12, 14),
(14, 16, 18),
(18, 20, 22),
24,
32,
40,
48
];
/// Returns the current [`UiDensity`] setting. Use this to
/// modify or show something in the UI other than spacing.
///
/// Do not use this to calculate spacing values.
///
/// Always use [DynamicSpacing] for spacing values.
pub fn ui_density(cx: &mut App) -> UiDensity {
theme::theme_settings(cx).ui_density(cx)
}

View File

@@ -0,0 +1,294 @@
use crate::prelude::*;
use gpui::{
AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, Window,
div, rems,
};
use theme::ActiveTheme;
use crate::{Color, rems_from_px};
/// Extends [`gpui::Styled`] with typography-related styling methods.
pub trait StyledTypography: Styled + Sized {
/// Sets the font family to the buffer font.
fn font_buffer(self, cx: &App) -> Self {
let settings = theme::theme_settings(cx);
let buffer_font_family = settings.buffer_font(cx).family.clone();
self.font_family(buffer_font_family)
}
/// Sets the font family to the UI font.
fn font_ui(self, cx: &App) -> Self {
let settings = theme::theme_settings(cx);
let ui_font_family = settings.ui_font(cx).family.clone();
self.font_family(ui_font_family)
}
/// Sets the text size using a [`TextSize`].
fn text_ui_size(self, size: TextSize, cx: &App) -> Self {
self.text_size(size.rems(cx))
}
/// The large size for UI text.
///
/// `1rem` or `16px` 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_lg(self, cx: &App) -> Self {
self.text_size(TextSize::Large.rems(cx))
}
/// 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 smaller text.
fn text_ui(self, cx: &App) -> Self {
self.text_size(TextSize::default().rems(cx))
}
/// 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, cx: &App) -> Self {
self.text_size(TextSize::Small.rems(cx))
}
/// 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, cx: &App) -> Self {
self.text_size(TextSize::XSmall.rems(cx))
}
/// 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: &App) -> Self {
let settings = theme::theme_settings(cx);
self.text_size(settings.buffer_font_size(cx))
}
}
impl<E: Styled> StyledTypography for E {}
/// A utility for getting the size of various semantic text sizes.
#[derive(Debug, Default, Clone)]
pub enum TextSize {
/// 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 large size for UI text.
///
/// `1rem` or `16px` at the default scale of `1rem` = `16px`.
///
/// Note: The absolute size of this text will change based on a user's `ui_scale` setting.
Large,
/// 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,
/// The `ui_font_size` set by the user.
Ui,
/// The `buffer_font_size` set by the user.
Editor,
// TODO: The terminal settings will need to be passed to
// ThemeSettings before we can enable this.
//// The `terminal.font_size` set by the user.
// Terminal,
}
impl TextSize {
/// Returns the text size in rems.
pub fn rems(self, cx: &App) -> Rems {
let settings = theme::theme_settings(cx);
match self {
Self::Large => rems_from_px(16.),
Self::Default => rems_from_px(14.),
Self::Small => rems_from_px(12.),
Self::XSmall => rems_from_px(10.),
Self::Ui => rems_from_px(settings.ui_font_size(cx)),
Self::Editor => rems_from_px(settings.buffer_font_size(cx)),
}
}
pub fn pixels(self, cx: &App) -> Pixels {
let settings = theme::theme_settings(cx);
match self {
Self::Large => px(16.),
Self::Default => px(14.),
Self::Small => px(12.),
Self::XSmall => px(10.),
Self::Ui => settings.ui_font_size(cx),
Self::Editor => settings.buffer_font_size(cx),
}
}
}
/// The size of a [`Headline`] element
///
/// Defaults to a Major Second scale.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
pub enum HeadlineSize {
/// An extra small headline - `~14px` @16px/rem
XSmall,
/// A small headline - `16px` @16px/rem
Small,
#[default]
/// A medium headline - `~18px` @16px/rem
Medium,
/// A large headline - `~20px` @16px/rem
Large,
/// An extra large headline - `~22px` @16px/rem
XLarge,
}
impl HeadlineSize {
/// Returns the headline size in rems.
pub fn rems(self) -> Rems {
match self {
Self::XSmall => rems(0.88),
Self::Small => rems(1.0),
Self::Medium => rems(1.125),
Self::Large => rems(1.27),
Self::XLarge => rems(1.43),
}
}
/// Returns the line height for the headline size.
pub fn line_height(self) -> Rems {
match self {
Self::XSmall => rems(1.6),
Self::Small => rems(1.6),
Self::Medium => rems(1.6),
Self::Large => rems(1.6),
Self::XLarge => rems(1.6),
}
}
}
/// A headline element, used to emphasize some text and
/// create a visual hierarchy.
#[derive(IntoElement, RegisterComponent)]
pub struct Headline {
size: HeadlineSize,
text: SharedString,
color: Color,
}
impl RenderOnce for Headline {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let ui_font = theme::theme_settings(cx).ui_font(cx).clone();
div()
.font(ui_font)
.line_height(self.size.line_height())
.text_size(self.size.rems())
.text_color(cx.theme().colors().text)
.child(self.text)
}
}
impl Headline {
/// Create a new headline element.
pub fn new(text: impl Into<SharedString>) -> Self {
Self {
size: HeadlineSize::default(),
text: text.into(),
color: Color::default(),
}
}
/// Set the size of the headline.
pub fn size(mut self, size: HeadlineSize) -> Self {
self.size = size;
self
}
/// Set the color of the headline.
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
}
impl Component for Headline {
fn scope() -> ComponentScope {
ComponentScope::Typography
}
fn description() -> Option<&'static str> {
Some("A headline element used to emphasize text and create visual hierarchy in the UI.")
}
fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
Some(
v_flex()
.gap_1()
.children(vec![
single_example(
"XLarge",
Headline::new("XLarge Headline")
.size(HeadlineSize::XLarge)
.into_any_element(),
),
single_example(
"Large",
Headline::new("Large Headline")
.size(HeadlineSize::Large)
.into_any_element(),
),
single_example(
"Medium (Default)",
Headline::new("Medium Headline").into_any_element(),
),
single_example(
"Small",
Headline::new("Small Headline")
.size(HeadlineSize::Small)
.into_any_element(),
),
single_example(
"XSmall",
Headline::new("XSmall Headline")
.size(HeadlineSize::XSmall)
.into_any_element(),
),
])
.into_any_element(),
)
}
}

View File

@@ -0,0 +1,29 @@
use gpui::{Length, Rems, Window, rems};
/// The base size of a rem, in pixels.
pub const BASE_REM_SIZE_IN_PX: f32 = 16.;
/// Returns a rem value derived from the provided pixel value and the base rem size (16px).
///
/// This can be used to compute rem values relative to pixel sizes, without
/// needing to hard-code the rem value.
///
/// For instance, instead of writing `rems(0.875)` you can write `rems_from_px(14.)`
#[inline(always)]
pub fn rems_from_px(px: impl Into<f32>) -> Rems {
rems(px.into() / BASE_REM_SIZE_IN_PX)
}
/// 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, window: &mut Window) -> Length {
Length::from(window.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, window: &mut Window) -> Length {
Length::from(window.viewport_size().height * percent)
}

8
crates/ui/src/traits.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod animation_ext;
pub mod clickable;
pub mod disableable;
pub mod fixed;
pub mod styled_ext;
pub mod toggleable;
pub mod transformable;
pub mod visible_on_hover;

View File

@@ -0,0 +1,42 @@
use std::time::Duration;
use gpui::{Animation, AnimationElement, AnimationExt, Transformation, percentage};
use crate::{prelude::*, traits::transformable::Transformable};
/// An extension trait for adding common animations to animatable components.
pub trait CommonAnimationExt: AnimationExt {
/// Render this component as rotating over the given duration.
///
/// NOTE: This method uses the location of the caller to generate an ID for this state.
/// If this is not sufficient to identify your state (e.g. you're rendering a list item),
/// you can provide a custom ElementID using the `use_keyed_rotate_animation` method.
#[track_caller]
fn with_rotate_animation(self, duration: u64) -> AnimationElement<Self>
where
Self: Transformable + Sized,
{
self.with_keyed_rotate_animation(
ElementId::CodeLocation(*std::panic::Location::caller()),
duration,
)
}
/// Render this component as rotating with the given element ID over the given duration.
fn with_keyed_rotate_animation(
self,
id: impl Into<ElementId>,
duration: u64,
) -> AnimationElement<Self>
where
Self: Transformable + Sized,
{
self.with_animation(
id,
Animation::new(Duration::from_secs(duration)).repeat(),
|component, delta| component.transform(Transformation::rotate(percentage(delta))),
)
}
}
impl<T: AnimationExt> CommonAnimationExt for T {}

View File

@@ -0,0 +1,9 @@
use gpui::{App, ClickEvent, CursorStyle, Window};
/// A trait for elements that can be clicked. Enables the use of the `on_click` method.
pub trait Clickable {
/// Sets the click handler that will fire whenever the element is clicked.
fn on_click(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self;
/// Sets the cursor style when hovering over the element.
fn cursor_style(self, cursor_style: CursorStyle) -> Self;
}

View File

@@ -0,0 +1,5 @@
/// A trait for elements that can be disabled. Generally used to implement disabling an element's interactivity and changing its appearance to reflect that it is disabled.
pub trait Disableable {
/// Sets whether the element is disabled.
fn disabled(self, disabled: bool) -> Self;
}

View File

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

View File

@@ -0,0 +1,134 @@
use gpui::{App, Styled, hsla};
use crate::ElevationIndex;
use crate::prelude::*;
fn elevated<E: Styled>(this: E, cx: &App, index: ElevationIndex) -> E {
this.bg(cx.theme().colors().elevated_surface_background)
.rounded_lg()
.border_1()
.border_color(cx.theme().colors().border_variant)
.shadow(index.shadow(cx))
}
fn elevated_borderless<E: Styled>(this: E, cx: &mut App, index: ElevationIndex) -> E {
this.bg(cx.theme().colors().elevated_surface_background)
.rounded_lg()
.shadow(index.shadow(cx))
}
/// Extends [`gpui::Styled`] with Zed-specific styling methods.
// gate on rust-analyzer so rust-analyzer never needs to expand this macro, it takes up to 10 seconds to expand due to inefficiencies in rust-analyzers proc-macro srv
#[cfg_attr(
all(debug_assertions, not(rust_analyzer)),
gpui_macros::derive_inspector_reflection
)]
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()
}
/// The [`Surface`](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: &App) -> Self {
elevated(self, cx, ElevationIndex::Surface)
}
/// See [`elevation_1`](Self::elevation_1).
///
/// Renders a borderless version [`elevation_1`](Self::elevation_1).
fn elevation_1_borderless(self, cx: &mut App) -> Self {
elevated_borderless(self, cx, ElevationIndex::Surface)
}
/// Non-Modal Elevated Surfaces appear above the [`Surface`](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: &App) -> Self {
elevated(self, cx, ElevationIndex::ElevatedSurface)
}
/// See [`elevation_2`](Self::elevation_2).
///
/// Renders a borderless version [`elevation_2`](Self::elevation_2).
fn elevation_2_borderless(self, cx: &mut App) -> Self {
elevated_borderless(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`](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: &App) -> Self {
elevated(self, cx, ElevationIndex::ModalSurface)
}
/// See [`elevation_3`](Self::elevation_3).
///
/// Renders a borderless version [`elevation_3`](Self::elevation_3).
fn elevation_3_borderless(self, cx: &mut App) -> Self {
elevated_borderless(self, cx, ElevationIndex::ModalSurface)
}
/// The theme's primary border color.
fn border_primary(self, cx: &mut App) -> Self {
self.border_color(cx.theme().colors().border)
}
/// The theme's secondary or muted border color.
fn border_muted(self, cx: &mut App) -> Self {
self.border_color(cx.theme().colors().border_variant)
}
/// Sets the background color to red for debugging when building UI.
fn debug_bg_red(self) -> Self {
self.bg(hsla(0. / 360., 1., 0.5, 1.))
}
/// Sets the background color to green for debugging when building UI.
fn debug_bg_green(self) -> Self {
self.bg(hsla(120. / 360., 1., 0.5, 1.))
}
/// Sets the background color to blue for debugging when building UI.
fn debug_bg_blue(self) -> Self {
self.bg(hsla(240. / 360., 1., 0.5, 1.))
}
/// Sets the background color to yellow for debugging when building UI.
fn debug_bg_yellow(self) -> Self {
self.bg(hsla(60. / 360., 1., 0.5, 1.))
}
/// Sets the background color to cyan for debugging when building UI.
fn debug_bg_cyan(self) -> Self {
self.bg(hsla(160. / 360., 1., 0.5, 1.))
}
/// Sets the background color to magenta for debugging when building UI.
fn debug_bg_magenta(self) -> Self {
self.bg(hsla(300. / 360., 1., 0.5, 1.))
}
}
impl<E: Styled> StyledExt for E {}

View File

@@ -0,0 +1,69 @@
/// A trait for elements that can be toggled.
///
/// Implement this for elements that are visually distinct
/// when in two opposing states, like checkboxes or switches.
pub trait Toggleable {
/// Sets whether the element is selected.
fn toggle_state(self, selected: bool) -> Self;
}
/// Represents the selection status of an element.
#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Copy)]
pub enum ToggleState {
/// The element is not selected.
#[default]
Unselected,
/// The selection state of the element is indeterminate.
Indeterminate,
/// The element is selected.
Selected,
}
impl ToggleState {
/// Returns the inverse of the current selection status.
///
/// Indeterminate states become selected if inverted.
pub fn inverse(&self) -> Self {
match self {
Self::Unselected | Self::Indeterminate => Self::Selected,
Self::Selected => Self::Unselected,
}
}
/// Creates a `ToggleState` from the given `any_checked` and `all_checked` flags.
pub fn from_any_and_all(any_checked: bool, all_checked: bool) -> Self {
match (any_checked, all_checked) {
(true, true) => Self::Selected,
(false, false) => Self::Unselected,
_ => Self::Indeterminate,
}
}
/// Returns whether this toggle state is selected
pub fn selected(&self) -> bool {
match self {
ToggleState::Indeterminate | ToggleState::Unselected => false,
ToggleState::Selected => true,
}
}
}
impl From<bool> for ToggleState {
fn from(selected: bool) -> Self {
if selected {
Self::Selected
} else {
Self::Unselected
}
}
}
impl From<Option<bool>> for ToggleState {
fn from(selected: Option<bool>) -> Self {
match selected {
Some(true) => Self::Selected,
Some(false) => Self::Unselected,
None => Self::Indeterminate,
}
}
}

View File

@@ -0,0 +1,7 @@
use gpui::Transformation;
/// A trait for components that can be transformed.
pub trait Transformable {
/// Sets the transformation for the element.
fn transform(self, transformation: Transformation) -> Self;
}

View File

@@ -0,0 +1,17 @@
use gpui::{InteractiveElement, SharedString, Styled};
/// A trait for elements that can be made visible on hover by
/// tracking a specific group.
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())
}
}

20
crates/ui/src/ui.rs Normal file
View File

@@ -0,0 +1,20 @@
//! # 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.
//!
//! ## Related Crates:
//!
//! - [`ui_macros`] - proc_macros support for this crate
//! - `ui_input` - the single line input component
pub mod component_prelude;
mod components;
pub mod prelude;
mod styles;
mod traits;
pub mod utils;
pub use components::*;
pub use prelude::*;
pub use styles::*;
pub use traits::animation_ext::*;

Some files were not shown because too many files have changed in this diff Show More