move gpui-components to ui crate

This commit is contained in:
2024-12-10 09:40:27 +07:00
parent 9f0e367527
commit 516eb0e8bc
91 changed files with 20957 additions and 231 deletions

BIN
crates/ui/src/.DS_Store vendored Normal file

Binary file not shown.

300
crates/ui/src/accordion.rs Normal file
View File

@@ -0,0 +1,300 @@
use std::{cell::Cell, rc::Rc, sync::Arc};
use gpui::{
div, prelude::FluentBuilder as _, rems, AnyElement, Div, ElementId, InteractiveElement as _,
IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled,
WindowContext,
};
use crate::{h_flex, theme::ActiveTheme as _, v_flex, Icon, IconName, Sizable, Size};
/// An AccordionGroup is a container for multiple Accordion elements.
#[derive(IntoElement)]
pub struct Accordion {
id: ElementId,
base: Div,
multiple: bool,
size: Size,
bordered: bool,
disabled: bool,
children: Vec<AccordionItem>,
on_toggle_click: Option<Arc<dyn Fn(&[usize], &mut WindowContext) + Send + Sync>>,
}
impl Accordion {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: v_flex().gap_1(),
multiple: false,
size: Size::default(),
bordered: true,
children: Vec::new(),
disabled: false,
on_toggle_click: None,
}
}
pub fn multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
pub fn bordered(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn item<F>(mut self, child: F) -> Self
where
F: FnOnce(AccordionItem) -> AccordionItem,
{
let item = child(AccordionItem::new());
self.children.push(item);
self
}
/// Sets the on_toggle_click callback for the AccordionGroup.
///
/// The first argument `Vec<usize>` is the indices of the open accordions.
pub fn on_toggle_click(
mut self,
on_toggle_click: impl Fn(&[usize], &mut WindowContext) + Send + Sync + 'static,
) -> Self {
self.on_toggle_click = Some(Arc::new(on_toggle_click));
self
}
}
impl Sizable for Accordion {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Accordion {
fn render(self, _: &mut WindowContext) -> impl IntoElement {
let mut open_ixs: Vec<usize> = Vec::new();
let multiple = self.multiple;
let state = Rc::new(Cell::new(None));
self.children
.iter()
.enumerate()
.for_each(|(ix, accordion)| {
if accordion.open {
open_ixs.push(ix);
}
});
self.base
.id(self.id)
.children(
self.children
.into_iter()
.enumerate()
.map(|(ix, accordion)| {
let state = Rc::clone(&state);
accordion
.with_size(self.size)
.bordered(self.bordered)
.when(self.disabled, |this| this.disabled(true))
.on_toggle_click(move |_, _| {
state.set(Some(ix));
})
}),
)
.when_some(
self.on_toggle_click.filter(|_| !self.disabled),
move |this, on_toggle_click| {
this.on_click(move |_, cx| {
let mut open_ixs = open_ixs.clone();
if let Some(ix) = state.get() {
if multiple {
if let Some(pos) = open_ixs.iter().position(|&i| i == ix) {
open_ixs.remove(pos);
} else {
open_ixs.push(ix);
}
} else {
let was_open = open_ixs.iter().any(|&i| i == ix);
open_ixs.clear();
if !was_open {
open_ixs.push(ix);
}
}
}
on_toggle_click(&open_ixs, cx);
})
},
)
}
}
/// An Accordion is a vertically stacked list of items, each of which can be expanded to reveal the content associated with it.
#[derive(IntoElement)]
pub struct AccordionItem {
icon: Option<Icon>,
title: AnyElement,
content: AnyElement,
open: bool,
size: Size,
bordered: bool,
disabled: bool,
on_toggle_click: Option<Arc<dyn Fn(&bool, &mut WindowContext)>>,
}
impl AccordionItem {
pub fn new() -> Self {
Self {
icon: None,
title: SharedString::default().into_any_element(),
content: SharedString::default().into_any_element(),
open: false,
disabled: false,
on_toggle_click: None,
size: Size::default(),
bordered: true,
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn title(mut self, title: impl IntoElement) -> Self {
self.title = title.into_any_element();
self
}
pub fn content(mut self, content: impl IntoElement) -> Self {
self.content = content.into_any_element();
self
}
pub fn bordered(mut self, bordered: bool) -> Self {
self.bordered = bordered;
self
}
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
fn on_toggle_click(
mut self,
on_toggle_click: impl Fn(&bool, &mut WindowContext) + 'static,
) -> Self {
self.on_toggle_click = Some(Arc::new(on_toggle_click));
self
}
}
impl Sizable for AccordionItem {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for AccordionItem {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let text_size = match self.size {
Size::XSmall => rems(0.875),
Size::Small => rems(0.875),
_ => rems(1.0),
};
v_flex()
.bg(cx.theme().accordion)
.overflow_hidden()
.when(self.bordered, |this| {
this.border_1().border_color(cx.theme().border).rounded_md()
})
.text_size(text_size)
.child(
h_flex()
.id("accordion-title")
.justify_between()
.map(|this| match self.size {
Size::XSmall => this.py_0().px_1p5(),
Size::Small => this.py_0p5().px_2(),
Size::Large => this.py_1p5().px_4(),
_ => this.py_1().px_3(),
})
.when(self.open, |this| {
this.when(self.bordered, |this| {
this.bg(cx.theme().accordion_active)
.text_color(cx.theme().foreground)
.border_b_1()
.border_color(cx.theme().border)
})
})
.child(
h_flex()
.items_center()
.map(|this| match self.size {
Size::XSmall => this.gap_1(),
Size::Small => this.gap_1(),
_ => this.gap_2(),
})
.when_some(self.icon, |this, icon| {
this.child(
icon.with_size(self.size)
.text_color(cx.theme().muted_foreground),
)
})
.child(self.title),
)
.when(!self.disabled, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().accordion_hover))
.child(
Icon::new(if self.open {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.xsmall()
.text_color(cx.theme().muted_foreground),
)
})
.when_some(
self.on_toggle_click.filter(|_| !self.disabled),
|this, on_toggle_click| {
this.on_click({
move |_, cx| {
on_toggle_click(&!self.open, cx);
}
})
},
),
)
.when(self.open, |this| {
this.child(
div()
.map(|this| match self.size {
Size::XSmall => this.p_1p5(),
Size::Small => this.p_2(),
Size::Large => this.p_4(),
_ => this.p_3(),
})
.child(self.content),
)
})
}
}

View File

@@ -0,0 +1,19 @@
/// A cubic bezier function like CSS `cubic-bezier`.
///
/// Builder:
///
/// https://cubic-bezier.com
pub fn cubic_bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> impl Fn(f32) -> f32 {
move |t: f32| {
let one_t = 1.0 - t;
let one_t2 = one_t * one_t;
let t2 = t * t;
let t3 = t2 * t;
// The Bezier curve function for x and y, where x0 = 0, y0 = 0, x3 = 1, y3 = 1
let _x = 3.0 * x1 * one_t2 * t + 3.0 * x2 * one_t * t2 + t3;
let y = 3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3;
y
}
}

123
crates/ui/src/badge.rs Normal file
View File

@@ -0,0 +1,123 @@
use crate::{theme::ActiveTheme as _, Sizable, Size};
use gpui::{
div, prelude::FluentBuilder as _, relative, Div, Hsla, InteractiveElement as _, IntoElement,
ParentElement, RenderOnce, Styled,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BadgeVariant {
#[default]
Primary,
Secondary,
Outline,
Destructive,
Custom {
color: Hsla,
foreground: Hsla,
border: Hsla,
},
}
impl BadgeVariant {
fn bg(&self, cx: &gpui::WindowContext) -> Hsla {
match self {
Self::Primary => cx.theme().primary,
Self::Secondary => cx.theme().secondary,
Self::Outline => gpui::transparent_black(),
Self::Destructive => cx.theme().destructive,
Self::Custom { color, .. } => *color,
}
}
fn border(&self, cx: &gpui::WindowContext) -> Hsla {
match self {
Self::Primary => cx.theme().primary,
Self::Secondary => cx.theme().secondary,
Self::Outline => cx.theme().border,
Self::Destructive => cx.theme().destructive,
Self::Custom { border, .. } => *border,
}
}
fn fg(&self, cx: &gpui::WindowContext) -> Hsla {
match self {
Self::Primary => cx.theme().primary_foreground,
Self::Secondary => cx.theme().secondary_foreground,
Self::Outline => cx.theme().foreground,
Self::Destructive => cx.theme().destructive_foreground,
Self::Custom { foreground, .. } => *foreground,
}
}
}
/// Badge is a small status indicator for UI elements.
///
/// Only support: Medium, Small
#[derive(IntoElement)]
pub struct Badge {
base: Div,
veriant: BadgeVariant,
size: Size,
}
impl Badge {
fn new() -> Self {
Self {
base: div().flex().items_center().rounded_md().border_1(),
veriant: BadgeVariant::default(),
size: Size::Medium,
}
}
pub fn with_variant(mut self, variant: BadgeVariant) -> Self {
self.veriant = variant;
self
}
pub fn primary() -> Self {
Self::new().with_variant(BadgeVariant::Primary)
}
pub fn secondary() -> Self {
Self::new().with_variant(BadgeVariant::Secondary)
}
pub fn outline() -> Self {
Self::new().with_variant(BadgeVariant::Outline)
}
pub fn destructive() -> Self {
Self::new().with_variant(BadgeVariant::Destructive)
}
pub fn custom(color: Hsla, foreground: Hsla, border: Hsla) -> Self {
Self::new().with_variant(BadgeVariant::Custom {
color,
foreground,
border,
})
}
}
impl Sizable for Badge {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl ParentElement for Badge {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.base.extend(elements);
}
}
impl RenderOnce for Badge {
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
self.base
.line_height(relative(1.3))
.map(|this| match self.size {
Size::XSmall | Size::Small => this.text_xs().px_1p5().py_0(),
_ => this.text_xs().px_2p5().py_0p5(),
})
.bg(self.veriant.bg(cx))
.text_color(self.veriant.fg(cx))
.border_color(self.veriant.border(cx))
.hover(|this| this.opacity(0.9))
}
}

118
crates/ui/src/breadcrumb.rs Normal file
View File

@@ -0,0 +1,118 @@
use std::rc::Rc;
use gpui::{
div, prelude::FluentBuilder as _, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, WindowContext,
};
use crate::{h_flex, theme::ActiveTheme, Icon, IconName};
#[derive(IntoElement)]
pub struct Breadcrumb {
items: Vec<BreadcrumbItem>,
}
#[derive(IntoElement)]
pub struct BreadcrumbItem {
id: ElementId,
text: SharedString,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext)>>,
disabled: bool,
is_last: bool,
}
impl BreadcrumbItem {
pub fn new(id: impl Into<ElementId>, text: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
text: text.into(),
on_click: None,
disabled: false,
is_last: false,
}
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_click = Some(Rc::new(on_click));
self
}
/// For internal use only.
fn is_last(mut self, is_last: bool) -> Self {
self.is_last = is_last;
self
}
}
impl RenderOnce for BreadcrumbItem {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
div()
.id(self.id)
.child(self.text)
.text_color(cx.theme().muted_foreground)
.when(self.is_last, |this| this.text_color(cx.theme().foreground))
.when(self.disabled, |this| {
this.text_color(cx.theme().muted_foreground)
})
.when(!self.disabled, |this| {
this.when_some(self.on_click, |this, on_click| {
this.cursor_pointer().on_click(move |event, cx| {
on_click(event, cx);
})
})
})
}
}
impl Breadcrumb {
pub fn new() -> Self {
Self { items: Vec::new() }
}
/// Add an item to the breadcrumb.
pub fn item(mut self, item: BreadcrumbItem) -> Self {
self.items.push(item);
self
}
}
#[derive(IntoElement)]
struct BreadcrumbSeparator;
impl RenderOnce for BreadcrumbSeparator {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
Icon::new(IconName::ChevronRight)
.text_color(cx.theme().muted_foreground)
.size_3p5()
.into_any_element()
}
}
impl RenderOnce for Breadcrumb {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let items_count = self.items.len();
let mut children = vec![];
for (ix, item) in self.items.into_iter().enumerate() {
let is_last = ix == items_count - 1;
children.push(item.is_last(is_last).into_any_element());
if !is_last {
children.push(BreadcrumbSeparator.into_any_element());
}
}
h_flex()
.gap_1p5()
.text_sm()
.text_color(cx.theme().muted_foreground)
.children(children)
}
}

667
crates/ui/src/button.rs Normal file
View File

@@ -0,0 +1,667 @@
use crate::{
h_flex,
indicator::Indicator,
theme::{ActiveTheme, Colorize as _},
tooltip::Tooltip,
Disableable, Icon, Selectable, Sizable, Size,
};
use gpui::{
div, prelude::FluentBuilder as _, px, relative, AnyElement, ClickEvent, Corners, Div, Edges,
ElementId, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
RenderOnce, SharedString, StatefulInteractiveElement as _, Styled, WindowContext,
};
pub enum ButtonRounded {
None,
Small,
Medium,
Large,
Size(Pixels),
}
impl From<Pixels> for ButtonRounded {
fn from(px: Pixels) -> Self {
ButtonRounded::Size(px)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct ButtonCustomVariant {
color: Hsla,
foreground: Hsla,
border: Hsla,
shadow: bool,
hover: Hsla,
active: Hsla,
}
pub trait ButtonVariants: Sized {
fn with_variant(self, variant: ButtonVariant) -> Self;
/// With the primary style for the Button.
fn primary(self) -> Self {
self.with_variant(ButtonVariant::Primary)
}
/// With the danger style for the Button.
fn danger(self) -> Self {
self.with_variant(ButtonVariant::Danger)
}
/// With the outline style for the Button.
fn outline(self) -> Self {
self.with_variant(ButtonVariant::Outline)
}
/// With the ghost style for the Button.
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost)
}
/// With the link style for the Button.
fn link(self) -> Self {
self.with_variant(ButtonVariant::Link)
}
/// With the text style for the Button, it will no padding look like a normal text.
fn text(self) -> Self {
self.with_variant(ButtonVariant::Text)
}
/// With the custom style for the Button.
fn custom(self, style: ButtonCustomVariant) -> Self {
self.with_variant(ButtonVariant::Custom(style))
}
}
impl ButtonCustomVariant {
pub fn new(cx: &WindowContext) -> Self {
Self {
color: cx.theme().secondary,
foreground: cx.theme().secondary_foreground,
border: cx.theme().border,
hover: cx.theme().secondary_hover,
active: cx.theme().secondary_active,
shadow: true,
}
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = color;
self
}
pub fn foreground(mut self, color: Hsla) -> Self {
self.foreground = color;
self
}
pub fn border(mut self, color: Hsla) -> Self {
self.border = color;
self
}
pub fn hover(mut self, color: Hsla) -> Self {
self.hover = color;
self
}
pub fn active(mut self, color: Hsla) -> Self {
self.active = color;
self
}
pub fn shadow(mut self, shadow: bool) -> Self {
self.shadow = shadow;
self
}
}
/// The veriant of the Button.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ButtonVariant {
Primary,
Secondary,
Danger,
Outline,
Ghost,
Link,
Text,
Custom(ButtonCustomVariant),
}
impl Default for ButtonVariant {
fn default() -> Self {
Self::Secondary
}
}
impl ButtonVariant {
fn is_link(&self) -> bool {
matches!(self, Self::Link)
}
fn is_text(&self) -> bool {
matches!(self, Self::Text)
}
fn no_padding(&self) -> bool {
self.is_link() || self.is_text()
}
}
/// A Button element.
#[derive(IntoElement)]
pub struct Button {
pub base: Div,
id: ElementId,
icon: Option<Icon>,
label: Option<SharedString>,
children: Vec<AnyElement>,
disabled: bool,
pub(crate) selected: bool,
variant: ButtonVariant,
rounded: ButtonRounded,
border_corners: Corners<bool>,
border_edges: Edges<bool>,
size: Size,
compact: bool,
tooltip: Option<SharedString>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
pub(crate) stop_propagation: bool,
loading: bool,
loading_icon: Option<Icon>,
}
impl From<Button> for AnyElement {
fn from(button: Button) -> Self {
button.into_any_element()
}
}
impl Button {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().flex_shrink_0(),
id: id.into(),
icon: None,
label: None,
disabled: false,
selected: false,
variant: ButtonVariant::default(),
rounded: ButtonRounded::Medium,
border_corners: Corners::all(true),
border_edges: Edges::all(true),
size: Size::Medium,
tooltip: None,
on_click: None,
stop_propagation: true,
loading: false,
compact: false,
children: Vec::new(),
loading_icon: None,
}
}
/// Set the border radius of the Button.
pub fn rounded(mut self, rounded: impl Into<ButtonRounded>) -> Self {
self.rounded = rounded.into();
self
}
/// Set the border corners side of the Button.
pub(crate) fn border_corners(mut self, corners: impl Into<Corners<bool>>) -> Self {
self.border_corners = corners.into();
self
}
/// Set the border edges of the Button.
pub(crate) fn border_edges(mut self, edges: impl Into<Edges<bool>>) -> Self {
self.border_edges = edges.into();
self
}
/// Set label to the Button, if no label is set, the button will be in Icon Button mode.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the icon of the button, if the Button have no label, the button well in Icon Button mode.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set the tooltip of the button.
pub fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
/// Set true to show the loading indicator.
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
/// Set the button to compact mode, then padding will be reduced.
pub fn compact(mut self) -> Self {
self.compact = true;
self
}
pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn stop_propagation(mut self, val: bool) -> Self {
self.stop_propagation = val;
self
}
pub fn loading_icon(mut self, icon: impl Into<Icon>) -> Self {
self.loading_icon = Some(icon.into());
self
}
}
impl Disableable for Button {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for Button {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl Sizable for Button {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl ButtonVariants for Button {
fn with_variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
}
impl Styled for Button {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl ParentElement for Button {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements)
}
}
impl InteractiveElement for Button {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl RenderOnce for Button {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let style: ButtonVariant = self.variant;
let normal_style = style.normal(cx);
let icon_size = match self.size {
Size::Size(v) => Size::Size(v * 0.75),
_ => self.size,
};
self.base
.id(self.id)
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.overflow_hidden()
.when(cx.theme().shadow && normal_style.shadow, |this| {
this.shadow_sm()
})
.when(!style.no_padding(), |this| {
if self.label.is_none() && self.children.is_empty() {
// Icon Button
match self.size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_5(),
Size::Small => this.size_6(),
Size::Large | Size::Medium => this.size_8(),
}
} else {
// Normal Button
match self.size {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => this.h_5().px_1(),
Size::Small => this.h_6().px_3().when(self.compact, |this| this.px_1p5()),
_ => this.h_8().px_4().when(self.compact, |this| this.px_2()),
}
}
})
.when(
self.border_corners.top_left && self.border_corners.bottom_left,
|this| match self.rounded {
ButtonRounded::Small => this.rounded_l(px(cx.theme().radius * 0.5)),
ButtonRounded::Medium => this.rounded_l(px(cx.theme().radius)),
ButtonRounded::Large => this.rounded_l(px(cx.theme().radius * 2.0)),
ButtonRounded::Size(px) => this.rounded_l(px),
ButtonRounded::None => this.rounded_none(),
},
)
.when(
self.border_corners.top_right && self.border_corners.bottom_right,
|this| match self.rounded {
ButtonRounded::Small => this.rounded_r(px(cx.theme().radius * 0.5)),
ButtonRounded::Medium => this.rounded_r(px(cx.theme().radius)),
ButtonRounded::Large => this.rounded_r(px(cx.theme().radius * 2.0)),
ButtonRounded::Size(px) => this.rounded_r(px),
ButtonRounded::None => this.rounded_none(),
},
)
.when(self.border_edges.left, |this| this.border_l_1())
.when(self.border_edges.right, |this| this.border_r_1())
.when(self.border_edges.top, |this| this.border_t_1())
.when(self.border_edges.bottom, |this| this.border_b_1())
.text_color(normal_style.fg)
.when(self.selected, |this| {
let selected_style = style.selected(cx);
this.bg(selected_style.bg)
.border_color(selected_style.border)
.text_color(selected_style.fg)
})
.when(!self.disabled && !self.selected, |this| {
this.border_color(normal_style.border)
.bg(normal_style.bg)
.when(normal_style.underline, |this| this.text_decoration_1())
.hover(|this| {
let hover_style = style.hovered(cx);
this.bg(hover_style.bg)
.border_color(hover_style.border)
.text_color(crate::red_400())
})
.active(|this| {
let active_style = style.active(cx);
this.bg(active_style.bg)
.border_color(active_style.border)
.text_color(active_style.fg)
})
})
.when_some(
self.on_click.filter(|_| !self.disabled && !self.loading),
|this, on_click| {
let stop_propagation = self.stop_propagation;
this.on_mouse_down(MouseButton::Left, move |_, cx| {
cx.prevent_default();
if stop_propagation {
cx.stop_propagation();
}
})
.on_click(move |event, cx| {
(on_click)(event, cx);
})
},
)
.when(self.disabled, |this| {
let disabled_style = style.disabled(cx);
this.cursor_not_allowed()
.bg(disabled_style.bg)
.text_color(disabled_style.fg)
.border_color(disabled_style.border)
.shadow_none()
})
.child({
h_flex()
.id("label")
.items_center()
.justify_center()
.map(|this| match self.size {
Size::XSmall => this.gap_1().text_xs(),
Size::Small => this.gap_1().text_sm(),
_ => this.gap_2().text_base(),
})
.when(!self.loading, |this| {
this.when_some(self.icon, |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading, |this| {
this.child(
Indicator::new()
.with_size(self.size)
.when_some(self.loading_icon, |this, icon| this.icon(icon)),
)
})
.when_some(self.label, |this, label| {
this.child(div().flex_none().line_height(relative(1.)).child(label))
})
.children(self.children)
})
.when(self.loading, |this| this.bg(normal_style.bg.opacity(0.8)))
.when_some(self.tooltip.clone(), |this, tooltip| {
this.tooltip(move |cx| Tooltip::new(tooltip.clone(), cx))
})
}
}
struct ButtonVariantStyle {
bg: Hsla,
border: Hsla,
fg: Hsla,
underline: bool,
shadow: bool,
}
impl ButtonVariant {
fn bg_color(&self, cx: &WindowContext) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().primary,
ButtonVariant::Secondary => cx.theme().secondary,
ButtonVariant::Danger => cx.theme().destructive,
ButtonVariant::Outline
| ButtonVariant::Ghost
| ButtonVariant::Link
| ButtonVariant::Text => cx.theme().transparent,
ButtonVariant::Custom(colors) => colors.color,
}
}
fn text_color(&self, cx: &WindowContext) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().primary_foreground,
ButtonVariant::Secondary | ButtonVariant::Outline | ButtonVariant::Ghost => {
cx.theme().secondary_foreground
}
ButtonVariant::Danger => cx.theme().destructive_foreground,
ButtonVariant::Link => cx.theme().link,
ButtonVariant::Text => cx.theme().foreground,
ButtonVariant::Custom(colors) => colors.foreground,
}
}
fn border_color(&self, cx: &WindowContext) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().primary,
ButtonVariant::Secondary => cx.theme().border,
ButtonVariant::Danger => cx.theme().destructive,
ButtonVariant::Outline => cx.theme().border,
ButtonVariant::Ghost | ButtonVariant::Link | ButtonVariant::Text => {
cx.theme().transparent
}
ButtonVariant::Custom(colors) => colors.border,
}
}
fn underline(&self, _: &WindowContext) -> bool {
match self {
ButtonVariant::Link => true,
_ => false,
}
}
fn shadow(&self, _: &WindowContext) -> bool {
match self {
ButtonVariant::Primary | ButtonVariant::Secondary | ButtonVariant::Danger => true,
ButtonVariant::Custom(c) => c.shadow,
_ => false,
}
}
fn normal(&self, cx: &WindowContext) -> ButtonVariantStyle {
let bg = self.bg_color(cx);
let border = self.border_color(cx);
let fg = self.text_color(cx);
let underline = self.underline(cx);
let shadow = self.shadow(cx);
ButtonVariantStyle {
bg,
border,
fg,
underline,
shadow,
}
}
fn hovered(&self, cx: &WindowContext) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().primary_hover,
ButtonVariant::Secondary | ButtonVariant::Outline => cx.theme().secondary_hover,
ButtonVariant::Danger => cx.theme().destructive_hover,
ButtonVariant::Ghost => {
if cx.theme().mode.is_dark() {
cx.theme().secondary.lighten(0.1).opacity(0.8)
} else {
cx.theme().secondary.darken(0.1).opacity(0.8)
}
}
ButtonVariant::Link => cx.theme().transparent,
ButtonVariant::Text => cx.theme().transparent,
ButtonVariant::Custom(colors) => colors.hover,
};
let border = self.border_color(cx);
let fg = match self {
ButtonVariant::Link => cx.theme().link_hover,
_ => self.text_color(cx),
};
let underline = self.underline(cx);
let shadow = self.shadow(cx);
ButtonVariantStyle {
bg,
border,
fg,
underline,
shadow,
}
}
fn active(&self, cx: &WindowContext) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().primary_active,
ButtonVariant::Secondary | ButtonVariant::Outline | ButtonVariant::Ghost => {
cx.theme().secondary_active
}
ButtonVariant::Danger => cx.theme().destructive_active,
ButtonVariant::Link => cx.theme().transparent,
ButtonVariant::Text => cx.theme().transparent,
ButtonVariant::Custom(colors) => colors.active,
};
let border = self.border_color(cx);
let fg = match self {
ButtonVariant::Link => cx.theme().link_active,
ButtonVariant::Text => cx.theme().foreground.opacity(0.7),
_ => self.text_color(cx),
};
let underline = self.underline(cx);
let shadow = self.shadow(cx);
ButtonVariantStyle {
bg,
border,
fg,
underline,
shadow,
}
}
fn selected(&self, cx: &WindowContext) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().primary_active,
ButtonVariant::Secondary | ButtonVariant::Outline | ButtonVariant::Ghost => {
cx.theme().secondary_active
}
ButtonVariant::Danger => cx.theme().destructive_active,
ButtonVariant::Link => cx.theme().transparent,
ButtonVariant::Text => cx.theme().transparent,
ButtonVariant::Custom(colors) => colors.active,
};
let border = self.border_color(cx);
let fg = match self {
ButtonVariant::Link => cx.theme().link_active,
ButtonVariant::Text => cx.theme().foreground.opacity(0.7),
_ => self.text_color(cx),
};
let underline = self.underline(cx);
let shadow = self.shadow(cx);
ButtonVariantStyle {
bg,
border,
fg,
underline,
shadow,
}
}
fn disabled(&self, cx: &WindowContext) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Link
| ButtonVariant::Ghost
| ButtonVariant::Outline
| ButtonVariant::Text => cx.theme().transparent,
ButtonVariant::Primary => cx.theme().primary.opacity(0.15),
ButtonVariant::Danger => cx.theme().destructive.opacity(0.15),
ButtonVariant::Secondary => cx.theme().secondary.opacity(1.5),
ButtonVariant::Custom(style) => style.color.opacity(0.15),
};
let fg = match self {
ButtonVariant::Link | ButtonVariant::Text | ButtonVariant::Ghost => {
cx.theme().link.grayscale()
}
_ => cx.theme().secondary_foreground.opacity(0.5).grayscale(),
};
let border = match self {
ButtonVariant::Outline => cx.theme().border.opacity(0.5),
_ => bg,
};
let underline = self.underline(cx);
let shadow = false;
ButtonVariantStyle {
bg,
border,
fg,
underline,
shadow,
}
}
}

View File

@@ -0,0 +1,198 @@
use gpui::{
div, prelude::FluentBuilder as _, Corners, Div, Edges, ElementId, InteractiveElement,
IntoElement, ParentElement, RenderOnce, StatefulInteractiveElement as _, Styled, WindowContext,
};
use std::{cell::Cell, rc::Rc};
use crate::{
button::{Button, ButtonVariant, ButtonVariants},
Disableable, Sizable, Size,
};
/// A ButtonGroup element, to wrap multiple buttons in a group.
#[derive(IntoElement)]
pub struct ButtonGroup {
pub base: Div,
id: ElementId,
children: Vec<Button>,
multiple: bool,
disabled: bool,
// The button props
compact: Option<bool>,
variant: Option<ButtonVariant>,
size: Option<Size>,
on_click: Option<Box<dyn Fn(&Vec<usize>, &mut WindowContext) + 'static>>,
}
impl Disableable for ButtonGroup {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl ButtonGroup {
/// Creates a new ButtonGroup.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div(),
children: Vec::new(),
id: id.into(),
variant: None,
size: None,
compact: None,
multiple: false,
disabled: false,
on_click: None,
}
}
/// Adds a button as a child to the ButtonGroup.
pub fn child(mut self, child: Button) -> Self {
self.children.push(child.disabled(self.disabled));
self
}
/// With the multiple selection mode.
pub fn multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
/// With the compact mode for the ButtonGroup.
pub fn compact(mut self) -> Self {
self.compact = Some(true);
self
}
/// Sets the on_click handler for the ButtonGroup.
///
/// The handler first argument is a vector of the selected button indices.
pub fn on_click(mut self, handler: impl Fn(&Vec<usize>, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl Sizable for ButtonGroup {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = Some(size.into());
self
}
}
impl Styled for ButtonGroup {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl ButtonVariants for ButtonGroup {
fn with_variant(mut self, variant: ButtonVariant) -> Self {
self.variant = Some(variant);
self
}
}
impl RenderOnce for ButtonGroup {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
let children_len = self.children.len();
let mut selected_ixs: Vec<usize> = Vec::new();
let state = Rc::new(Cell::new(None));
for (ix, child) in self.children.iter().enumerate() {
if child.selected {
selected_ixs.push(ix);
}
}
self.base
.id(self.id)
.flex()
.items_center()
.children(
self.children
.into_iter()
.enumerate()
.map(|(child_index, child)| {
let state = Rc::clone(&state);
let child = if children_len == 1 {
child
} else if child_index == 0 {
// First
child
.border_corners(Corners {
top_left: true,
top_right: false,
bottom_left: true,
bottom_right: false,
})
.border_edges(Edges {
left: true,
top: true,
right: true,
bottom: true,
})
} else if child_index == children_len - 1 {
// Last
child
.border_edges(Edges {
left: false,
top: true,
right: true,
bottom: true,
})
.border_corners(Corners {
top_left: false,
top_right: true,
bottom_left: false,
bottom_right: true,
})
} else {
// Middle
child
.border_corners(Corners::all(false))
.border_edges(Edges {
left: false,
top: true,
right: true,
bottom: true,
})
}
.stop_propagation(false)
.when_some(self.size, |this, size| this.with_size(size))
.when_some(self.variant, |this, variant| this.with_variant(variant))
.when_some(self.compact, |this, _| this.compact())
.on_click(move |_, _| {
state.set(Some(child_index));
});
child
}),
)
.when_some(
self.on_click.filter(|_| !self.disabled),
move |this, on_click| {
this.on_click(move |_, cx| {
let mut selected_ixs = selected_ixs.clone();
if let Some(ix) = state.get() {
if self.multiple {
if let Some(pos) = selected_ixs.iter().position(|&i| i == ix) {
selected_ixs.remove(pos);
} else {
selected_ixs.push(ix);
}
} else {
selected_ixs.clear();
selected_ixs.push(ix);
}
}
on_click(&selected_ixs, cx);
})
},
)
}
}

131
crates/ui/src/checkbox.rs Normal file
View File

@@ -0,0 +1,131 @@
use crate::{h_flex, theme::ActiveTheme, v_flex, Disableable, IconName, Selectable};
use gpui::{
div, prelude::FluentBuilder as _, relative, svg, ElementId, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _,
WindowContext,
};
/// A Checkbox element.
#[derive(IntoElement)]
pub struct Checkbox {
id: ElementId,
label: Option<SharedString>,
checked: bool,
disabled: bool,
on_click: Option<Box<dyn Fn(&bool, &mut WindowContext) + 'static>>,
}
impl Checkbox {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
label: None,
checked: false,
disabled: false,
on_click: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn on_click(mut self, handler: impl Fn(&bool, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl Disableable for Checkbox {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for Checkbox {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(self, selected: bool) -> Self {
self.checked(selected)
}
}
impl RenderOnce for Checkbox {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let (color, icon_color) = if self.disabled {
(
cx.theme().primary.opacity(0.5),
cx.theme().primary_foreground.opacity(0.5),
)
} else {
(cx.theme().primary, cx.theme().primary_foreground)
};
h_flex()
.id(self.id)
.gap_2()
.items_center()
.line_height(relative(1.))
.child(
v_flex()
.relative()
.border_1()
.border_color(color)
.rounded_sm()
.size_4()
.flex_shrink_0()
.map(|this| match self.checked {
false => this.bg(cx.theme().transparent),
_ => this.bg(color),
})
.child(
svg()
.absolute()
.top_px()
.left_px()
.size_3()
.text_color(icon_color)
.map(|this| match self.checked {
true => this.path(IconName::Check.path()),
_ => this,
}),
),
)
.map(|this| {
if let Some(label) = self.label {
this.text_color(cx.theme().foreground).child(
div()
.w_full()
.overflow_x_hidden()
.text_ellipsis()
.line_height(relative(1.))
.child(label),
)
} else {
this
}
})
.when(self.disabled, |this| {
this.cursor_not_allowed()
.text_color(cx.theme().muted_foreground)
})
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| {
this.on_click(move |_, cx| {
let checked = !self.checked;
on_click(&checked, cx);
})
},
)
}
}

152
crates/ui/src/clipboard.rs Normal file
View File

@@ -0,0 +1,152 @@
use std::{cell::RefCell, rc::Rc, time::Duration};
use gpui::{
prelude::FluentBuilder, AnyElement, ClipboardItem, Element, ElementId, GlobalElementId,
IntoElement, LayoutId, ParentElement, SharedString, Styled, WindowContext,
};
use crate::{
button::{Button, ButtonVariants as _},
h_flex, IconName, Sizable as _,
};
pub struct Clipboard {
id: ElementId,
value: SharedString,
content_builder: Option<Box<dyn Fn(&mut WindowContext) -> AnyElement>>,
copied_callback: Option<Rc<dyn Fn(SharedString, &mut WindowContext)>>,
}
impl Clipboard {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
value: "".into(),
content_builder: None,
copied_callback: None,
}
}
pub fn value(mut self, value: impl Into<SharedString>) -> Self {
self.value = value.into();
self
}
pub fn content<E, F>(mut self, element_builder: F) -> Self
where
E: IntoElement,
F: Fn(&mut WindowContext) -> E + 'static,
{
self.content_builder = Some(Box::new(move |cx| element_builder(cx).into_any_element()));
self
}
pub fn on_copied<F>(mut self, handler: F) -> Self
where
F: Fn(SharedString, &mut WindowContext) + 'static,
{
self.copied_callback = Some(Rc::new(handler));
self
}
}
impl IntoElement for Clipboard {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
#[derive(Default)]
pub struct ClipboardState {
copied: Rc<RefCell<bool>>,
}
impl Element for Clipboard {
type RequestLayoutState = AnyElement;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
cx: &mut WindowContext,
) -> (LayoutId, Self::RequestLayoutState) {
cx.with_element_state::<ClipboardState, _>(global_id.unwrap(), |state, cx| {
let state = state.unwrap_or_default();
let content_element = self
.content_builder
.as_ref()
.map(|builder| builder(cx).into_any_element());
let value = self.value.clone();
let clipboard_id = self.id.clone();
let copied_callback = self.copied_callback.as_ref().map(|c| c.clone());
let copied = state.copied.clone();
let copide_value = *copied.borrow();
let mut element = h_flex()
.gap_1()
.items_center()
.when_some(content_element, |this, element| this.child(element))
.child(
Button::new(clipboard_id)
.icon(if copide_value {
IconName::Check
} else {
IconName::Copy
})
.ghost()
.xsmall()
.when(!copide_value, |this| {
this.on_click(move |_, cx| {
cx.stop_propagation();
cx.write_to_clipboard(ClipboardItem::new_string(value.to_string()));
*copied.borrow_mut() = true;
let copied = copied.clone();
cx.spawn(|cx| async move {
cx.background_executor().timer(Duration::from_secs(2)).await;
*copied.borrow_mut() = false;
})
.detach();
if let Some(callback) = &copied_callback {
callback(value.clone(), cx);
}
})
}),
)
.into_any_element();
((element.request_layout(cx), element), state)
})
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) {
element.prepaint(cx);
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
element.paint(cx)
}
}

View File

@@ -0,0 +1,377 @@
use gpui::{
anchored, canvas, deferred, div, prelude::FluentBuilder as _, px, relative, AnchorCorner,
AppContext, Bounds, ElementId, EventEmitter, FocusHandle, FocusableView, Hsla,
InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
Render, SharedString, StatefulInteractiveElement as _, Styled, View, ViewContext,
VisualContext,
};
use crate::{
divider::Divider,
h_flex,
input::{InputEvent, TextInput},
popover::Escape,
theme::{ActiveTheme as _, Colorize},
tooltip::Tooltip,
v_flex, ColorExt as _, Sizable, Size, StyleSized,
};
const KEY_CONTEXT: &str = "ColorPicker";
pub fn init(cx: &mut AppContext) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(KEY_CONTEXT))])
}
#[derive(Clone)]
pub enum ColorPickerEvent {
Change(Option<Hsla>),
}
fn color_palettes() -> Vec<Vec<Hsla>> {
use crate::colors::DEFAULT_COLOR;
use itertools::Itertools as _;
macro_rules! c {
($color:tt) => {
DEFAULT_COLOR
.$color
.keys()
.sorted()
.map(|k| DEFAULT_COLOR.$color.get(k).map(|c| c.hsla).unwrap())
.collect::<Vec<_>>()
};
}
vec![
c!(stone),
c!(red),
c!(orange),
c!(yellow),
c!(green),
c!(cyan),
c!(blue),
c!(purple),
c!(pink),
]
}
pub struct ColorPicker {
id: ElementId,
focus_handle: FocusHandle,
value: Option<Hsla>,
featured_colors: Vec<Hsla>,
hovered_color: Option<Hsla>,
label: Option<SharedString>,
size: Size,
anchor: AnchorCorner,
color_input: View<TextInput>,
open: bool,
bounds: Bounds<Pixels>,
}
impl ColorPicker {
pub fn new(id: impl Into<ElementId>, cx: &mut ViewContext<Self>) -> Self {
let color_input = cx.new_view(|cx| TextInput::new(cx).xsmall());
cx.subscribe(&color_input, |this, _, ev: &InputEvent, cx| match ev {
InputEvent::Change(value) => {
if let Ok(color) = Hsla::parse_hex_string(value) {
this.value = Some(color);
this.hovered_color = Some(color);
}
}
InputEvent::PressEnter => {
let val = this.color_input.read(cx).text();
if let Ok(color) = Hsla::parse_hex_string(&val) {
this.open = false;
this.update_value(Some(color), true, cx);
}
}
_ => {}
})
.detach();
Self {
id: id.into(),
focus_handle: cx.focus_handle(),
featured_colors: vec![
crate::black(),
crate::gray_600(),
crate::gray_400(),
crate::white(),
crate::red_600(),
crate::orange_600(),
crate::yellow_600(),
crate::green_600(),
crate::blue_600(),
crate::indigo_600(),
crate::purple_600(),
],
value: None,
hovered_color: None,
size: Size::Medium,
label: None,
anchor: AnchorCorner::TopLeft,
color_input,
open: false,
bounds: Bounds::default(),
}
}
/// Set the featured colors to be displayed in the color picker.
///
/// This is used to display a set of colors that the user can quickly select from,
/// for example provided user's last used colors.
pub fn featured_colors(mut self, colors: Vec<Hsla>) -> Self {
self.featured_colors = colors;
self
}
/// Set current color value.
pub fn set_value(&mut self, value: Hsla, cx: &mut ViewContext<Self>) {
self.update_value(Some(value), false, cx)
}
/// Set the size of the color picker, default is `Size::Medium`.
pub fn size(mut self, size: Size) -> Self {
self.size = size;
self
}
/// Set the label to be displayed above the color picker.
///
/// Default is `None`.
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
/// Set the anchor corner of the color picker.
///
/// Default is `AnchorCorner::TopLeft`.
pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
self.anchor = anchor;
self
}
fn on_escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
cx.propagate();
self.open = false;
cx.notify();
}
fn toggle_picker(&mut self, _: &gpui::ClickEvent, cx: &mut ViewContext<Self>) {
self.open = !self.open;
cx.notify();
}
fn update_value(&mut self, value: Option<Hsla>, emit: bool, cx: &mut ViewContext<Self>) {
self.value = value;
self.hovered_color = value;
self.color_input.update(cx, |view, cx| {
if let Some(value) = value {
view.set_text(value.to_hex_string(), cx);
} else {
view.set_text("", cx);
}
});
if emit {
cx.emit(ColorPickerEvent::Change(value));
}
cx.notify();
}
fn render_item(
&self,
color: Hsla,
clickable: bool,
cx: &mut ViewContext<Self>,
) -> impl IntoElement {
div()
.id(SharedString::from(format!(
"color-{}",
color.to_hex_string()
)))
.h_5()
.w_5()
.bg(color)
.border_1()
.border_color(color.darken(0.1))
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| {
this.border_color(color.darken(0.3))
.bg(color.lighten(0.1))
.shadow_sm()
})
.active(|this| this.border_color(color.darken(0.5)).bg(color.darken(0.2)))
.on_mouse_move(cx.listener(move |view, _, cx| {
view.hovered_color = Some(color);
cx.notify();
}))
.on_click(cx.listener(move |view, _, cx| {
view.update_value(Some(color), true, cx);
view.open = false;
cx.notify();
}))
})
}
fn render_colors(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(
h_flex().gap_1().children(
self.featured_colors
.iter()
.map(|color| self.render_item(*color, true, cx)),
),
)
.child(Divider::horizontal())
.child(
v_flex()
.gap_1()
.children(color_palettes().iter().map(|sub_colors| {
h_flex().gap_1().children(
sub_colors
.iter()
.rev()
.map(|color| self.render_item(*color, true, cx)),
)
})),
)
.when_some(self.hovered_color, |this, hovered_color| {
this.child(Divider::horizontal()).child(
h_flex()
.gap_2()
.items_center()
.child(
div()
.bg(hovered_color)
.flex_shrink_0()
.border_1()
.border_color(hovered_color.darken(0.2))
.size_5()
.rounded(px(cx.theme().radius)),
)
.child(self.color_input.clone()),
)
})
}
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
match self.anchor {
AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
AnchorCorner::TopRight => AnchorCorner::BottomRight,
AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
AnchorCorner::BottomRight => AnchorCorner::TopRight,
}
.corner(bounds)
}
}
impl Sizable for ColorPicker {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl EventEmitter<ColorPickerEvent> for ColorPicker {}
impl FocusableView for ColorPicker {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ColorPicker {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let display_title: SharedString = if let Some(value) = self.value {
value.to_hex_string()
} else {
"".to_string()
}
.into();
let view = cx.view().clone();
div()
.id(self.id.clone())
.key_context(KEY_CONTEXT)
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_escape))
.child(
h_flex()
.id("color-picker-input")
.cursor_pointer()
.gap_2()
.items_center()
.input_text_size(self.size)
.line_height(relative(1.))
.child(
div()
.id("color-picker-square")
.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().input)
.rounded(px(cx.theme().radius))
.bg(cx.theme().background)
.shadow_sm()
.overflow_hidden()
.size_with(self.size)
.when_some(self.value, |this, value| {
this.bg(value).border_color(value.darken(0.3))
})
.tooltip(move |cx| Tooltip::new(display_title.clone(), cx)),
)
.when_some(self.label.clone(), |this, label| this.child(label))
.on_click(cx.listener(Self::toggle_picker))
.child(
canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
)
.absolute()
.size_full(),
),
)
.when(self.open, |this| {
this.child(
deferred(
anchored()
.anchor(self.anchor)
.snap_to_window_with_margin(px(8.))
.position(self.resolved_corner(self.bounds))
.child(
div()
.occlude()
.map(|this| match self.anchor {
AnchorCorner::TopLeft | AnchorCorner::TopRight => {
this.mt_1p5()
}
AnchorCorner::BottomLeft | AnchorCorner::BottomRight => {
this.mb_1p5()
}
})
.w_72()
.overflow_hidden()
.rounded_lg()
.p_3()
.border_1()
.border_color(cx.theme().border)
.shadow_lg()
.rounded_lg()
.bg(cx.theme().background)
.on_mouse_up_out(
MouseButton::Left,
cx.listener(|view, _, cx| view.on_escape(&Escape, cx)),
)
.child(self.render_colors(cx)),
),
)
.with_priority(1),
)
})
}
}

297
crates/ui/src/colors.rs Normal file
View File

@@ -0,0 +1,297 @@
use std::collections::HashMap;
use gpui::Hsla;
use serde::{de::Error, Deserialize, Deserializer};
use crate::theme::hsl;
use anyhow::Result;
pub(crate) trait ColorExt {
fn to_hex_string(&self) -> String;
fn parse_hex_string(hex: &str) -> Result<Hsla>;
}
impl ColorExt for Hsla {
fn to_hex_string(&self) -> String {
let rgb = self.to_rgb();
if rgb.a < 1. {
return format!(
"#{:02X}{:02X}{:02X}{:02X}",
((rgb.r * 255.) as u32),
((rgb.g * 255.) as u32),
((rgb.b * 255.) as u32),
((self.a * 255.) as u32)
);
}
format!(
"#{:02X}{:02X}{:02X}",
((rgb.r * 255.) as u32),
((rgb.g * 255.) as u32),
((rgb.b * 255.) as u32)
)
}
fn parse_hex_string(hex: &str) -> Result<Hsla> {
let hex = hex.trim_start_matches('#');
let len = hex.len();
if len != 6 && len != 8 {
return Err(anyhow::anyhow!("invalid hex color"));
}
let r = u8::from_str_radix(&hex[0..2], 16)? as f32 / 255.;
let g = u8::from_str_radix(&hex[2..4], 16)? as f32 / 255.;
let b = u8::from_str_radix(&hex[4..6], 16)? as f32 / 255.;
let a = if len == 8 {
u8::from_str_radix(&hex[6..8], 16)? as f32 / 255.
} else {
1.
};
let v = gpui::Rgba { r, g, b, a };
let color: Hsla = v.into();
Ok(color)
}
}
pub(crate) static DEFAULT_COLOR: once_cell::sync::Lazy<ShadcnColors> =
once_cell::sync::Lazy::new(|| {
serde_json::from_str(include_str!("../colors.json")).expect("failed to parse default-json")
});
type ColorScales = HashMap<usize, ShadcnColor>;
mod color_scales {
use std::collections::HashMap;
use super::{ColorScales, ShadcnColor};
use serde::de::{Deserialize, Deserializer};
pub fn deserialize<'de, D>(deserializer: D) -> Result<ColorScales, D::Error>
where
D: Deserializer<'de>,
{
let mut map = HashMap::new();
for color in Vec::<ShadcnColor>::deserialize(deserializer)? {
map.insert(color.scale, color);
}
Ok(map)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
pub(crate) struct ShadcnColors {
pub(crate) black: ShadcnColor,
pub(crate) white: ShadcnColor,
#[serde(with = "color_scales")]
pub(crate) slate: ColorScales,
#[serde(with = "color_scales")]
pub(crate) gray: ColorScales,
#[serde(with = "color_scales")]
pub(crate) zinc: ColorScales,
#[serde(with = "color_scales")]
pub(crate) neutral: ColorScales,
#[serde(with = "color_scales")]
pub(crate) stone: ColorScales,
#[serde(with = "color_scales")]
pub(crate) red: ColorScales,
#[serde(with = "color_scales")]
pub(crate) orange: ColorScales,
#[serde(with = "color_scales")]
pub(crate) amber: ColorScales,
#[serde(with = "color_scales")]
pub(crate) yellow: ColorScales,
#[serde(with = "color_scales")]
pub(crate) lime: ColorScales,
#[serde(with = "color_scales")]
pub(crate) green: ColorScales,
#[serde(with = "color_scales")]
pub(crate) emerald: ColorScales,
#[serde(with = "color_scales")]
pub(crate) teal: ColorScales,
#[serde(with = "color_scales")]
pub(crate) cyan: ColorScales,
#[serde(with = "color_scales")]
pub(crate) sky: ColorScales,
#[serde(with = "color_scales")]
pub(crate) blue: ColorScales,
#[serde(with = "color_scales")]
pub(crate) indigo: ColorScales,
#[serde(with = "color_scales")]
pub(crate) violet: ColorScales,
#[serde(with = "color_scales")]
pub(crate) purple: ColorScales,
#[serde(with = "color_scales")]
pub(crate) fuchsia: ColorScales,
#[serde(with = "color_scales")]
pub(crate) pink: ColorScales,
#[serde(with = "color_scales")]
pub(crate) rose: ColorScales,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Deserialize)]
pub(crate) struct ShadcnColor {
#[serde(default)]
pub(crate) scale: usize,
#[serde(deserialize_with = "from_hsl_channel", alias = "hslChannel")]
pub(crate) hsla: Hsla,
}
/// Deserialize Hsla from a string in the format "210 40% 98%"
fn from_hsl_channel<'de, D>(deserializer: D) -> Result<Hsla, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer).unwrap();
let mut parts = s.split_whitespace();
if parts.clone().count() != 3 {
return Err(D::Error::custom(
"expected hslChannel has 3 parts, e.g: '210 40% 98%'",
));
}
fn parse_number(s: &str) -> f32 {
s.trim_end_matches('%')
.parse()
.expect("failed to parse number")
}
let (h, s, l) = (
parse_number(parts.next().unwrap()),
parse_number(parts.next().unwrap()),
parse_number(parts.next().unwrap()),
);
Ok(hsl(h, s, l))
}
macro_rules! color_method {
($color:tt, $scale:tt) => {
paste::paste! {
#[inline]
#[allow(unused)]
pub fn [<$color _ $scale>]() -> Hsla {
if let Some(color) = DEFAULT_COLOR.$color.get(&($scale as usize)) {
return color.hsla;
}
black()
}
}
};
}
macro_rules! color_methods {
($color:tt) => {
paste::paste! {
/// Get color by scale number.
///
/// The possible scale numbers are:
/// 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
///
/// If the scale number is not found, it will return black color.
#[inline]
pub fn [<$color>](scale: usize) -> Hsla {
if let Some(color) = DEFAULT_COLOR.$color.get(&scale) {
return color.hsla;
}
black()
}
}
color_method!($color, 50);
color_method!($color, 100);
color_method!($color, 200);
color_method!($color, 300);
color_method!($color, 400);
color_method!($color, 500);
color_method!($color, 600);
color_method!($color, 700);
color_method!($color, 800);
color_method!($color, 900);
color_method!($color, 950);
};
}
pub fn black() -> Hsla {
DEFAULT_COLOR.black.hsla
}
pub fn white() -> Hsla {
DEFAULT_COLOR.white.hsla
}
color_methods!(slate);
color_methods!(gray);
color_methods!(zinc);
color_methods!(neutral);
color_methods!(stone);
color_methods!(red);
color_methods!(orange);
color_methods!(amber);
color_methods!(yellow);
color_methods!(lime);
color_methods!(green);
color_methods!(emerald);
color_methods!(teal);
color_methods!(cyan);
color_methods!(sky);
color_methods!(blue);
color_methods!(indigo);
color_methods!(violet);
color_methods!(purple);
color_methods!(fuchsia);
color_methods!(pink);
color_methods!(rose);
#[cfg(test)]
mod tests {
use gpui::{rgb, rgba};
use super::*;
#[test]
fn test_default_colors() {
assert_eq!(white(), hsl(0.0, 0.0, 100.0));
assert_eq!(black(), hsl(0.0, 0.0, 0.0));
assert_eq!(slate_50(), hsl(210.0, 40.0, 98.0));
assert_eq!(slate_100(), hsl(210.0, 40.0, 96.1));
assert_eq!(slate_900(), hsl(222.2, 47.4, 11.2));
assert_eq!(red_50(), hsl(0.0, 85.7, 97.3));
assert_eq!(yellow_100(), hsl(54.9, 96.7, 88.0));
assert_eq!(green_200(), hsl(141.0, 78.9, 85.1));
assert_eq!(cyan_300(), hsl(187.0, 92.4, 69.0));
assert_eq!(blue_400(), hsl(213.1, 93.9, 67.8));
assert_eq!(indigo_500(), hsl(238.7, 83.5, 66.7));
}
#[test]
fn test_to_hex_string() {
let color: Hsla = rgb(0xf8fafc).into();
assert_eq!(color.to_hex_string(), "#F8FAFC");
let color: Hsla = rgb(0xfef2f2).into();
assert_eq!(color.to_hex_string(), "#FEF2F2");
let color: Hsla = rgba(0x0413fcaa).into();
assert_eq!(color.to_hex_string(), "#0413FCAA");
}
#[test]
fn test_from_hex_string() {
let color: Hsla = Hsla::parse_hex_string("#F8FAFC").unwrap();
assert_eq!(color, rgb(0xf8fafc).into());
let color: Hsla = Hsla::parse_hex_string("#FEF2F2").unwrap();
assert_eq!(color, rgb(0xfef2f2).into());
let color: Hsla = Hsla::parse_hex_string("#0413FCAA").unwrap();
assert_eq!(color, rgba(0x0413fcaa).into());
}
}

View File

@@ -0,0 +1,230 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
anchored, deferred, div, prelude::FluentBuilder, px, relative, AnchorCorner, AnyElement,
DismissEvent, DispatchPhase, Element, ElementId, Focusable, GlobalElementId,
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
Position, Stateful, Style, View, ViewContext, WindowContext,
};
use crate::popup_menu::PopupMenu;
pub trait ContextMenuExt: ParentElement + Sized {
fn context_menu(
self,
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
self.child(ContextMenu::new("context-menu").menu(f))
}
}
impl<E> ContextMenuExt for Stateful<E> where E: ParentElement {}
impl<E> ContextMenuExt for Focusable<E> where E: ParentElement {}
/// A context menu that can be shown on right-click.
pub struct ContextMenu {
id: ElementId,
menu: Option<Box<dyn Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static>>,
anchor: AnchorCorner,
}
impl ContextMenu {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
menu: None,
anchor: AnchorCorner::TopLeft,
}
}
#[must_use]
pub fn menu<F>(mut self, builder: F) -> Self
where
F: Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
{
self.menu = Some(Box::new(builder));
self
}
fn with_element_state<R>(
&mut self,
id: &GlobalElementId,
cx: &mut WindowContext,
f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut WindowContext) -> R,
) -> R {
cx.with_optional_element_state::<ContextMenuState, _>(Some(id), |element_state, cx| {
let mut element_state = element_state.unwrap().unwrap_or_default();
let result = f(self, &mut element_state, cx);
(result, Some(element_state))
})
}
}
impl IntoElement for ContextMenu {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
pub struct ContextMenuState {
menu_view: Rc<RefCell<Option<View<PopupMenu>>>>,
menu_element: Option<AnyElement>,
open: Rc<RefCell<bool>>,
position: Rc<RefCell<Point<Pixels>>>,
}
impl Default for ContextMenuState {
fn default() -> Self {
Self {
menu_view: Rc::new(RefCell::new(None)),
menu_element: None,
open: Rc::new(RefCell::new(false)),
position: Default::default(),
}
}
}
impl Element for ContextMenu {
type RequestLayoutState = ContextMenuState;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn request_layout(
&mut self,
id: Option<&gpui::GlobalElementId>,
cx: &mut WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
// Set the layout style relative to the table view to get same size.
style.position = Position::Absolute;
style.flex_grow = 1.0;
style.flex_shrink = 1.0;
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
let anchor = self.anchor;
self.with_element_state(id.unwrap(), cx, |_, state: &mut ContextMenuState, cx| {
let position = state.position.clone();
let position = position.borrow();
let open = state.open.clone();
let menu_view = state.menu_view.borrow().clone();
let (menu_element, menu_layout_id) = if *open.borrow() {
let has_menu_item = menu_view
.as_ref()
.map(|menu| !menu.read(cx).is_empty())
.unwrap_or(false);
if has_menu_item {
let mut menu_element = deferred(
anchored()
.position(*position)
.snap_to_window_with_margin(px(8.))
.anchor(anchor)
.when_some(menu_view, |this, menu| {
// Focus the menu, so that can be handle the action.
menu.focus_handle(cx).focus(cx);
this.child(div().occlude().child(menu.clone()))
}),
)
.with_priority(1)
.into_any();
let menu_layout_id = menu_element.request_layout(cx);
(Some(menu_element), Some(menu_layout_id))
} else {
(None, None)
}
} else {
(None, None)
};
let mut layout_ids = vec![];
if let Some(menu_layout_id) = menu_layout_id {
layout_ids.push(menu_layout_id);
}
let layout_id = cx.request_layout(style, layout_ids);
(
layout_id,
ContextMenuState {
menu_element,
..Default::default()
},
)
})
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) -> Self::PrepaintState {
if let Some(menu_element) = &mut request_layout.menu_element {
menu_element.prepaint(cx);
}
}
fn paint(
&mut self,
id: Option<&gpui::GlobalElementId>,
bounds: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
if let Some(menu_element) = &mut request_layout.menu_element {
menu_element.paint(cx);
}
let Some(builder) = self.menu.take() else {
return;
};
self.with_element_state(
id.unwrap(),
cx,
|_view, state: &mut ContextMenuState, cx| {
let position = state.position.clone();
let open = state.open.clone();
let menu_view = state.menu_view.clone();
// When right mouse click, to build content menu, and show it at the mouse position.
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& event.button == MouseButton::Right
&& bounds.contains(&event.position)
{
*position.borrow_mut() = event.position;
*open.borrow_mut() = true;
let menu =
PopupMenu::build(cx, |menu, cx| (builder)(menu, cx)).into_element();
let open = open.clone();
cx.subscribe(&menu, move |_, _: &DismissEvent, cx| {
*open.borrow_mut() = false;
cx.refresh();
})
.detach();
*menu_view.borrow_mut() = Some(menu);
cx.refresh();
}
});
},
);
}
}

84
crates/ui/src/divider.rs Normal file
View File

@@ -0,0 +1,84 @@
use gpui::{
div, prelude::FluentBuilder as _, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce,
SharedString, Styled,
};
use crate::theme::ActiveTheme;
/// A divider that can be either vertical or horizontal.
#[derive(IntoElement)]
pub struct Divider {
base: Div,
label: Option<SharedString>,
axis: Axis,
color: Option<Hsla>,
}
impl Divider {
pub fn vertical() -> Self {
Self {
base: div().h_full(),
axis: Axis::Vertical,
label: None,
color: None,
}
}
pub fn horizontal() -> Self {
Self {
base: div().w_full(),
axis: Axis::Horizontal,
label: None,
color: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn color(mut self, color: impl Into<Hsla>) -> Self {
self.color = Some(color.into());
self
}
}
impl Styled for Divider {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Divider {
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
let theme = cx.theme();
self.base
.flex()
.flex_shrink_0()
.items_center()
.justify_center()
.child(
div()
.absolute()
.map(|this| match self.axis {
Axis::Vertical => this.w(px(1.)).h_full(),
Axis::Horizontal => this.h(px(1.)).w_full(),
})
.bg(self.color.unwrap_or(cx.theme().border)),
)
.when_some(self.label, |this, label| {
this.child(
div()
.px_2()
.py_1()
.mx_auto()
.text_xs()
.bg(cx.theme().background)
.text_color(theme.muted_foreground)
.child(label),
)
})
}
}

443
crates/ui/src/dock/dock.rs Normal file
View File

@@ -0,0 +1,443 @@
//! Dock is a fixed container that places at left, bottom, right of the Windows.
use std::sync::Arc;
use gpui::{
div, prelude::FluentBuilder as _, px, Axis, Element, Entity, InteractiveElement as _,
IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Style, Styled as _, View, ViewContext, VisualContext as _,
WeakView, WindowContext,
};
use serde::{Deserialize, Serialize};
use crate::{
resizable::{HANDLE_PADDING, HANDLE_SIZE, PANEL_MIN_SIZE},
theme::ActiveTheme as _,
AxisExt as _, StyledExt,
};
use super::{DockArea, DockItem, PanelView, TabPanel};
#[derive(Clone, Render)]
struct ResizePanel;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum DockPlacement {
#[serde(rename = "center")]
Center,
#[serde(rename = "left")]
Left,
#[serde(rename = "bottom")]
Bottom,
#[serde(rename = "right")]
Right,
}
impl DockPlacement {
fn axis(&self) -> Axis {
match self {
Self::Left | Self::Right => Axis::Horizontal,
Self::Bottom => Axis::Vertical,
Self::Center => unreachable!(),
}
}
pub fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
pub fn is_bottom(&self) -> bool {
matches!(self, Self::Bottom)
}
pub fn is_right(&self) -> bool {
matches!(self, Self::Right)
}
}
/// The Dock is a fixed container that places at left, bottom, right of the Windows.
///
/// This is unlike Panel, it can't be move or add any other panel.
pub struct Dock {
pub(super) placement: DockPlacement,
dock_area: WeakView<DockArea>,
pub(crate) panel: DockItem,
/// The size is means the width or height of the Dock, if the placement is left or right, the size is width, otherwise the size is height.
pub(super) size: Pixels,
pub(super) open: bool,
/// Whether the Dock is collapsible, default: true
pub(super) collapsible: bool,
// Runtime state
/// Whether the Dock is resizing
is_resizing: bool,
}
impl Dock {
pub(crate) fn new(
dock_area: WeakView<DockArea>,
placement: DockPlacement,
cx: &mut ViewContext<Self>,
) -> Self {
let panel = cx.new_view(|cx| {
let mut tab = TabPanel::new(None, dock_area.clone(), cx);
tab.closeable = false;
tab
});
let panel = DockItem::Tabs {
items: Vec::new(),
active_ix: 0,
view: panel.clone(),
};
Self::subscribe_panel_events(dock_area.clone(), &panel, cx);
Self {
placement,
dock_area,
panel,
open: true,
collapsible: true,
size: px(200.0),
is_resizing: false,
}
}
pub fn left(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
Self::new(dock_area, DockPlacement::Left, cx)
}
pub fn bottom(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
Self::new(dock_area, DockPlacement::Bottom, cx)
}
pub fn right(dock_area: WeakView<DockArea>, cx: &mut ViewContext<Self>) -> Self {
Self::new(dock_area, DockPlacement::Right, cx)
}
/// Update the Dock to be collapsible or not.
///
/// And if the Dock is not collapsible, it will be open.
pub fn set_collapsible(&mut self, collapsible: bool, cx: &mut ViewContext<Self>) {
self.collapsible = collapsible;
if !collapsible {
self.open = true
}
cx.notify();
}
pub(super) fn from_state(
dock_area: WeakView<DockArea>,
placement: DockPlacement,
size: Pixels,
panel: DockItem,
open: bool,
cx: &mut WindowContext,
) -> Self {
Self::subscribe_panel_events(dock_area.clone(), &panel, cx);
if !open {
if let DockItem::Tabs { view, .. } = panel.clone() {
view.update(cx, |panel, cx| {
panel.set_collapsed(true, cx);
});
}
}
Self {
placement,
dock_area,
panel,
open,
size,
collapsible: true,
is_resizing: false,
}
}
fn subscribe_panel_events(
dock_area: WeakView<DockArea>,
panel: &DockItem,
cx: &mut WindowContext,
) {
match panel {
DockItem::Tabs { view, .. } => {
cx.defer({
let view = view.clone();
move |cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, cx);
});
}
});
}
DockItem::Split { items, view, .. } => {
for item in items {
Self::subscribe_panel_events(dock_area.clone(), item, cx);
}
cx.defer({
let view = view.clone();
move |cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&view, cx);
});
}
});
}
DockItem::Panel { .. } => {
// Not supported
}
}
}
pub fn set_panel(&mut self, panel: DockItem, cx: &mut ViewContext<Self>) {
self.panel = panel;
cx.notify();
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn toggle_open(&mut self, cx: &mut ViewContext<Self>) {
self.set_open(!self.open, cx);
}
/// Returns the size of the Dock, the size is means the width or height of
/// the Dock, if the placement is left or right, the size is width,
/// otherwise the size is height.
pub fn size(&self) -> Pixels {
self.size
}
/// Set the size of the Dock.
pub fn set_size(&mut self, size: Pixels, cx: &mut ViewContext<Self>) {
self.size = size.max(PANEL_MIN_SIZE);
cx.notify();
}
/// Set the open state of the Dock.
pub fn set_open(&mut self, open: bool, cx: &mut ViewContext<Self>) {
self.open = open;
let item = self.panel.clone();
cx.defer(move |_, cx| {
item.set_collapsed(!open, cx);
});
cx.notify();
}
/// Add item to the Dock.
pub fn add_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
self.panel.add_panel(panel, &self.dock_area, cx);
cx.notify();
}
fn render_resize_handle(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let axis = self.placement.axis();
let neg_offset = -HANDLE_PADDING;
let view = cx.view().clone();
div()
.id("resize-handle")
.occlude()
.absolute()
.flex_shrink_0()
.when(self.placement.is_left(), |this| {
// FIXME: Improve this to let the scroll bar have px(HANDLE_PADDING)
this.cursor_col_resize()
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING)
})
.when(self.placement.is_right(), |this| {
this.cursor_col_resize()
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(self.placement.is_bottom(), |this| {
this.cursor_row_resize()
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
})
.child(
div()
.bg(cx.theme().border)
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.on_drag(ResizePanel {}, move |info, _, cx| {
cx.stop_propagation();
view.update(cx, |view, _| {
view.is_resizing = true;
});
cx.new_view(|_| info.clone())
})
}
fn resize(&mut self, mouse_position: Point<Pixels>, cx: &mut ViewContext<Self>) {
if !self.is_resizing {
return;
}
let dock_area = self
.dock_area
.upgrade()
.expect("DockArea is missing")
.read(cx);
let area_bounds = dock_area.bounds;
let mut left_dock_size = Pixels(0.0);
let mut right_dock_size = Pixels(0.0);
// Get the size of the left dock if it's open and not the current dock
if let Some(left_dock) = &dock_area.left_dock {
if left_dock.entity_id() != cx.view().entity_id() {
let left_dock_read = left_dock.read(cx);
if left_dock_read.is_open() {
left_dock_size = left_dock_read.size;
}
}
}
// Get the size of the right dock if it's open and not the current dock
if let Some(right_dock) = &dock_area.right_dock {
if right_dock.entity_id() != cx.view().entity_id() {
let right_dock_read = right_dock.read(cx);
if right_dock_read.is_open() {
right_dock_size = right_dock_read.size;
}
}
}
let size = match self.placement {
DockPlacement::Left => mouse_position.x - area_bounds.left(),
DockPlacement::Right => area_bounds.right() - mouse_position.x,
DockPlacement::Bottom => area_bounds.bottom() - mouse_position.y,
DockPlacement::Center => unreachable!(),
};
match self.placement {
DockPlacement::Left => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - right_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Right => {
let max_size = area_bounds.size.width - PANEL_MIN_SIZE - left_dock_size;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Bottom => {
let max_size = area_bounds.size.height - PANEL_MIN_SIZE;
self.size = size.clamp(PANEL_MIN_SIZE, max_size);
}
DockPlacement::Center => unreachable!(),
}
cx.notify();
}
fn done_resizing(&mut self, _: &mut ViewContext<Self>) {
self.is_resizing = false;
}
}
impl Render for Dock {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::IntoElement {
if !self.open && !self.placement.is_bottom() {
return div();
}
div()
.relative()
.overflow_hidden()
.map(|this| match self.placement {
DockPlacement::Left | DockPlacement::Right => this.h_flex().h_full().w(self.size),
DockPlacement::Bottom => this.w_full().h(self.size),
DockPlacement::Center => unreachable!(),
})
// Bottom Dock should keep the title bar, then user can click the Toggle button
.when(!self.open && self.placement.is_bottom(), |this| {
this.h(px(29.))
})
.map(|this| match &self.panel {
DockItem::Split { view, .. } => this.child(view.clone()),
DockItem::Tabs { view, .. } => this.child(view.clone()),
DockItem::Panel { view, .. } => this.child(view.clone().view()),
})
.child(self.render_resize_handle(cx))
.child(DockElement {
view: cx.view().clone(),
})
}
}
struct DockElement {
view: View<Dock>,
}
impl IntoElement for DockElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for DockElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
cx: &mut gpui::WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(cx.request_layout(Style::default(), None), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut gpui::WindowContext,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
cx: &mut gpui::WindowContext,
) {
cx.on_mouse_event({
let view = self.view.clone();
move |e: &MouseMoveEvent, phase, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.resize(e.position, cx))
}
}
});
// When any mouse up, stop dragging
cx.on_mouse_event({
let view = self.view.clone();
move |_: &MouseUpEvent, phase, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(cx));
}
}
})
}
}

View File

@@ -0,0 +1,55 @@
use gpui::{
AppContext, EventEmitter, FocusHandle, FocusableView, ParentElement as _, Render, SharedString,
Styled as _, WindowContext,
};
use crate::theme::ActiveTheme as _;
use super::{DockItemState, Panel, PanelEvent};
pub(crate) struct InvalidPanel {
name: SharedString,
focus_handle: FocusHandle,
old_state: DockItemState,
}
impl InvalidPanel {
pub(crate) fn new(name: &str, state: DockItemState, cx: &mut WindowContext) -> Self {
Self {
focus_handle: cx.focus_handle(),
name: SharedString::from(name.to_owned()),
old_state: state,
}
}
}
impl Panel for InvalidPanel {
fn panel_name(&self) -> &'static str {
"InvalidPanel"
}
fn dump(&self, _cx: &AppContext) -> super::DockItemState {
self.old_state.clone()
}
}
impl EventEmitter<PanelEvent> for InvalidPanel {}
impl FocusableView for InvalidPanel {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InvalidPanel {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl gpui::IntoElement {
gpui::div()
.size_full()
.my_6()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_color(cx.theme().muted_foreground)
.child(format!(
"The `{}` panel type is not registered in PanelRegistry.",
self.name.clone()
))
}
}

811
crates/ui/src/dock/mod.rs Normal file
View File

@@ -0,0 +1,811 @@
mod dock;
mod invalid_panel;
mod panel;
mod stack_panel;
mod state;
mod tab_panel;
use anyhow::Result;
pub use dock::*;
use gpui::{
actions, canvas, div, prelude::FluentBuilder, AnyElement, AnyView, AppContext, Axis, Bounds,
Edges, Entity as _, EntityId, EventEmitter, InteractiveElement as _, IntoElement,
ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, View, ViewContext,
VisualContext, WeakView, WindowContext,
};
use std::sync::Arc;
pub use panel::*;
pub use stack_panel::*;
pub use state::*;
pub use tab_panel::*;
pub fn init(cx: &mut AppContext) {
cx.set_global(PanelRegistry::new());
}
actions!(dock, [ToggleZoom, ClosePanel]);
pub enum DockEvent {
/// The layout of the dock has changed, subscribers this to save the layout.
///
/// This event is emitted when every time the layout of the dock has changed,
/// So it emits may be too frequently, you may want to debounce the event.
LayoutChanged,
}
/// The main area of the dock.
pub struct DockArea {
id: SharedString,
/// The version is used to special the default layout, this is like the `panel_version` in [`Panel`](Panel).
version: Option<usize>,
pub(crate) bounds: Bounds<Pixels>,
/// The center view of the dockarea.
items: DockItem,
/// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed,
toggle_button_panels: Edges<Option<EntityId>>,
/// The left dock of the dock_area.
left_dock: Option<View<Dock>>,
/// The bottom dock of the dock_area.
bottom_dock: Option<View<Dock>>,
/// The right dock of the dock_area.
right_dock: Option<View<Dock>>,
/// The top zoom view of the dock_area, if any.
zoom_view: Option<AnyView>,
/// Lock panels layout, but allow to resize.
is_locked: bool,
/// The panel style, default is [`PanelStyle::Default`](PanelStyle::Default).
pub(crate) panel_style: PanelStyle,
_subscriptions: Vec<Subscription>,
}
/// DockItem is a tree structure that represents the layout of the dock.
#[derive(Clone)]
pub enum DockItem {
/// Split layout
Split {
axis: gpui::Axis,
items: Vec<DockItem>,
sizes: Vec<Option<Pixels>>,
view: View<StackPanel>,
},
/// Tab layout
Tabs {
items: Vec<Arc<dyn PanelView>>,
active_ix: usize,
view: View<TabPanel>,
},
/// Panel layout
Panel { view: Arc<dyn PanelView> },
}
impl DockItem {
/// Create DockItem with split layout, each item of panel have equal size.
pub fn split(
axis: Axis,
items: Vec<DockItem>,
dock_area: &WeakView<DockArea>,
cx: &mut WindowContext,
) -> Self {
let sizes = vec![None; items.len()];
Self::split_with_sizes(axis, items, sizes, dock_area, cx)
}
/// Create DockItem with split layout, each item of panel have specified size.
///
/// Please note that the `items` and `sizes` must have the same length.
/// Set `None` in `sizes` to make the index of panel have auto size.
pub fn split_with_sizes(
axis: Axis,
items: Vec<DockItem>,
sizes: Vec<Option<Pixels>>,
dock_area: &WeakView<DockArea>,
cx: &mut WindowContext,
) -> Self {
let mut items = items;
let stack_panel = cx.new_view(|cx| {
let mut stack_panel = StackPanel::new(axis, cx);
for (i, item) in items.iter_mut().enumerate() {
let view = item.view();
let size = sizes.get(i).copied().flatten();
stack_panel.add_panel(view.clone(), size, dock_area.clone(), cx)
}
for (i, item) in items.iter().enumerate() {
let view = item.view();
let size = sizes.get(i).copied().flatten();
stack_panel.add_panel(view.clone(), size, dock_area.clone(), cx)
}
stack_panel
});
cx.defer({
let stack_panel = stack_panel.clone();
let dock_area = dock_area.clone();
move |cx| {
_ = dock_area.update(cx, |this, cx| {
this.subscribe_panel(&stack_panel, cx);
});
}
});
Self::Split {
axis,
items,
sizes,
view: stack_panel,
}
}
/// Create DockItem with panel layout
pub fn panel(panel: Arc<dyn PanelView>) -> Self {
Self::Panel { view: panel }
}
/// Create DockItem with tabs layout, items are displayed as tabs.
///
/// The `active_ix` is the index of the active tab, if `None` the first tab is active.
pub fn tabs(
items: Vec<Arc<dyn PanelView>>,
active_ix: Option<usize>,
dock_area: &WeakView<DockArea>,
cx: &mut WindowContext,
) -> Self {
let mut new_items: Vec<Arc<dyn PanelView>> = vec![];
for item in items.into_iter() {
new_items.push(item)
}
Self::new_tabs(new_items, active_ix, dock_area, cx)
}
pub fn tab<P: Panel>(
item: View<P>,
dock_area: &WeakView<DockArea>,
cx: &mut WindowContext,
) -> Self {
Self::new_tabs(vec![Arc::new(item.clone())], None, dock_area, cx)
}
fn new_tabs(
items: Vec<Arc<dyn PanelView>>,
active_ix: Option<usize>,
dock_area: &WeakView<DockArea>,
cx: &mut WindowContext,
) -> Self {
let active_ix = active_ix.unwrap_or(0);
let tab_panel = cx.new_view(|cx| {
let mut tab_panel = TabPanel::new(None, dock_area.clone(), cx);
for item in items.iter() {
tab_panel.add_panel(item.clone(), cx)
}
tab_panel.active_ix = active_ix;
tab_panel
});
Self::Tabs {
items,
active_ix,
view: tab_panel,
}
}
/// Returns the views of the dock item.
fn view(&self) -> Arc<dyn PanelView> {
match self {
Self::Split { view, .. } => Arc::new(view.clone()),
Self::Tabs { view, .. } => Arc::new(view.clone()),
Self::Panel { view, .. } => view.clone(),
}
}
/// Find existing panel in the dock item.
pub fn find_panel(&self, panel: Arc<dyn PanelView>) -> Option<Arc<dyn PanelView>> {
match self {
Self::Split { items, .. } => {
items.iter().find_map(|item| item.find_panel(panel.clone()))
}
Self::Tabs { items, .. } => items.iter().find(|item| *item == &panel).cloned(),
Self::Panel { view } => Some(view.clone()),
}
}
/// Add a panel to the dock item.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
dock_area: &WeakView<DockArea>,
cx: &mut WindowContext,
) {
match self {
Self::Tabs { view, items, .. } => {
items.push(panel.clone());
view.update(cx, |tab_panel, cx| {
tab_panel.add_panel(panel, cx);
});
}
Self::Split { view, items, .. } => {
// Iter items to add panel to the first tabs
for item in items.into_iter() {
if let DockItem::Tabs { view, .. } = item {
view.update(cx, |tab_panel, cx| {
tab_panel.add_panel(panel.clone(), cx);
});
return;
}
}
// Unable to find tabs, create new tabs
let new_item = Self::tabs(vec![panel.clone()], None, dock_area, cx);
items.push(new_item.clone());
view.update(cx, |stack_panel, cx| {
stack_panel.add_panel(new_item.view(), None, dock_area.clone(), cx);
});
}
Self::Panel { .. } => {}
}
}
pub fn set_collapsed(&self, collapsed: bool, cx: &mut WindowContext) {
match self {
DockItem::Tabs { view, .. } => {
view.update(cx, |tab_panel, cx| {
tab_panel.set_collapsed(collapsed, cx);
});
}
DockItem::Split { items, .. } => {
// For each child item, set collapsed state
for item in items {
item.set_collapsed(collapsed, cx);
}
}
DockItem::Panel { .. } => {}
}
}
/// Recursively traverses to find the left-most and top-most TabPanel.
pub(crate) fn left_top_tab_panel(&self, cx: &AppContext) -> Option<View<TabPanel>> {
match self {
DockItem::Tabs { view, .. } => Some(view.clone()),
DockItem::Split { view, .. } => view.read(cx).left_top_tab_panel(true, cx),
DockItem::Panel { .. } => None,
}
}
/// Recursively traverses to find the right-most and top-most TabPanel.
pub(crate) fn right_top_tab_panel(&self, cx: &AppContext) -> Option<View<TabPanel>> {
match self {
DockItem::Tabs { view, .. } => Some(view.clone()),
DockItem::Split { view, .. } => view.read(cx).right_top_tab_panel(true, cx),
DockItem::Panel { .. } => None,
}
}
}
impl DockArea {
pub fn new(
id: impl Into<SharedString>,
version: Option<usize>,
cx: &mut ViewContext<Self>,
) -> Self {
let stack_panel = cx.new_view(|cx| StackPanel::new(Axis::Horizontal, cx));
let dock_item = DockItem::Split {
axis: Axis::Horizontal,
items: vec![],
sizes: vec![],
view: stack_panel.clone(),
};
let mut this = Self {
id: id.into(),
version,
bounds: Bounds::default(),
items: dock_item,
zoom_view: None,
toggle_button_panels: Edges::default(),
left_dock: None,
right_dock: None,
bottom_dock: None,
is_locked: false,
panel_style: PanelStyle::Default,
_subscriptions: vec![],
};
this.subscribe_panel(&stack_panel, cx);
this
}
/// Set the panel style of the dock area.
pub fn panel_style(mut self, style: PanelStyle) -> Self {
self.panel_style = style;
self
}
/// Set version of the dock area.
pub fn set_version(&mut self, version: usize, cx: &mut ViewContext<Self>) {
self.version = Some(version);
cx.notify();
}
// FIXME: Remove this method after 2025-01-01
#[deprecated(note = "Use `set_center` instead")]
pub fn set_root(&mut self, item: DockItem, cx: &mut ViewContext<Self>) {
self.set_center(item, cx);
}
/// The the DockItem as the center of the dock area.
///
/// This is used to render at the Center of the DockArea.
pub fn set_center(&mut self, item: DockItem, cx: &mut ViewContext<Self>) {
self.subscribe_item(&item, cx);
self.items = item;
self.update_toggle_button_tab_panels(cx);
cx.notify();
}
pub fn set_left_dock(
&mut self,
panel: DockItem,
size: Option<Pixels>,
open: bool,
cx: &mut ViewContext<Self>,
) {
self.subscribe_item(&panel, cx);
let weak_self = cx.view().downgrade();
self.left_dock = Some(cx.new_view(|cx| {
let mut dock = Dock::left(weak_self.clone(), cx);
if let Some(size) = size {
dock.set_size(size, cx);
}
dock.set_panel(panel, cx);
dock.set_open(open, cx);
dock
}));
self.update_toggle_button_tab_panels(cx);
}
pub fn set_bottom_dock(
&mut self,
panel: DockItem,
size: Option<Pixels>,
open: bool,
cx: &mut ViewContext<Self>,
) {
self.subscribe_item(&panel, cx);
let weak_self = cx.view().downgrade();
self.bottom_dock = Some(cx.new_view(|cx| {
let mut dock = Dock::bottom(weak_self.clone(), cx);
if let Some(size) = size {
dock.set_size(size, cx);
}
dock.set_panel(panel, cx);
dock.set_open(open, cx);
dock
}));
self.update_toggle_button_tab_panels(cx);
}
pub fn set_right_dock(
&mut self,
panel: DockItem,
size: Option<Pixels>,
open: bool,
cx: &mut ViewContext<Self>,
) {
self.subscribe_item(&panel, cx);
let weak_self = cx.view().downgrade();
self.right_dock = Some(cx.new_view(|cx| {
let mut dock = Dock::right(weak_self.clone(), cx);
if let Some(size) = size {
dock.set_size(size, cx);
}
dock.set_panel(panel, cx);
dock.set_open(open, cx);
dock
}));
self.update_toggle_button_tab_panels(cx);
}
/// Set locked state of the dock area, if locked, the dock area cannot be split or move, but allows to resize panels.
pub fn set_locked(&mut self, locked: bool, _: &mut WindowContext) {
self.is_locked = locked;
}
/// Determine if the dock area is locked.
pub fn is_locked(&self) -> bool {
self.is_locked
}
/// Determine if the dock area has a dock at the given placement.
pub fn has_dock(&self, placement: DockPlacement) -> bool {
match placement {
DockPlacement::Left => self.left_dock.is_some(),
DockPlacement::Bottom => self.bottom_dock.is_some(),
DockPlacement::Right => self.right_dock.is_some(),
DockPlacement::Center => false,
}
}
/// Determine if the dock at the given placement is open.
pub fn is_dock_open(&self, placement: DockPlacement, cx: &AppContext) -> bool {
match placement {
DockPlacement::Left => self
.left_dock
.as_ref()
.map(|dock| dock.read(cx).is_open())
.unwrap_or(false),
DockPlacement::Bottom => self
.bottom_dock
.as_ref()
.map(|dock| dock.read(cx).is_open())
.unwrap_or(false),
DockPlacement::Right => self
.right_dock
.as_ref()
.map(|dock| dock.read(cx).is_open())
.unwrap_or(false),
DockPlacement::Center => false,
}
}
/// Set the dock at the given placement to be open or closed.
///
/// Only the left, bottom, right dock can be toggled.
pub fn set_dock_collapsible(
&mut self,
collapsible_edges: Edges<bool>,
cx: &mut ViewContext<Self>,
) {
if let Some(left_dock) = self.left_dock.as_ref() {
left_dock.update(cx, |dock, cx| {
dock.set_collapsible(collapsible_edges.left, cx);
});
}
if let Some(bottom_dock) = self.bottom_dock.as_ref() {
bottom_dock.update(cx, |dock, cx| {
dock.set_collapsible(collapsible_edges.bottom, cx);
});
}
if let Some(right_dock) = self.right_dock.as_ref() {
right_dock.update(cx, |dock, cx| {
dock.set_collapsible(collapsible_edges.right, cx);
});
}
}
/// Determine if the dock at the given placement is collapsible.
pub fn is_dock_collapsible(&self, placement: DockPlacement, cx: &AppContext) -> bool {
match placement {
DockPlacement::Left => self
.left_dock
.as_ref()
.map(|dock| dock.read(cx).collapsible)
.unwrap_or(false),
DockPlacement::Bottom => self
.bottom_dock
.as_ref()
.map(|dock| dock.read(cx).collapsible)
.unwrap_or(false),
DockPlacement::Right => self
.right_dock
.as_ref()
.map(|dock| dock.read(cx).collapsible)
.unwrap_or(false),
DockPlacement::Center => false,
}
}
pub fn toggle_dock(&self, placement: DockPlacement, cx: &mut ViewContext<Self>) {
let dock = match placement {
DockPlacement::Left => &self.left_dock,
DockPlacement::Bottom => &self.bottom_dock,
DockPlacement::Right => &self.right_dock,
DockPlacement::Center => return,
};
if let Some(dock) = dock {
dock.update(cx, |view, cx| {
view.toggle_open(cx);
})
}
}
/// Add a panel item to the dock area at the given placement.
///
/// If the left, bottom, right dock is not present, it will set the dock at the placement.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
placement: DockPlacement,
cx: &mut ViewContext<Self>,
) {
let weak_self = cx.view().downgrade();
match placement {
DockPlacement::Left => {
if let Some(dock) = self.left_dock.as_ref() {
dock.update(cx, |dock, cx| dock.add_panel(panel, cx))
} else {
self.set_left_dock(
DockItem::tabs(vec![panel], None, &weak_self, cx),
None,
true,
cx,
);
}
}
DockPlacement::Bottom => {
if let Some(dock) = self.bottom_dock.as_ref() {
dock.update(cx, |dock, cx| dock.add_panel(panel, cx))
} else {
self.set_bottom_dock(
DockItem::tabs(vec![panel], None, &weak_self, cx),
None,
true,
cx,
);
}
}
DockPlacement::Right => {
if let Some(dock) = self.right_dock.as_ref() {
dock.update(cx, |dock, cx| dock.add_panel(panel, cx))
} else {
self.set_right_dock(
DockItem::tabs(vec![panel], None, &weak_self, cx),
None,
true,
cx,
);
}
}
DockPlacement::Center => {
self.items.add_panel(panel, &cx.view().downgrade(), cx);
}
}
}
/// Load the state of the DockArea from the DockAreaState.
///
/// See also [DockeArea::dump].
pub fn load(&mut self, state: DockAreaState, cx: &mut ViewContext<Self>) -> Result<()> {
self.version = state.version;
let weak_self = cx.view().downgrade();
if let Some(left_dock_state) = state.left_dock {
self.left_dock = Some(left_dock_state.to_dock(weak_self.clone(), cx));
}
if let Some(right_dock_state) = state.right_dock {
self.right_dock = Some(right_dock_state.to_dock(weak_self.clone(), cx));
}
if let Some(bottom_dock_state) = state.bottom_dock {
self.bottom_dock = Some(bottom_dock_state.to_dock(weak_self.clone(), cx));
}
self.items = state.center.to_item(weak_self, cx);
self.update_toggle_button_tab_panels(cx);
Ok(())
}
/// Dump the dock panels layout to DockItemState.
///
/// See also [DockArea::load].
pub fn dump(&self, cx: &AppContext) -> DockAreaState {
let root = self.items.view();
let center = root.dump(cx);
let left_dock = self
.left_dock
.as_ref()
.map(|dock| DockState::new(dock.clone(), cx));
let right_dock = self
.right_dock
.as_ref()
.map(|dock| DockState::new(dock.clone(), cx));
let bottom_dock = self
.bottom_dock
.as_ref()
.map(|dock| DockState::new(dock.clone(), cx));
DockAreaState {
version: self.version,
center,
left_dock,
right_dock,
bottom_dock,
}
}
/// Subscribe event on the panels
#[allow(clippy::only_used_in_recursion)]
fn subscribe_item(&mut self, item: &DockItem, cx: &mut ViewContext<Self>) {
match item {
DockItem::Split { items, view, .. } => {
for item in items {
self.subscribe_item(item, cx);
}
self._subscriptions
.push(cx.subscribe(view, move |_, _, event, cx| match event {
PanelEvent::LayoutChanged => {
let dock_area = cx.view().clone();
cx.spawn(|_, mut cx| async move {
let _ = cx.update(|cx| {
let _ = dock_area.update(cx, |view, cx| {
view.update_toggle_button_tab_panels(cx)
});
});
})
.detach();
cx.emit(DockEvent::LayoutChanged);
}
_ => {}
}));
}
DockItem::Tabs { .. } => {
// We subscribe to the tab panel event in StackPanel's insert_panel
}
DockItem::Panel { .. } => {
// Not supported
}
}
}
/// Subscribe zoom event on the panel
pub(crate) fn subscribe_panel<P: Panel>(
&mut self,
view: &View<P>,
cx: &mut ViewContext<DockArea>,
) {
let subscription = cx.subscribe(view, move |_, panel, event, cx| match event {
PanelEvent::ZoomIn => {
let dock_area = cx.view().clone();
let panel = panel.clone();
cx.spawn(|_, mut cx| async move {
let _ = cx.update(|cx| {
let _ = dock_area.update(cx, |dock, cx| {
dock.set_zoomed_in(panel, cx);
cx.notify();
});
});
})
.detach();
}
PanelEvent::ZoomOut => {
let dock_area = cx.view().clone();
cx.spawn(|_, mut cx| async move {
let _ = cx.update(|cx| {
let _ = dock_area.update(cx, |view, cx| view.set_zoomed_out(cx));
});
})
.detach()
}
PanelEvent::LayoutChanged => {
let dock_area = cx.view().clone();
cx.spawn(|_, mut cx| async move {
let _ = cx.update(|cx| {
let _ = dock_area
.update(cx, |view, cx| view.update_toggle_button_tab_panels(cx));
});
})
.detach();
cx.emit(DockEvent::LayoutChanged);
}
});
self._subscriptions.push(subscription);
}
/// Returns the ID of the dock area.
pub fn id(&self) -> SharedString {
self.id.clone()
}
pub fn set_zoomed_in<P: Panel>(&mut self, panel: View<P>, cx: &mut ViewContext<Self>) {
self.zoom_view = Some(panel.into());
cx.notify();
}
pub fn set_zoomed_out(&mut self, cx: &mut ViewContext<Self>) {
self.zoom_view = None;
cx.notify();
}
fn render_items(&self, _cx: &mut ViewContext<Self>) -> AnyElement {
match &self.items {
DockItem::Split { view, .. } => view.clone().into_any_element(),
DockItem::Tabs { view, .. } => view.clone().into_any_element(),
DockItem::Panel { view, .. } => view.clone().view().into_any_element(),
}
}
pub fn update_toggle_button_tab_panels(&mut self, cx: &mut ViewContext<Self>) {
// Left toggle button
self.toggle_button_panels.left = self
.items
.left_top_tab_panel(cx)
.map(|view| view.entity_id());
// Right toggle button
self.toggle_button_panels.right = self
.items
.right_top_tab_panel(cx)
.map(|view| view.entity_id());
// Bottom toggle button
self.toggle_button_panels.bottom = self
.bottom_dock
.as_ref()
.and_then(|dock| dock.read(cx).panel.left_top_tab_panel(cx))
.map(|view| view.entity_id());
}
}
impl EventEmitter<DockEvent> for DockArea {}
impl Render for DockArea {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let view = cx.view().clone();
div()
.id("dock-area")
.relative()
.size_full()
.overflow_hidden()
.child(
canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
)
.absolute()
.size_full(),
)
.map(|this| {
if let Some(zoom_view) = self.zoom_view.clone() {
this.child(zoom_view)
} else {
this.child(
div()
.flex()
.flex_row()
.h_full()
// Left dock
.when_some(self.left_dock.clone(), |this, dock| {
this.child(div().flex().flex_none().child(dock))
})
// Center
.child(
div()
.flex()
.flex_1()
.flex_col()
.overflow_hidden()
// Top center
.child(
div()
.flex_1()
.overflow_hidden()
.child(self.render_items(cx)),
)
// Bottom Dock
.when_some(self.bottom_dock.clone(), |this, dock| {
this.child(dock)
}),
)
// Right Dock
.when_some(self.right_dock.clone(), |this, dock| {
this.child(div().flex().flex_none().child(dock))
}),
)
}
})
}
}

186
crates/ui/src/dock/panel.rs Normal file
View File

@@ -0,0 +1,186 @@
use gpui::{
AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView, Global, Hsla,
IntoElement, SharedString, View, WeakView, WindowContext,
};
use std::{collections::HashMap, sync::Arc};
use super::{DockArea, DockItemInfo, DockItemState};
use crate::{button::Button, popup_menu::PopupMenu};
pub enum PanelEvent {
ZoomIn,
ZoomOut,
LayoutChanged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelStyle {
/// Display the TabBar when there are multiple tabs, otherwise display the simple title.
Default,
/// Always display the tab bar.
TabBar,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TitleStyle {
pub background: Hsla,
pub foreground: Hsla,
}
pub trait Panel: EventEmitter<PanelEvent> + FocusableView {
/// The name of the panel used to serialize, deserialize and identify the panel.
///
/// This is used to identify the panel when deserializing the panel.
/// Once you have defined a panel name, this must not be changed.
fn panel_name(&self) -> &'static str;
/// The title of the panel
fn title(&self, _cx: &WindowContext) -> AnyElement {
SharedString::from("Unnamed").into_any_element()
}
/// The theme of the panel title, default is `None`.
fn title_style(&self, _cx: &WindowContext) -> Option<TitleStyle> {
None
}
/// Whether the panel can be closed, default is `true`.
fn closeable(&self, _cx: &WindowContext) -> bool {
true
}
/// Return true if the panel is zoomable, default is `false`.
fn zoomable(&self, _cx: &WindowContext) -> bool {
true
}
/// The addition popup menu of the panel, default is `None`.
fn popup_menu(&self, this: PopupMenu, _cx: &WindowContext) -> PopupMenu {
this
}
/// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`.
fn toolbar_buttons(&self, _cx: &WindowContext) -> Vec<Button> {
vec![]
}
/// Dump the panel, used to serialize the panel.
fn dump(&self, _cx: &AppContext) -> DockItemState {
DockItemState::new(self)
}
}
pub trait PanelView: 'static + Send + Sync {
fn panel_name(&self, _cx: &WindowContext) -> &'static str;
fn title(&self, _cx: &WindowContext) -> AnyElement;
fn title_style(&self, _cx: &WindowContext) -> Option<TitleStyle>;
fn closeable(&self, cx: &WindowContext) -> bool;
fn zoomable(&self, cx: &WindowContext) -> bool;
fn popup_menu(&self, menu: PopupMenu, cx: &WindowContext) -> PopupMenu;
fn toolbar_buttons(&self, cx: &WindowContext) -> Vec<Button>;
fn view(&self) -> AnyView;
fn focus_handle(&self, cx: &AppContext) -> FocusHandle;
fn dump(&self, cx: &AppContext) -> DockItemState;
}
impl<T: Panel> PanelView for View<T> {
fn panel_name(&self, cx: &WindowContext) -> &'static str {
self.read(cx).panel_name()
}
fn title(&self, cx: &WindowContext) -> AnyElement {
self.read(cx).title(cx)
}
fn title_style(&self, cx: &WindowContext) -> Option<TitleStyle> {
self.read(cx).title_style(cx)
}
fn closeable(&self, cx: &WindowContext) -> bool {
self.read(cx).closeable(cx)
}
fn zoomable(&self, cx: &WindowContext) -> bool {
self.read(cx).zoomable(cx)
}
fn popup_menu(&self, menu: PopupMenu, cx: &WindowContext) -> PopupMenu {
self.read(cx).popup_menu(menu, cx)
}
fn toolbar_buttons(&self, cx: &WindowContext) -> Vec<Button> {
self.read(cx).toolbar_buttons(cx)
}
fn view(&self) -> AnyView {
self.clone().into()
}
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.read(cx).focus_handle(cx)
}
fn dump(&self, cx: &AppContext) -> DockItemState {
self.read(cx).dump(cx)
}
}
impl From<&dyn PanelView> for AnyView {
fn from(handle: &dyn PanelView) -> Self {
handle.view()
}
}
impl<T: Panel> From<&dyn PanelView> for View<T> {
fn from(value: &dyn PanelView) -> Self {
value.view().downcast::<T>().unwrap()
}
}
impl PartialEq for dyn PanelView {
fn eq(&self, other: &Self) -> bool {
self.view() == other.view()
}
}
pub struct PanelRegistry {
pub(super) items: HashMap<
String,
Arc<
dyn Fn(
WeakView<DockArea>,
&DockItemState,
&DockItemInfo,
&mut WindowContext,
) -> Box<dyn PanelView>,
>,
>,
}
impl PanelRegistry {
pub fn new() -> Self {
Self {
items: HashMap::new(),
}
}
}
impl Global for PanelRegistry {}
/// Register the Panel init by panel_name to global registry.
pub fn register_panel<F>(cx: &mut AppContext, panel_name: &str, deserialize: F)
where
F: Fn(
WeakView<DockArea>,
&DockItemState,
&DockItemInfo,
&mut WindowContext,
) -> Box<dyn PanelView>
+ 'static,
{
if let None = cx.try_global::<PanelRegistry>() {
cx.set_global(PanelRegistry::new());
}
cx.global_mut::<PanelRegistry>()
.items
.insert(panel_name.to_string(), Arc::new(deserialize));
}

View File

@@ -0,0 +1,379 @@
use std::sync::Arc;
use crate::{
dock::DockItemInfo,
h_flex,
resizable::{
h_resizable, resizable_panel, v_resizable, ResizablePanel, ResizablePanelEvent,
ResizablePanelGroup,
},
theme::ActiveTheme,
AxisExt as _, Placement,
};
use super::{DockArea, DockItemState, Panel, PanelEvent, PanelView, TabPanel};
use gpui::{
prelude::FluentBuilder as _, AppContext, Axis, DismissEvent, EventEmitter, FocusHandle,
FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, View,
ViewContext, VisualContext, WeakView,
};
use smallvec::SmallVec;
pub struct StackPanel {
pub(super) parent: Option<WeakView<StackPanel>>,
pub(super) axis: Axis,
focus_handle: FocusHandle,
pub(crate) panels: SmallVec<[Arc<dyn PanelView>; 2]>,
panel_group: View<ResizablePanelGroup>,
_subscriptions: Vec<Subscription>,
}
impl Panel for StackPanel {
fn panel_name(&self) -> &'static str {
"StackPanel"
}
fn title(&self, _cx: &gpui::WindowContext) -> gpui::AnyElement {
"StackPanel".into_any_element()
}
fn dump(&self, cx: &AppContext) -> DockItemState {
let sizes = self.panel_group.read(cx).sizes();
let mut state = DockItemState::new(self);
for panel in &self.panels {
state.add_child(panel.dump(cx));
state.info = DockItemInfo::stack(sizes.clone(), self.axis);
}
state
}
}
impl StackPanel {
pub fn new(axis: Axis, cx: &mut ViewContext<Self>) -> Self {
let panel_group = cx.new_view(|cx| {
if axis == Axis::Horizontal {
h_resizable(cx)
} else {
v_resizable(cx)
}
});
// Bubble up the resize event.
let _subscriptions = vec![cx
.subscribe(&panel_group, |_, _, _: &ResizablePanelEvent, cx| {
cx.emit(PanelEvent::LayoutChanged)
})];
Self {
axis,
parent: None,
focus_handle: cx.focus_handle(),
panels: SmallVec::new(),
panel_group,
_subscriptions,
}
}
/// The first level of the stack panel is root, will not have a parent.
fn is_root(&self) -> bool {
self.parent.is_none()
}
/// Return true if self or parent only have last panel.
pub(super) fn is_last_panel(&self, cx: &AppContext) -> bool {
if self.panels.len() > 1 {
return false;
}
if let Some(parent) = &self.parent {
if let Some(parent) = parent.upgrade() {
return parent.read(cx).is_last_panel(cx);
}
}
true
}
pub(super) fn panels_len(&self) -> usize {
self.panels.len()
}
/// Return the index of the panel.
pub(crate) fn index_of_panel(&self, panel: Arc<dyn PanelView>) -> Option<usize> {
self.panels.iter().position(|p| p == &panel)
}
/// Add a panel at the end of the stack.
pub fn add_panel(
&mut self,
panel: Arc<dyn PanelView>,
size: Option<Pixels>,
dock_area: WeakView<DockArea>,
cx: &mut ViewContext<Self>,
) {
self.insert_panel(panel, self.panels.len(), size, dock_area, cx);
}
pub fn add_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakView<DockArea>,
cx: &mut ViewContext<Self>,
) {
self.insert_panel_at(panel, self.panels_len(), placement, size, dock_area, cx);
}
pub fn insert_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
placement: Placement,
size: Option<Pixels>,
dock_area: WeakView<DockArea>,
cx: &mut ViewContext<Self>,
) {
match placement {
Placement::Top | Placement::Left => {
self.insert_panel_before(panel, ix, size, dock_area, cx)
}
Placement::Right | Placement::Bottom => {
self.insert_panel_after(panel, ix, size, dock_area, cx)
}
}
}
/// Insert a panel at the index.
pub fn insert_panel_before(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakView<DockArea>,
cx: &mut ViewContext<Self>,
) {
self.insert_panel(panel, ix, size, dock_area, cx);
}
/// Insert a panel after the index.
pub fn insert_panel_after(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakView<DockArea>,
cx: &mut ViewContext<Self>,
) {
self.insert_panel(panel, ix + 1, size, dock_area, cx);
}
fn new_resizable_panel(panel: Arc<dyn PanelView>, size: Option<Pixels>) -> ResizablePanel {
resizable_panel()
.content_view(panel.view())
.when_some(size, |this, size| this.size(size))
}
fn insert_panel(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
size: Option<Pixels>,
dock_area: WeakView<DockArea>,
cx: &mut ViewContext<Self>,
) {
// If the panel is already in the stack, return.
if let Some(_) = self.index_of_panel(panel.clone()) {
return;
}
let view = cx.view().clone();
cx.window_context().defer({
let panel = panel.clone();
move |cx| {
// If the panel is a TabPanel, set its parent to this.
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
tab_panel.update(cx, |tab_panel, _| tab_panel.set_parent(view.downgrade()));
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
stack_panel.update(cx, |stack_panel, _| {
stack_panel.parent = Some(view.downgrade())
});
}
// Subscribe to the panel's layout change event.
_ = dock_area.update(cx, |this, cx| {
if let Ok(tab_panel) = panel.view().downcast::<TabPanel>() {
this.subscribe_panel(&tab_panel, cx);
} else if let Ok(stack_panel) = panel.view().downcast::<Self>() {
this.subscribe_panel(&stack_panel, cx);
}
});
}
});
let ix = if ix > self.panels.len() {
self.panels.len()
} else {
ix
};
self.panels.insert(ix, panel.clone());
self.panel_group.update(cx, |view, cx| {
view.insert_child(Self::new_resizable_panel(panel.clone(), size), ix, cx)
});
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Remove panel from the stack.
pub fn remove_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
if let Some(ix) = self.index_of_panel(panel.clone()) {
self.panels.remove(ix);
self.panel_group.update(cx, |view, cx| {
view.remove_child(ix, cx);
});
cx.emit(PanelEvent::LayoutChanged);
self.remove_self_if_empty(cx);
} else {
println!("Panel not found in stack panel.");
}
}
/// Replace the old panel with the new panel at same index.
pub(super) fn replace_panel(
&mut self,
old_panel: Arc<dyn PanelView>,
new_panel: View<StackPanel>,
cx: &mut ViewContext<Self>,
) {
if let Some(ix) = self.index_of_panel(old_panel.clone()) {
self.panels[ix] = Arc::new(new_panel.clone());
self.panel_group.update(cx, |view, cx| {
view.replace_child(
Self::new_resizable_panel(Arc::new(new_panel.clone()), None),
ix,
cx,
);
});
cx.emit(PanelEvent::LayoutChanged);
}
}
/// If children is empty, remove self from parent view.
pub(crate) fn remove_self_if_empty(&mut self, cx: &mut ViewContext<Self>) {
if self.is_root() {
return;
}
if !self.panels.is_empty() {
return;
}
let view = cx.view().clone();
if let Some(parent) = self.parent.as_ref() {
_ = parent.update(cx, |parent, cx| {
parent.remove_panel(Arc::new(view.clone()), cx);
});
}
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Find the first top left in the stack.
pub(super) fn left_top_tab_panel(
&self,
check_parent: bool,
cx: &AppContext,
) -> Option<View<TabPanel>> {
if check_parent {
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
if let Some(panel) = parent.read(cx).left_top_tab_panel(true, cx) {
return Some(panel);
}
}
}
let first_panel = self.panels.first();
if let Some(view) = first_panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).left_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Find the first top right in the stack.
pub(super) fn right_top_tab_panel(
&self,
check_parent: bool,
cx: &AppContext,
) -> Option<View<TabPanel>> {
if check_parent {
if let Some(parent) = self.parent.as_ref().and_then(|parent| parent.upgrade()) {
if let Some(panel) = parent.read(cx).right_top_tab_panel(true, cx) {
return Some(panel);
}
}
}
let panel = if self.axis.is_vertical() {
self.panels.first()
} else {
self.panels.last()
};
if let Some(view) = panel {
if let Ok(tab_panel) = view.view().downcast::<TabPanel>() {
Some(tab_panel)
} else if let Ok(stack_panel) = view.view().downcast::<StackPanel>() {
stack_panel.read(cx).right_top_tab_panel(false, cx)
} else {
None
}
} else {
None
}
}
/// Remove all panels from the stack.
pub(super) fn remove_all_panels(&mut self, cx: &mut ViewContext<Self>) {
self.panels.clear();
self.panel_group
.update(cx, |view, cx| view.remove_all_children(cx));
}
/// Change the axis of the stack panel.
pub(super) fn set_axis(&mut self, axis: Axis, cx: &mut ViewContext<Self>) {
self.axis = axis;
self.panel_group
.update(cx, |view, cx| view.set_axis(axis, cx));
cx.notify();
}
}
impl FocusableView for StackPanel {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<PanelEvent> for StackPanel {}
impl EventEmitter<DismissEvent> for StackPanel {}
impl Render for StackPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
h_flex()
.size_full()
.overflow_hidden()
.bg(cx.theme().tab_bar)
.child(self.panel_group.clone())
}
}

201
crates/ui/src/dock/state.rs Normal file
View File

@@ -0,0 +1,201 @@
use gpui::{AppContext, Axis, Pixels, View, VisualContext as _, WeakView, WindowContext};
use itertools::Itertools as _;
use serde::{Deserialize, Serialize};
use super::{
invalid_panel::InvalidPanel, Dock, DockArea, DockItem, DockPlacement, Panel, PanelRegistry,
};
/// Used to serialize and deserialize the DockArea
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
pub struct DockAreaState {
/// The version is used to mark this persisted state is compatible with the current version
/// For example, some times we many totally changed the structure of the Panel,
/// then we can compare the version to decide whether we can use the state or ignore.
#[serde(default)]
pub version: Option<usize>,
pub center: DockItemState,
pub left_dock: Option<DockState>,
pub right_dock: Option<DockState>,
pub bottom_dock: Option<DockState>,
}
/// Used to serialize and deserialize the Dock
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DockState {
panel: DockItemState,
placement: DockPlacement,
size: Pixels,
open: bool,
}
impl DockState {
pub fn new(dock: View<Dock>, cx: &AppContext) -> Self {
let dock = dock.read(cx);
Self {
placement: dock.placement,
size: dock.size,
open: dock.open,
panel: dock.panel.view().dump(cx),
}
}
/// Convert the DockState to Dock
pub fn to_dock(&self, dock_area: WeakView<DockArea>, cx: &mut WindowContext) -> View<Dock> {
let item = self.panel.to_item(dock_area.clone(), cx);
cx.new_view(|cx| {
Dock::from_state(
dock_area.clone(),
self.placement,
self.size,
item,
self.open,
cx,
)
})
}
}
/// Used to serialize and deserialize the DockerItem
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DockItemState {
pub panel_name: String,
pub children: Vec<DockItemState>,
pub info: DockItemInfo,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DockItemInfo {
#[serde(rename = "stack")]
Stack {
sizes: Vec<Pixels>,
/// The axis of the stack, 0 is horizontal, 1 is vertical
axis: usize,
},
#[serde(rename = "tabs")]
Tabs { active_index: usize },
#[serde(rename = "panel")]
Panel(serde_json::Value),
}
impl DockItemInfo {
pub fn stack(sizes: Vec<Pixels>, axis: Axis) -> Self {
Self::Stack {
sizes,
axis: if axis == Axis::Horizontal { 0 } else { 1 },
}
}
pub fn tabs(active_index: usize) -> Self {
Self::Tabs { active_index }
}
pub fn panel(value: serde_json::Value) -> Self {
Self::Panel(value)
}
pub fn axis(&self) -> Option<Axis> {
match self {
Self::Stack { axis, .. } => Some(if *axis == 0 {
Axis::Horizontal
} else {
Axis::Vertical
}),
_ => None,
}
}
pub fn sizes(&self) -> Option<&Vec<Pixels>> {
match self {
Self::Stack { sizes, .. } => Some(sizes),
_ => None,
}
}
pub fn active_index(&self) -> Option<usize> {
match self {
Self::Tabs { active_index } => Some(*active_index),
_ => None,
}
}
}
impl Default for DockItemState {
fn default() -> Self {
Self {
panel_name: "".to_string(),
children: Vec::new(),
info: DockItemInfo::Panel(serde_json::Value::Null),
}
}
}
impl DockItemState {
pub fn new<P: Panel>(panel: &P) -> Self {
Self {
panel_name: panel.panel_name().to_string(),
..Default::default()
}
}
pub fn add_child(&mut self, panel: DockItemState) {
self.children.push(panel);
}
pub fn to_item(&self, dock_area: WeakView<DockArea>, cx: &mut WindowContext) -> DockItem {
let info = self.info.clone();
let items: Vec<DockItem> = self
.children
.iter()
.map(|child| child.to_item(dock_area.clone(), cx))
.collect();
match info {
DockItemInfo::Stack { sizes, axis } => {
let axis = if axis == 0 {
Axis::Horizontal
} else {
Axis::Vertical
};
let sizes = sizes.iter().map(|s| Some(*s)).collect_vec();
DockItem::split_with_sizes(axis, items, sizes, &dock_area, cx)
}
DockItemInfo::Tabs { active_index } => {
if items.len() == 1 {
return items[0].clone();
}
let items = items
.iter()
.flat_map(|item| match item {
DockItem::Tabs { items, .. } => items.clone(),
_ => {
unreachable!("Invalid DockItem type in DockItemInfo::Tabs")
}
})
.collect_vec();
DockItem::tabs(items, Some(active_index), &dock_area, cx)
}
DockItemInfo::Panel(_) => {
let view = if let Some(f) = cx
.global::<PanelRegistry>()
.items
.get(&self.panel_name)
.cloned()
{
f(dock_area.clone(), self, &info, cx)
} else {
// Show an invalid panel if the panel is not registered.
Box::new(
cx.new_view(|cx| InvalidPanel::new(&self.panel_name, self.clone(), cx)),
)
};
DockItem::tabs(vec![view.into()], None, &dock_area, cx)
}
}
}
}

View File

@@ -0,0 +1,888 @@
use std::sync::Arc;
use gpui::{
div, prelude::FluentBuilder, px, rems, AnchorCorner, AppContext, DefiniteLength, DismissEvent,
DragMoveEvent, Empty, Entity, EventEmitter, FocusHandle, FocusableView,
InteractiveElement as _, IntoElement, ParentElement, Pixels, Render, ScrollHandle,
SharedString, StatefulInteractiveElement, Styled, View, ViewContext, VisualContext as _,
WeakView, WindowContext,
};
use crate::{
button::{Button, ButtonVariants as _},
dock::DockItemInfo,
h_flex,
popup_menu::{PopupMenu, PopupMenuExt},
tab::{Tab, TabBar},
theme::ActiveTheme,
v_flex, AxisExt, IconName, Placement, Selectable, Sizable,
};
use super::{
ClosePanel, DockArea, DockItemState, DockPlacement, Panel, PanelEvent, PanelStyle, PanelView,
StackPanel, ToggleZoom,
};
#[derive(Clone, Copy)]
struct TabState {
closeable: bool,
zoomable: bool,
draggable: bool,
droppable: bool,
}
#[derive(Clone)]
pub(crate) struct DragPanel {
pub(crate) panel: Arc<dyn PanelView>,
pub(crate) tab_panel: View<TabPanel>,
}
impl DragPanel {
pub(crate) fn new(panel: Arc<dyn PanelView>, tab_panel: View<TabPanel>) -> Self {
Self { panel, tab_panel }
}
}
impl Render for DragPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.id("drag-panel")
.cursor_grab()
.py_1()
.px_3()
.w_24()
.overflow_hidden()
.whitespace_nowrap()
.border_1()
.border_color(cx.theme().border)
.rounded_md()
.text_color(cx.theme().tab_foreground)
.bg(cx.theme().tab_active)
.opacity(0.75)
.child(self.panel.title(cx))
}
}
pub struct TabPanel {
focus_handle: FocusHandle,
dock_area: WeakView<DockArea>,
/// The stock_panel can be None, if is None, that means the panels can't be split or move
stack_panel: Option<WeakView<StackPanel>>,
pub(crate) panels: Vec<Arc<dyn PanelView>>,
pub(crate) active_ix: usize,
/// If this is true, the Panel closeable will follow the active panel's closeable,
/// otherwise this TabPanel will not able to close
pub(crate) closeable: bool,
tab_bar_scroll_handle: ScrollHandle,
is_zoomed: bool,
is_collapsed: bool,
/// When drag move, will get the placement of the panel to be split
will_split_placement: Option<Placement>,
}
impl Panel for TabPanel {
fn panel_name(&self) -> &'static str {
"TabPanel"
}
fn title(&self, cx: &WindowContext) -> gpui::AnyElement {
self.active_panel()
.map(|panel| panel.title(cx))
.unwrap_or("Empty Tab".into_any_element())
}
fn closeable(&self, cx: &WindowContext) -> bool {
if !self.closeable {
return false;
}
self.active_panel()
.map(|panel| panel.closeable(cx))
.unwrap_or(false)
}
fn zoomable(&self, cx: &WindowContext) -> bool {
self.active_panel()
.map(|panel| panel.zoomable(cx))
.unwrap_or(false)
}
fn popup_menu(&self, menu: PopupMenu, cx: &WindowContext) -> PopupMenu {
if let Some(panel) = self.active_panel() {
panel.popup_menu(menu, cx)
} else {
menu
}
}
fn toolbar_buttons(&self, cx: &WindowContext) -> Vec<Button> {
if let Some(panel) = self.active_panel() {
panel.toolbar_buttons(cx)
} else {
vec![]
}
}
fn dump(&self, cx: &AppContext) -> DockItemState {
let mut state = DockItemState::new(self);
for panel in self.panels.iter() {
state.add_child(panel.dump(cx));
state.info = DockItemInfo::tabs(self.active_ix);
}
state
}
}
impl TabPanel {
pub fn new(
stack_panel: Option<WeakView<StackPanel>>,
dock_area: WeakView<DockArea>,
cx: &mut ViewContext<Self>,
) -> Self {
Self {
focus_handle: cx.focus_handle(),
dock_area,
stack_panel,
panels: Vec::new(),
active_ix: 0,
tab_bar_scroll_handle: ScrollHandle::new(),
will_split_placement: None,
is_zoomed: false,
is_collapsed: false,
closeable: true,
}
}
pub(super) fn set_parent(&mut self, view: WeakView<StackPanel>) {
self.stack_panel = Some(view);
}
/// Return current active_panel View
pub fn active_panel(&self) -> Option<Arc<dyn PanelView>> {
self.panels.get(self.active_ix).cloned()
}
fn set_active_ix(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
self.active_ix = ix;
self.tab_bar_scroll_handle.scroll_to_item(ix);
self.focus_active_panel(cx);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Add a panel to the end of the tabs
pub fn add_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
assert_ne!(
panel.panel_name(cx),
"StackPanel",
"can not allows add `StackPanel` to `TabPanel`"
);
if self
.panels
.iter()
.any(|p| p.view().entity_id() == panel.view().entity_id())
{
return;
}
self.panels.push(panel);
// set the active panel to the new panel
self.set_active_ix(self.panels.len() - 1, cx);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Add panel to try to split
pub fn add_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
cx: &mut ViewContext<Self>,
) {
cx.spawn(|view, mut cx| async move {
cx.update(|cx| {
view.update(cx, |view, cx| {
view.will_split_placement = Some(placement);
view.split_panel(panel, placement, size, cx)
})
.ok()
})
.ok()
})
.detach();
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
fn insert_panel_at(
&mut self,
panel: Arc<dyn PanelView>,
ix: usize,
cx: &mut ViewContext<Self>,
) {
if self
.panels
.iter()
.any(|p| p.view().entity_id() == panel.view().entity_id())
{
return;
}
self.panels.insert(ix, panel);
self.set_active_ix(ix, cx);
cx.emit(PanelEvent::LayoutChanged);
cx.notify();
}
/// Remove a panel from the tab panel
pub fn remove_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
self.detach_panel(panel, cx);
self.remove_self_if_empty(cx);
cx.emit(PanelEvent::ZoomOut);
cx.emit(PanelEvent::LayoutChanged);
}
fn detach_panel(&mut self, panel: Arc<dyn PanelView>, cx: &mut ViewContext<Self>) {
let panel_view = panel.view();
self.panels.retain(|p| p.view() != panel_view);
if self.active_ix >= self.panels.len() {
self.set_active_ix(self.panels.len().saturating_sub(1), cx)
}
}
/// Check to remove self from the parent StackPanel, if there is no panel left
fn remove_self_if_empty(&self, cx: &mut ViewContext<Self>) {
if !self.panels.is_empty() {
return;
}
let tab_view = cx.view().clone();
if let Some(stack_panel) = self.stack_panel.as_ref() {
_ = stack_panel.update(cx, |view, cx| {
view.remove_panel(Arc::new(tab_view), cx);
});
}
}
pub(super) fn set_collapsed(&mut self, collapsed: bool, cx: &mut ViewContext<Self>) {
self.is_collapsed = collapsed;
cx.notify();
}
fn is_locked(&self, cx: &AppContext) -> bool {
let Some(dock_area) = self.dock_area.upgrade() else {
return true;
};
if dock_area.read(cx).is_locked() {
return true;
}
if self.is_zoomed {
return true;
}
self.stack_panel.is_none()
}
/// Return true if self or parent only have last panel.
fn is_last_panel(&self, cx: &AppContext) -> bool {
if let Some(parent) = &self.stack_panel {
if let Some(stack_panel) = parent.upgrade() {
if !stack_panel.read(cx).is_last_panel(cx) {
return false;
}
}
}
self.panels.len() <= 1
}
/// Return true if the tab panel is draggable.
///
/// E.g. if the parent and self only have one panel, it is not draggable.
fn draggable(&self, cx: &AppContext) -> bool {
!self.is_locked(cx) && !self.is_last_panel(cx)
}
/// Return true if the tab panel is droppable.
///
/// E.g. if the tab panel is locked, it is not droppable.
fn droppable(&self, cx: &AppContext) -> bool {
!self.is_locked(cx)
}
fn render_toolbar(&self, state: TabState, cx: &mut ViewContext<Self>) -> impl IntoElement {
let is_zoomed = self.is_zoomed && state.zoomable;
let view = cx.view().clone();
let build_popup_menu = move |this, cx: &WindowContext| view.read(cx).popup_menu(this, cx);
// TODO: Do not show MenuButton if there is no menu items
h_flex()
.gap_2()
.occlude()
.items_center()
.children(
self.toolbar_buttons(cx)
.into_iter()
.map(|btn| btn.xsmall().ghost()),
)
.when(self.is_zoomed, |this| {
this.child(
Button::new("zoom")
.icon(IconName::Minimize)
.xsmall()
.ghost()
.tooltip("Zoom Out")
.on_click(
cx.listener(|view, _, cx| view.on_action_toggle_zoom(&ToggleZoom, cx)),
),
)
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.xsmall()
.ghost()
.popup_menu(move |this, cx| {
build_popup_menu(this, cx)
.when(state.zoomable, |this| {
let name = if is_zoomed { "Zoom Out" } else { "Zoom In" };
this.separator().menu(name, Box::new(ToggleZoom))
})
.when(state.closeable, |this| {
this.separator().menu("Close", Box::new(ClosePanel))
})
})
.anchor(AnchorCorner::TopRight),
)
}
fn render_dock_toggle_button(
&self,
placement: DockPlacement,
cx: &mut ViewContext<Self>,
) -> Option<impl IntoElement> {
if self.is_zoomed {
return None;
}
let dock_area = self.dock_area.upgrade()?.read(cx);
if !dock_area.is_dock_collapsible(placement, cx) {
return None;
}
let view_entity_id = cx.view().entity_id();
let toggle_button_panels = dock_area.toggle_button_panels;
// Check if current TabPanel's entity_id matches the one stored in DockArea for this placement
if !match placement {
DockPlacement::Left => {
dock_area.left_dock.is_some() && toggle_button_panels.left == Some(view_entity_id)
}
DockPlacement::Right => {
dock_area.right_dock.is_some() && toggle_button_panels.right == Some(view_entity_id)
}
DockPlacement::Bottom => {
dock_area.bottom_dock.is_some()
&& toggle_button_panels.bottom == Some(view_entity_id)
}
DockPlacement::Center => unreachable!(),
} {
return None;
}
let is_open = dock_area.is_dock_open(placement, cx);
let icon = match placement {
DockPlacement::Left => {
if is_open {
IconName::PanelLeft
} else {
IconName::PanelLeftOpen
}
}
DockPlacement::Right => {
if is_open {
IconName::PanelRight
} else {
IconName::PanelRightOpen
}
}
DockPlacement::Bottom => {
if is_open {
IconName::PanelBottom
} else {
IconName::PanelBottomOpen
}
}
DockPlacement::Center => unreachable!(),
};
Some(
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
.icon(icon)
.xsmall()
.ghost()
.tooltip(match is_open {
true => "Collapse",
false => "Expand",
})
.on_click(cx.listener({
let dock_area = self.dock_area.clone();
move |_, _, cx| {
_ = dock_area.update(cx, |dock_area, cx| {
dock_area.toggle_dock(placement, cx);
});
}
})),
)
}
fn render_title_bar(&self, state: TabState, cx: &mut ViewContext<Self>) -> impl IntoElement {
let view = cx.view().clone();
let Some(dock_area) = self.dock_area.upgrade() else {
return div().into_any_element();
};
let panel_style = dock_area.read(cx).panel_style;
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, cx);
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, cx);
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, cx);
if self.panels.len() == 1 && panel_style == PanelStyle::Default {
let panel = self.panels.get(0).unwrap();
let title_style = panel.title_style(cx);
return h_flex()
.justify_between()
.items_center()
.line_height(rems(1.0))
.h(px(30.))
.py_2()
.px_3()
.when(left_dock_button.is_some(), |this| this.pl_2())
.when(right_dock_button.is_some(), |this| this.pr_2())
.when_some(title_style, |this, theme| {
this.bg(theme.background).text_color(theme.foreground)
})
.when(
left_dock_button.is_some() || bottom_dock_button.is_some(),
|this| {
this.child(
h_flex()
.flex_shrink_0()
.mr_1()
.gap_1()
.children(left_dock_button)
.children(bottom_dock_button),
)
},
)
.child(
div()
.id("tab")
.flex_1()
.min_w_16()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.child(panel.title(cx))
.when(state.draggable, |this| {
this.on_drag(
DragPanel {
panel: panel.clone(),
tab_panel: view,
},
|drag, _, cx| {
cx.stop_propagation();
cx.new_view(|_| drag.clone())
},
)
}),
)
.child(
h_flex()
.flex_shrink_0()
.ml_1()
.gap_1()
.child(self.render_toolbar(state, cx))
.children(right_dock_button),
)
.into_any_element();
}
let tabs_count = self.panels.len();
TabBar::new("tab-bar")
.track_scroll(self.tab_bar_scroll_handle.clone())
.when(
left_dock_button.is_some() || bottom_dock_button.is_some(),
|this| {
this.prefix(
h_flex()
.items_center()
.top_0()
// Right -1 for avoid border overlap with the first tab
.right(-px(1.))
.border_r_1()
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.px_2()
.children(left_dock_button)
.children(bottom_dock_button),
)
},
)
.children(self.panels.iter().enumerate().map(|(ix, panel)| {
let mut active = ix == self.active_ix;
// Always not show active tab style, if the panel is collapsed
if self.is_collapsed {
active = false;
}
Tab::new(("tab", ix), panel.title(cx))
.py_2()
.selected(active)
.on_click(cx.listener(move |view, _, cx| {
view.set_active_ix(ix, cx);
}))
.when(state.draggable, |this| {
this.on_drag(
DragPanel::new(panel.clone(), view.clone()),
|drag, _, cx| {
cx.stop_propagation();
cx.new_view(|_| drag.clone())
},
)
})
.when(state.droppable, |this| {
this.drag_over::<DragPanel>(|this, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().drag_border)
})
.on_drop(cx.listener(
move |this, drag: &DragPanel, cx| {
this.will_split_placement = None;
this.on_drop(drag, Some(ix), cx)
},
))
})
}))
.child(
// empty space to allow move to last tab right
div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow()
.min_w_16()
.when(state.droppable, |this| {
this.drag_over::<DragPanel>(|this, _, cx| this.bg(cx.theme().drop_target))
.on_drop(cx.listener(move |this, drag: &DragPanel, cx| {
this.will_split_placement = None;
let ix = if drag.tab_panel == view {
Some(tabs_count - 1)
} else {
None
};
this.on_drop(drag, ix, cx)
}))
}),
)
.suffix(
h_flex()
.items_center()
.top_0()
.right_0()
.border_l_1()
.border_b_1()
.h_full()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.px_2()
.gap_1()
.child(self.render_toolbar(state, cx))
.when_some(right_dock_button, |this, btn| this.child(btn)),
)
.into_any_element()
}
fn render_active_panel(&self, state: TabState, cx: &mut ViewContext<Self>) -> impl IntoElement {
self.active_panel()
.map(|panel| {
div()
.id("tab-content")
.group("")
.overflow_y_scroll()
.overflow_x_hidden()
.flex_1()
.child(panel.view())
.when(state.droppable, |this| {
this.on_drag_move(cx.listener(Self::on_panel_drag_move))
.child(
div()
.invisible()
.absolute()
.bg(cx.theme().drop_target)
.map(|this| match self.will_split_placement {
Some(placement) => {
let size = DefiniteLength::Fraction(0.35);
match placement {
Placement::Left => {
this.left_0().top_0().bottom_0().w(size)
}
Placement::Right => {
this.right_0().top_0().bottom_0().w(size)
}
Placement::Top => {
this.top_0().left_0().right_0().h(size)
}
Placement::Bottom => {
this.bottom_0().left_0().right_0().h(size)
}
}
}
None => this.top_0().left_0().size_full(),
})
.group_drag_over::<DragPanel>("", |this| this.visible())
.on_drop(cx.listener(|this, drag: &DragPanel, cx| {
this.on_drop(drag, None, cx)
})),
)
})
.into_any_element()
})
.unwrap_or(Empty {}.into_any_element())
}
/// Calculate the split direction based on the current mouse position
fn on_panel_drag_move(&mut self, drag: &DragMoveEvent<DragPanel>, cx: &mut ViewContext<Self>) {
let bounds = drag.bounds;
let position = drag.event.position;
// Check the mouse position to determine the split direction
if position.x < bounds.left() + bounds.size.width * 0.35 {
self.will_split_placement = Some(Placement::Left);
} else if position.x > bounds.left() + bounds.size.width * 0.65 {
self.will_split_placement = Some(Placement::Right);
} else if position.y < bounds.top() + bounds.size.height * 0.35 {
self.will_split_placement = Some(Placement::Top);
} else if position.y > bounds.top() + bounds.size.height * 0.65 {
self.will_split_placement = Some(Placement::Bottom);
} else {
// center to merge into the current tab
self.will_split_placement = None;
}
cx.notify()
}
fn on_drop(&mut self, drag: &DragPanel, ix: Option<usize>, cx: &mut ViewContext<Self>) {
let panel = drag.panel.clone();
let is_same_tab = drag.tab_panel == *cx.view();
// If target is same tab, and it is only one panel, do nothing.
if is_same_tab && ix.is_none() {
if self.will_split_placement.is_none() {
return;
} else if self.panels.len() == 1 {
return;
}
}
// Here is looks like remove_panel on a same item, but it difference.
//
// We must to split it to remove_panel, unless it will be crash by error:
// Cannot update ui::dock::tab_panel::TabPanel while it is already being updated
if is_same_tab {
self.detach_panel(panel.clone(), cx);
} else {
let _ = drag.tab_panel.update(cx, |view, cx| {
view.detach_panel(panel.clone(), cx);
view.remove_self_if_empty(cx);
});
}
// Insert into new tabs
if let Some(placement) = self.will_split_placement {
self.split_panel(panel, placement, None, cx);
} else if let Some(ix) = ix {
self.insert_panel_at(panel, ix, cx)
} else {
self.add_panel(panel, cx)
}
self.remove_self_if_empty(cx);
cx.emit(PanelEvent::LayoutChanged);
}
/// Add panel with split placement
fn split_panel(
&self,
panel: Arc<dyn PanelView>,
placement: Placement,
size: Option<Pixels>,
cx: &mut ViewContext<Self>,
) {
let dock_area = self.dock_area.clone();
// wrap the panel in a TabPanel
let new_tab_panel = cx.new_view(|cx| Self::new(None, dock_area.clone(), cx));
new_tab_panel.update(cx, |view, cx| {
view.add_panel(panel, cx);
});
let stack_panel = match self.stack_panel.as_ref().and_then(|panel| panel.upgrade()) {
Some(panel) => panel,
None => return,
};
let parent_axis = stack_panel.read(cx).axis;
let ix = stack_panel
.read(cx)
.index_of_panel(Arc::new(cx.view().clone()))
.unwrap_or_default();
if parent_axis.is_vertical() && placement.is_vertical() {
stack_panel.update(cx, |view, cx| {
view.insert_panel_at(
Arc::new(new_tab_panel),
ix,
placement,
size,
dock_area.clone(),
cx,
);
});
} else if parent_axis.is_horizontal() && placement.is_horizontal() {
stack_panel.update(cx, |view, cx| {
view.insert_panel_at(
Arc::new(new_tab_panel),
ix,
placement,
size,
dock_area.clone(),
cx,
);
});
} else {
// 1. Create new StackPanel with new axis
// 2. Move cx.view() from parent StackPanel to the new StackPanel
// 3. Add the new TabPanel to the new StackPanel at the correct index
// 4. Add new StackPanel to the parent StackPanel at the correct index
let tab_panel = cx.view().clone();
// Try to use the old stack panel, not just create a new one, to avoid too many nested stack panels
let new_stack_panel = if stack_panel.read(cx).panels_len() <= 1 {
stack_panel.update(cx, |view, cx| {
view.remove_all_panels(cx);
view.set_axis(placement.axis(), cx);
});
stack_panel.clone()
} else {
cx.new_view(|cx| {
let mut panel = StackPanel::new(placement.axis(), cx);
panel.parent = Some(stack_panel.downgrade());
panel
})
};
new_stack_panel.update(cx, |view, cx| match placement {
Placement::Left | Placement::Top => {
view.add_panel(Arc::new(new_tab_panel), size, dock_area.clone(), cx);
view.add_panel(Arc::new(tab_panel.clone()), None, dock_area.clone(), cx);
}
Placement::Right | Placement::Bottom => {
view.add_panel(Arc::new(tab_panel.clone()), None, dock_area.clone(), cx);
view.add_panel(Arc::new(new_tab_panel), size, dock_area.clone(), cx);
}
});
if stack_panel != new_stack_panel {
stack_panel.update(cx, |view, cx| {
view.replace_panel(Arc::new(tab_panel.clone()), new_stack_panel.clone(), cx);
});
}
cx.spawn(|_, mut cx| async move {
cx.update(|cx| tab_panel.update(cx, |view, cx| view.remove_self_if_empty(cx)))
})
.detach()
}
cx.emit(PanelEvent::LayoutChanged);
}
fn focus_active_panel(&self, cx: &mut ViewContext<Self>) {
if let Some(active_panel) = self.active_panel() {
active_panel.focus_handle(cx).focus(cx);
}
}
fn on_action_toggle_zoom(&mut self, _: &ToggleZoom, cx: &mut ViewContext<Self>) {
if !self.zoomable(cx) {
return;
}
if !self.is_zoomed {
cx.emit(PanelEvent::ZoomIn)
} else {
cx.emit(PanelEvent::ZoomOut)
}
self.is_zoomed = !self.is_zoomed;
}
fn on_action_close_panel(&mut self, _: &ClosePanel, cx: &mut ViewContext<Self>) {
if let Some(panel) = self.active_panel() {
self.remove_panel(panel, cx);
}
}
}
impl FocusableView for TabPanel {
fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle {
if let Some(active_panel) = self.active_panel() {
active_panel.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
impl EventEmitter<DismissEvent> for TabPanel {}
impl EventEmitter<PanelEvent> for TabPanel {}
impl Render for TabPanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl gpui::IntoElement {
let focus_handle = self.focus_handle(cx);
let mut state = TabState {
closeable: self.closeable(cx),
draggable: self.draggable(cx),
droppable: self.droppable(cx),
zoomable: self.zoomable(cx),
};
if !state.draggable {
state.closeable = false;
}
v_flex()
.id("tab-panel")
.track_focus(&focus_handle)
.on_action(cx.listener(Self::on_action_toggle_zoom))
.on_action(cx.listener(Self::on_action_close_panel))
.size_full()
.overflow_hidden()
.bg(cx.theme().background)
.child(self.render_title_bar(state, cx))
.child(self.render_active_panel(state, cx))
}
}

244
crates/ui/src/drawer.rs Normal file
View File

@@ -0,0 +1,244 @@
use std::{rc::Rc, time::Duration};
use gpui::{
actions, anchored, div, point, prelude::FluentBuilder as _, px, Animation, AnimationExt as _,
AnyElement, AppContext, ClickEvent, DefiniteLength, DismissEvent, Div, EventEmitter,
FocusHandle, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, ParentElement,
Pixels, RenderOnce, Styled, WindowContext,
};
use crate::{
button::{Button, ButtonVariants as _},
h_flex,
modal::overlay_color,
root::ContextModal as _,
scroll::ScrollbarAxis,
theme::ActiveTheme,
title_bar::TITLE_BAR_HEIGHT,
v_flex, IconName, Placement, Sizable, StyledExt as _,
};
actions!(drawer, [Escape]);
const CONTEXT: &str = "Drawer";
pub fn init(cx: &mut AppContext) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
}
#[derive(IntoElement)]
pub struct Drawer {
pub(crate) focus_handle: FocusHandle,
placement: Placement,
size: DefiniteLength,
resizable: bool,
on_close: Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
title: Option<AnyElement>,
footer: Option<AnyElement>,
content: Div,
margin_top: Pixels,
overlay: bool,
}
impl Drawer {
pub fn new(cx: &mut WindowContext) -> Self {
Self {
focus_handle: cx.focus_handle(),
placement: Placement::Right,
size: DefiniteLength::Absolute(px(350.).into()),
resizable: true,
title: None,
footer: None,
content: v_flex().px_4().py_3(),
margin_top: TITLE_BAR_HEIGHT,
overlay: true,
on_close: Rc::new(|_, _| {}),
}
}
/// Sets the title of the drawer.
pub fn title(mut self, title: impl IntoElement) -> Self {
self.title = Some(title.into_any_element());
self
}
/// Set the footer of the drawer.
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
/// Sets the size of the drawer, default is 350px.
pub fn size(mut self, size: impl Into<DefiniteLength>) -> Self {
self.size = size.into();
self
}
/// Sets the margin top of the drawer, default is 0px.
///
/// This is used to let Drawer be placed below a Windows Title, you can give the height of the title bar.
pub fn margin_top(mut self, top: Pixels) -> Self {
self.margin_top = top;
self
}
/// Sets the placement of the drawer, default is `Placement::Right`.
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
/// Sets the placement of the drawer, default is `Placement::Right`.
pub fn set_placement(&mut self, placement: Placement) {
self.placement = placement;
}
/// Sets whether the drawer is resizable, default is `true`.
pub fn resizable(mut self, resizable: bool) -> Self {
self.resizable = resizable;
self
}
/// Set whether the drawer should have an overlay, default is `true`.
pub fn overlay(mut self, overlay: bool) -> Self {
self.overlay = overlay;
self
}
/// Listen to the close event of the drawer.
pub fn on_close(
mut self,
on_close: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_close = Rc::new(on_close);
self
}
}
impl EventEmitter<DismissEvent> for Drawer {}
impl ParentElement for Drawer {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.content.extend(elements);
}
}
impl Styled for Drawer {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.content.style()
}
}
impl RenderOnce for Drawer {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let placement = self.placement;
let titlebar_height = self.margin_top;
let size = cx.viewport_size();
let on_close = self.on_close.clone();
anchored()
.position(point(px(0.), titlebar_height))
.snap_to_window()
.child(
div()
.occlude()
.w(size.width)
.h(size.height - titlebar_height)
.bg(overlay_color(self.overlay, cx))
.when(self.overlay, |this| {
this.on_mouse_down(MouseButton::Left, {
let on_close = self.on_close.clone();
move |_, cx| {
on_close(&ClickEvent::default(), cx);
cx.close_drawer();
}
})
})
.child(
v_flex()
.id("drawer")
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.on_action({
let on_close = self.on_close.clone();
move |_: &Escape, cx| {
on_close(&ClickEvent::default(), cx);
cx.close_drawer();
}
})
.absolute()
.occlude()
.bg(cx.theme().background)
.border_color(cx.theme().border)
.shadow_xl()
.map(|this| {
// Set the size of the drawer.
if placement.is_horizontal() {
this.h_full().w(self.size)
} else {
this.w_full().h(self.size)
}
})
.map(|this| match self.placement {
Placement::Top => this.top_0().left_0().right_0().border_b_1(),
Placement::Right => this.top_0().right_0().bottom_0().border_l_1(),
Placement::Bottom => {
this.bottom_0().left_0().right_0().border_t_1()
}
Placement::Left => this.top_0().left_0().bottom_0().border_r_1(),
})
.child(
// TitleBar
h_flex()
.justify_between()
.px_4()
.py_2()
.w_full()
.child(self.title.unwrap_or(div().into_any_element()))
.child(
Button::new("close")
.small()
.ghost()
.icon(IconName::Close)
.on_click(move |_, cx| {
on_close(&ClickEvent::default(), cx);
cx.close_drawer();
}),
),
)
.child(
// Body
div().flex_1().overflow_hidden().child(
v_flex()
.scrollable(
cx.parent_view_id().unwrap_or_default(),
ScrollbarAxis::Vertical,
)
.child(self.content),
),
)
.when_some(self.footer, |this, footer| {
// Footer
this.child(
h_flex()
.justify_between()
.px_4()
.py_3()
.w_full()
.child(footer),
)
})
.with_animation(
"slide",
Animation::new(Duration::from_secs_f64(0.15)),
move |this, delta| {
let y = px(-100.) + delta * px(100.);
this.map(|this| match placement {
Placement::Top => this.top(y),
Placement::Right => this.right(y),
Placement::Bottom => this.bottom(y),
Placement::Left => this.left(y),
})
},
),
),
)
}
}

713
crates/ui/src/dropdown.rs Normal file
View File

@@ -0,0 +1,713 @@
use gpui::{
actions, anchored, canvas, deferred, div, prelude::FluentBuilder, px, rems, AnyElement,
AppContext, Bounds, ClickEvent, DismissEvent, ElementId, EventEmitter, FocusHandle,
FocusableView, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, Pixels,
Render, SharedString, StatefulInteractiveElement, Styled, Task, View, ViewContext,
VisualContext, WeakView, WindowContext,
};
use crate::{
h_flex,
input::ClearButton,
list::{self, List, ListDelegate, ListItem},
theme::ActiveTheme,
v_flex, Disableable, Icon, IconName, Sizable, Size, StyleSized, StyledExt,
};
actions!(dropdown, [Up, Down, Enter, Escape]);
const CONTEXT: &str = "Dropdown";
pub fn init(cx: &mut AppContext) {
cx.bind_keys([
KeyBinding::new("up", Up, Some(CONTEXT)),
KeyBinding::new("down", Down, Some(CONTEXT)),
KeyBinding::new("enter", Enter, Some(CONTEXT)),
KeyBinding::new("escape", Escape, Some(CONTEXT)),
])
}
/// A trait for items that can be displayed in a dropdown.
pub trait DropdownItem {
type Value: Clone;
fn title(&self) -> SharedString;
fn value(&self) -> &Self::Value;
}
impl DropdownItem for String {
type Value = Self;
fn title(&self) -> SharedString {
SharedString::from(self.to_string())
}
fn value(&self) -> &Self::Value {
&self
}
}
impl DropdownItem for SharedString {
type Value = Self;
fn title(&self) -> SharedString {
SharedString::from(self.to_string())
}
fn value(&self) -> &Self::Value {
&self
}
}
pub trait DropdownDelegate: Sized {
type Item: DropdownItem;
fn len(&self) -> usize;
fn is_empty(&self) -> bool {
self.len() == 0
}
fn get(&self, ix: usize) -> Option<&Self::Item>;
fn position<V>(&self, value: &V) -> Option<usize>
where
Self::Item: DropdownItem<Value = V>,
V: PartialEq,
{
(0..self.len()).find(|&i| self.get(i).map_or(false, |item| item.value() == value))
}
fn can_search(&self) -> bool {
false
}
fn perform_search(&mut self, _query: &str, _cx: &mut ViewContext<Dropdown<Self>>) -> Task<()> {
Task::Ready(Some(()))
}
}
impl<T: DropdownItem> DropdownDelegate for Vec<T> {
type Item = T;
fn len(&self) -> usize {
self.len()
}
fn get(&self, ix: usize) -> Option<&Self::Item> {
self.as_slice().get(ix)
}
fn position<V>(&self, value: &V) -> Option<usize>
where
Self::Item: DropdownItem<Value = V>,
V: PartialEq,
{
self.iter().position(|v| v.value() == value)
}
}
struct DropdownListDelegate<D: DropdownDelegate + 'static> {
delegate: D,
dropdown: WeakView<Dropdown<D>>,
selected_index: Option<usize>,
}
impl<D> ListDelegate for DropdownListDelegate<D>
where
D: DropdownDelegate + 'static,
{
type Item = ListItem;
fn items_count(&self, _: &AppContext) -> usize {
self.delegate.len()
}
fn confirmed_index(&self, _: &AppContext) -> Option<usize> {
self.selected_index
}
fn render_item(&self, ix: usize, cx: &mut gpui::ViewContext<List<Self>>) -> Option<Self::Item> {
let selected = self
.selected_index
.map_or(false, |selected_index| selected_index == ix);
let size = self
.dropdown
.upgrade()
.map_or(Size::Medium, |dropdown| dropdown.read(cx).size);
if let Some(item) = self.delegate.get(ix) {
let list_item = ListItem::new(("list-item", ix))
.check_icon(IconName::Check)
.cursor_pointer()
.selected(selected)
.input_text_size(size)
.list_size(size)
.child(div().whitespace_nowrap().child(item.title().to_string()));
Some(list_item)
} else {
None
}
}
fn cancel(&mut self, cx: &mut ViewContext<List<Self>>) {
let dropdown = self.dropdown.clone();
cx.defer(move |_, cx| {
_ = dropdown.update(cx, |this, cx| {
this.open = false;
this.focus(cx);
});
});
}
fn confirm(&mut self, ix: Option<usize>, cx: &mut ViewContext<List<Self>>) {
self.selected_index = ix;
let selected_value = self
.selected_index
.and_then(|ix| self.delegate.get(ix))
.map(|item| item.value().clone());
let dropdown = self.dropdown.clone();
cx.defer(move |_, cx| {
_ = dropdown.update(cx, |this, cx| {
cx.emit(DropdownEvent::Confirm(selected_value.clone()));
this.selected_value = selected_value;
this.open = false;
this.focus(cx);
});
});
}
fn perform_search(&mut self, query: &str, cx: &mut ViewContext<List<Self>>) -> Task<()> {
self.dropdown
.upgrade()
.map_or(Task::Ready(None), |dropdown| {
dropdown.update(cx, |_, cx| self.delegate.perform_search(query, cx))
})
}
fn set_selected_index(&mut self, ix: Option<usize>, _: &mut ViewContext<List<Self>>) {
self.selected_index = ix;
}
fn render_empty(&self, cx: &mut ViewContext<List<Self>>) -> impl IntoElement {
if let Some(empty) = self
.dropdown
.upgrade()
.and_then(|dropdown| dropdown.read(cx).empty.as_ref())
{
empty(cx).into_any_element()
} else {
h_flex()
.justify_center()
.py_6()
.text_color(cx.theme().muted_foreground.opacity(0.6))
.child(Icon::new(IconName::Inbox).size(px(28.)))
.into_any_element()
}
}
}
pub enum DropdownEvent<D: DropdownDelegate + 'static> {
Confirm(Option<<D::Item as DropdownItem>::Value>),
}
/// A Dropdown element.
pub struct Dropdown<D: DropdownDelegate + 'static> {
id: ElementId,
focus_handle: FocusHandle,
list: View<List<DropdownListDelegate<D>>>,
size: Size,
icon: Option<IconName>,
open: bool,
cleanable: bool,
placeholder: Option<SharedString>,
title_prefix: Option<SharedString>,
selected_value: Option<<D::Item as DropdownItem>::Value>,
empty: Option<Box<dyn Fn(&WindowContext) -> AnyElement + 'static>>,
width: Length,
menu_width: Length,
/// Store the bounds of the input
bounds: Bounds<Pixels>,
disabled: bool,
}
pub struct SearchableVec<T> {
items: Vec<T>,
matched_items: Vec<T>,
}
impl<T: DropdownItem + Clone> SearchableVec<T> {
pub fn new(items: impl Into<Vec<T>>) -> Self {
let items = items.into();
Self {
items: items.clone(),
matched_items: items,
}
}
}
impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
type Item = T;
fn len(&self) -> usize {
self.matched_items.len()
}
fn get(&self, ix: usize) -> Option<&Self::Item> {
self.matched_items.get(ix)
}
fn position<V>(&self, value: &V) -> Option<usize>
where
Self::Item: DropdownItem<Value = V>,
V: PartialEq,
{
for (ix, item) in self.matched_items.iter().enumerate() {
if item.value() == value {
return Some(ix);
}
}
None
}
fn can_search(&self) -> bool {
true
}
fn perform_search(&mut self, query: &str, _cx: &mut ViewContext<Dropdown<Self>>) -> Task<()> {
self.matched_items = self
.items
.iter()
.filter(|item| item.title().to_lowercase().contains(&query.to_lowercase()))
.cloned()
.collect();
Task::Ready(Some(()))
}
}
impl From<Vec<SharedString>> for SearchableVec<SharedString> {
fn from(items: Vec<SharedString>) -> Self {
Self {
items: items.clone(),
matched_items: items,
}
}
}
impl<D> Dropdown<D>
where
D: DropdownDelegate + 'static,
{
pub fn new(
id: impl Into<ElementId>,
delegate: D,
selected_index: Option<usize>,
cx: &mut ViewContext<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let delegate = DropdownListDelegate {
delegate,
dropdown: cx.view().downgrade(),
selected_index,
};
let searchable = delegate.delegate.can_search();
let list = cx.new_view(|cx| {
let mut list = List::new(delegate, cx).max_h(rems(20.));
if !searchable {
list = list.no_query();
}
list
});
cx.on_blur(&list.focus_handle(cx), Self::on_blur).detach();
cx.on_blur(&focus_handle, Self::on_blur).detach();
let mut this = Self {
id: id.into(),
focus_handle,
placeholder: None,
list,
size: Size::Medium,
icon: None,
selected_value: None,
open: false,
cleanable: false,
title_prefix: None,
empty: None,
width: Length::Auto,
menu_width: Length::Auto,
bounds: Bounds::default(),
disabled: false,
};
this.set_selected_index(selected_index, cx);
this
}
/// Set the width of the dropdown input, default: Length::Auto
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
/// Set the width of the dropdown menu, default: Length::Auto
pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
self.menu_width = width.into();
self
}
/// Set the placeholder for display when dropdown value is empty.
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
/// Set the right icon for the dropdown input, instead of the default arrow icon.
pub fn icon(mut self, icon: impl Into<IconName>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set title prefix for the dropdown.
///
/// e.g.: Country: United States
///
/// You should set the label is `Country: `
pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
self.title_prefix = Some(prefix.into());
self
}
/// Set true to show the clear button when the input field is not empty.
pub fn cleanable(mut self) -> Self {
self.cleanable = true;
self
}
/// Set the disable state for the dropdown.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn set_disabled(&mut self, disabled: bool) {
self.disabled = disabled;
}
pub fn empty<E, F>(mut self, f: F) -> Self
where
E: IntoElement,
F: Fn(&WindowContext) -> E + 'static,
{
self.empty = Some(Box::new(move |cx| f(cx).into_any_element()));
self
}
pub fn set_selected_index(
&mut self,
selected_index: Option<usize>,
cx: &mut ViewContext<Self>,
) {
self.list.update(cx, |list, cx| {
list.set_selected_index(selected_index, cx);
});
self.update_selected_value(cx);
}
pub fn set_selected_value(
&mut self,
selected_value: &<D::Item as DropdownItem>::Value,
cx: &mut ViewContext<Self>,
) where
<<D as DropdownDelegate>::Item as DropdownItem>::Value: PartialEq,
{
let delegate = self.list.read(cx).delegate();
let selected_index = delegate.delegate.position(selected_value);
self.set_selected_index(selected_index, cx);
}
pub fn selected_index(&self, cx: &WindowContext) -> Option<usize> {
self.list.read(cx).selected_index()
}
fn update_selected_value(&mut self, cx: &WindowContext) {
self.selected_value = self
.selected_index(cx)
.and_then(|ix| self.list.read(cx).delegate().delegate.get(ix))
.map(|item| item.value().clone());
}
pub fn selected_value(&self) -> Option<&<D::Item as DropdownItem>::Value> {
self.selected_value.as_ref()
}
pub fn focus(&self, cx: &mut WindowContext) {
self.focus_handle.focus(cx);
}
fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
// When the dropdown and dropdown menu are both not focused, close the dropdown menu.
if self.list.focus_handle(cx).is_focused(cx) || self.focus_handle.is_focused(cx) {
return;
}
self.open = false;
cx.notify();
}
fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
if !self.open {
return;
}
self.list.focus_handle(cx).focus(cx);
cx.dispatch_action(Box::new(list::SelectPrev));
}
fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
if !self.open {
self.open = true;
}
self.list.focus_handle(cx).focus(cx);
cx.dispatch_action(Box::new(list::SelectNext));
}
fn enter(&mut self, _: &Enter, cx: &mut ViewContext<Self>) {
// Propagate the event to the parent view, for example to the Modal to support ENTER to confirm.
cx.propagate();
if !self.open {
self.open = true;
cx.notify();
} else {
self.list.focus_handle(cx).focus(cx);
cx.dispatch_action(Box::new(list::Confirm));
}
}
fn toggle_menu(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
cx.stop_propagation();
self.open = !self.open;
if self.open {
self.list.focus_handle(cx).focus(cx);
}
cx.notify();
}
fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
// Propagate the event to the parent view, for example to the Modal to support ESC to close.
cx.propagate();
self.open = false;
cx.notify();
}
fn clean(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
self.set_selected_index(None, cx);
cx.emit(DropdownEvent::Confirm(None));
}
fn display_title(&self, cx: &WindowContext) -> impl IntoElement {
let title = if let Some(selected_index) = &self.selected_index(cx) {
let title = self
.list
.read(cx)
.delegate()
.delegate
.get(*selected_index)
.map(|item| item.title().to_string())
.unwrap_or_default();
h_flex()
.when_some(self.title_prefix.clone(), |this, prefix| this.child(prefix))
.child(title.clone())
} else {
div().text_color(cx.theme().accent_foreground).child(
self.placeholder
.clone()
.unwrap_or_else(|| "Please select".into()),
)
};
title.when(self.disabled, |this| {
this.cursor_not_allowed()
.text_color(cx.theme().muted_foreground)
})
}
}
impl<D> Sizable for Dropdown<D>
where
D: DropdownDelegate + 'static,
{
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl<D> EventEmitter<DropdownEvent<D>> for Dropdown<D> where D: DropdownDelegate + 'static {}
impl<D> EventEmitter<DismissEvent> for Dropdown<D> where D: DropdownDelegate + 'static {}
impl<D> FocusableView for Dropdown<D>
where
D: DropdownDelegate + 'static,
{
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
if self.open {
self.list.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
impl<D> Render for Dropdown<D>
where
D: DropdownDelegate + 'static,
{
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let is_focused = self.focus_handle.is_focused(cx);
let show_clean = self.cleanable && self.selected_index(cx).is_some();
let view = cx.view().clone();
let bounds = self.bounds;
let allow_open = !(self.open || self.disabled);
let outline_visible = self.open || is_focused && !self.disabled;
// If the size has change, set size to self.list, to change the QueryInput size.
if self.list.read(cx).size != self.size {
self.list
.update(cx, |this, cx| this.set_size(self.size, cx))
}
div()
.id(self.id.clone())
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::up))
.on_action(cx.listener(Self::down))
.on_action(cx.listener(Self::enter))
.on_action(cx.listener(Self::escape))
.size_full()
.relative()
.input_text_size(self.size)
.child(
div()
.id("dropdown-input")
.relative()
.flex()
.items_center()
.justify_between()
.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().input)
.rounded(px(cx.theme().radius))
.when(cx.theme().shadow, |this| this.shadow_sm())
.map(|this| {
if self.disabled {
this.cursor_not_allowed()
} else {
this.cursor_pointer()
}
})
.overflow_hidden()
.input_text_size(self.size)
.map(|this| match self.width {
Length::Definite(l) => this.flex_none().w(l),
Length::Auto => this.w_full(),
})
.when(outline_visible, |this| this.outline(cx))
.input_size(self.size)
.when(allow_open, |this| {
this.on_click(cx.listener(Self::toggle_menu))
})
.child(
h_flex()
.w_full()
.items_center()
.justify_between()
.gap_1()
.child(
div()
.w_full()
.overflow_hidden()
.child(self.display_title(cx)),
)
.when(show_clean, |this| {
this.child(ClearButton::new(cx).map(|this| {
if self.disabled {
this.disabled(true)
} else {
this.on_click(cx.listener(Self::clean))
}
}))
})
.when(!show_clean, |this| {
let icon = match self.icon.clone() {
Some(icon) => icon,
None => {
if self.open {
IconName::ChevronUp
} else {
IconName::ChevronDown
}
}
};
this.child(
Icon::new(icon)
.xsmall()
.text_color(match self.disabled {
true => cx.theme().muted_foreground.opacity(0.5),
false => cx.theme().muted_foreground,
})
.when(self.disabled, |this| this.cursor_not_allowed()),
)
}),
)
.child(
canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
)
.absolute()
.size_full(),
),
)
.when(self.open, |this| {
this.child(
deferred(
anchored().snap_to_window_with_margin(px(8.)).child(
div()
.occlude()
.map(|this| match self.menu_width {
Length::Auto => this.w(bounds.size.width),
Length::Definite(w) => this.w(w),
})
.child(
v_flex()
.occlude()
.mt_1p5()
.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().border)
.rounded(px(cx.theme().radius))
.shadow_md()
.on_mouse_down_out(|_, cx| {
cx.dispatch_action(Box::new(Escape));
})
.child(self.list.clone()),
)
.on_mouse_down_out(cx.listener(|this, _, cx| {
this.escape(&Escape, cx);
})),
),
)
.with_priority(1),
)
})
}
}

22
crates/ui/src/event.rs Normal file
View File

@@ -0,0 +1,22 @@
use gpui::{ClickEvent, Focusable, InteractiveElement, Stateful, WindowContext};
pub trait InteractiveElementExt: InteractiveElement {
/// Set the listener for a double click event.
fn on_double_click(
mut self,
listener: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self
where
Self: Sized,
{
self.interactivity().on_click(move |event, context| {
if event.up.click_count == 2 {
listener(event, context);
}
});
self
}
}
impl<E: InteractiveElement> InteractiveElementExt for Focusable<E> {}
impl<E: InteractiveElement> InteractiveElementExt for Stateful<E> {}

View File

@@ -0,0 +1,40 @@
use gpui::{FocusHandle, ViewContext};
/// A trait for views that can cycle focus between its children.
///
/// This will provide a default implementation for the `cycle_focus` method that will cycle focus.
///
/// You should implement the `cycle_focus_handles` method to return a list of focus handles that
/// should be cycled, and the cycle will follow the order of the list.
pub trait FocusableCycle {
/// Returns a list of focus handles that should be cycled.
fn cycle_focus_handles(&self, cx: &mut ViewContext<Self>) -> Vec<FocusHandle>
where
Self: Sized;
/// Cycles focus between the focus handles returned by `cycle_focus_handles`.
/// If `is_next` is `true`, it will cycle to the next focus handle, otherwise it will cycle to prev.
fn cycle_focus(&self, is_next: bool, cx: &mut ViewContext<Self>)
where
Self: Sized,
{
let focused_handle = cx.focused();
let handles = self.cycle_focus_handles(cx);
let handles = if is_next {
handles
} else {
handles.into_iter().rev().collect()
};
let fallback_handle = handles[0].clone();
let target_focus_handle = handles
.into_iter()
.skip_while(|handle| Some(handle) != focused_handle.as_ref())
.skip(1)
.next()
.unwrap_or(fallback_handle);
target_focus_handle.focus(cx);
cx.stop_propagation();
}
}

197
crates/ui/src/history.rs Normal file
View File

@@ -0,0 +1,197 @@
use std::{
fmt::Debug,
time::{Duration, Instant},
};
pub trait HistoryItem: Clone {
fn version(&self) -> usize;
fn set_version(&mut self, version: usize);
}
/// The History is used to keep track of changes to a model and to allow undo and redo operations.
///
/// This is now used in Input for undo/redo operations. You can also use this in
/// your own models to keep track of changes, for example to track the tab
/// history for prev/next features.
#[derive(Debug)]
pub struct History<I: HistoryItem> {
undos: Vec<I>,
redos: Vec<I>,
last_changed_at: Instant,
version: usize,
pub(crate) ignore: bool,
max_undo: usize,
group_interval: Option<Duration>,
}
impl<I> History<I>
where
I: HistoryItem,
{
pub fn new() -> Self {
Self {
undos: Default::default(),
redos: Default::default(),
ignore: false,
last_changed_at: Instant::now(),
version: 0,
max_undo: 1000,
group_interval: None,
}
}
/// Set the maximum number of undo steps to keep, defaults to 1000.
pub fn max_undo(mut self, max_undo: usize) -> Self {
self.max_undo = max_undo;
self
}
/// Set the interval in milliseconds to group changes, defaults to None.
pub fn group_interval(mut self, group_interval: Duration) -> Self {
self.group_interval = Some(group_interval);
self
}
/// Increment the version number if the last change was made more than `GROUP_INTERVAL` milliseconds ago.
fn inc_version(&mut self) -> usize {
let t = Instant::now();
if Some(self.last_changed_at.elapsed()) > self.group_interval {
self.version += 1;
}
self.last_changed_at = t;
self.version
}
/// Get the current version number.
pub fn version(&self) -> usize {
self.version
}
pub fn push(&mut self, item: I) {
let version = self.inc_version();
if self.undos.len() >= self.max_undo {
self.undos.remove(0);
}
let mut item = item;
item.set_version(version);
self.undos.push(item);
}
pub fn undo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.undos.pop() {
let mut changes = vec![first_change.clone()];
// pick the next all changes with the same version
while self
.undos
.iter()
.filter(|c| c.version() == first_change.version())
.count()
> 0
{
let change = self.undos.pop().unwrap();
changes.push(change);
}
self.redos.extend(changes.iter().rev().cloned());
Some(changes)
} else {
None
}
}
pub fn redo(&mut self) -> Option<Vec<I>> {
if let Some(first_change) = self.redos.pop() {
let mut changes = vec![first_change.clone()];
// pick the next all changes with the same version
while self
.redos
.iter()
.filter(|c| c.version() == first_change.version())
.count()
> 0
{
let change = self.redos.pop().unwrap();
changes.push(change);
}
self.undos.extend(changes.iter().rev().cloned());
Some(changes)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone)]
struct TabIndex {
tab_index: usize,
version: usize,
}
impl From<usize> for TabIndex {
fn from(value: usize) -> Self {
TabIndex {
tab_index: value,
version: 0,
}
}
}
impl HistoryItem for TabIndex {
fn version(&self) -> usize {
self.version
}
fn set_version(&mut self, version: usize) {
self.version = version;
}
}
#[test]
fn test_history() {
let mut history: History<TabIndex> = History::new().max_undo(100);
history.push(0.into());
history.push(3.into());
history.push(2.into());
history.push(1.into());
assert_eq!(history.version(), 4);
let changes = history.undo().unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].tab_index, 1);
let changes = history.undo().unwrap();
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].tab_index, 2);
history.push(5.into());
let changes = history.redo().unwrap();
assert_eq!(changes[0].tab_index, 2);
let changes = history.redo().unwrap();
assert_eq!(changes[0].tab_index, 1);
let changes = history.undo().unwrap();
assert_eq!(changes[0].tab_index, 1);
let changes = history.undo().unwrap();
assert_eq!(changes[0].tab_index, 2);
let changes = history.undo().unwrap();
assert_eq!(changes[0].tab_index, 5);
let changes = history.undo().unwrap();
assert_eq!(changes[0].tab_index, 3);
let changes = history.undo().unwrap();
assert_eq!(changes[0].tab_index, 0);
assert_eq!(history.undo().is_none(), true);
}
}

319
crates/ui/src/icon.rs Normal file
View File

@@ -0,0 +1,319 @@
use crate::{theme::ActiveTheme, Sizable, Size};
use gpui::{
prelude::FluentBuilder as _, svg, AnyElement, Hsla, IntoElement, Radians, Render, RenderOnce,
SharedString, StyleRefinement, Styled, Svg, Transformation, View, VisualContext, WindowContext,
};
#[derive(IntoElement, Clone)]
pub enum IconName {
ALargeSmall,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Asterisk,
Bell,
BookOpen,
Bot,
Calendar,
ChartPie,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronsUpDown,
CircleCheck,
CircleUser,
CircleX,
Close,
Copy,
Dash,
Delete,
Ellipsis,
EllipsisVertical,
Eye,
EyeOff,
Frame,
GalleryVerticalEnd,
GitHub,
Globe,
Heart,
HeartOff,
Inbox,
Info,
LayoutDashboard,
Loader,
LoaderCircle,
Map,
Maximize,
Menu,
Minimize,
Minus,
Moon,
Palette,
PanelBottom,
PanelBottomOpen,
PanelLeft,
PanelLeftClose,
PanelLeftOpen,
PanelRight,
PanelRightClose,
PanelRightOpen,
Plus,
Search,
Settings,
Settings2,
SortAscending,
SortDescending,
SquareTerminal,
Star,
StarOff,
Sun,
ThumbsDown,
ThumbsUp,
TriangleAlert,
WindowClose,
WindowMaximize,
WindowMinimize,
WindowRestore,
}
impl IconName {
pub fn path(self) -> SharedString {
match self {
IconName::ALargeSmall => "icons/a-large-small.svg",
IconName::ArrowDown => "icons/arrow-down.svg",
IconName::ArrowLeft => "icons/arrow-left.svg",
IconName::ArrowRight => "icons/arrow-right.svg",
IconName::ArrowUp => "icons/arrow-up.svg",
IconName::Asterisk => "icons/asterisk.svg",
IconName::Bell => "icons/bell.svg",
IconName::BookOpen => "icons/book-open.svg",
IconName::Bot => "icons/bot.svg",
IconName::Calendar => "icons/calendar.svg",
IconName::ChartPie => "icons/chart-pie.svg",
IconName::Check => "icons/check.svg",
IconName::ChevronDown => "icons/chevron-down.svg",
IconName::ChevronLeft => "icons/chevron-left.svg",
IconName::ChevronRight => "icons/chevron-right.svg",
IconName::ChevronUp => "icons/chevron-up.svg",
IconName::ChevronsUpDown => "icons/chevrons-up-down.svg",
IconName::CircleCheck => "icons/circle-check.svg",
IconName::CircleUser => "icons/circle-user.svg",
IconName::CircleX => "icons/circle-x.svg",
IconName::Close => "icons/close.svg",
IconName::Copy => "icons/copy.svg",
IconName::Dash => "icons/dash.svg",
IconName::Delete => "icons/delete.svg",
IconName::Ellipsis => "icons/ellipsis.svg",
IconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
IconName::Eye => "icons/eye.svg",
IconName::EyeOff => "icons/eye-off.svg",
IconName::Frame => "icons/frame.svg",
IconName::GalleryVerticalEnd => "icons/gallery-vertical-end.svg",
IconName::GitHub => "icons/github.svg",
IconName::Globe => "icons/globe.svg",
IconName::Heart => "icons/heart.svg",
IconName::HeartOff => "icons/heart-off.svg",
IconName::Inbox => "icons/inbox.svg",
IconName::Info => "icons/info.svg",
IconName::LayoutDashboard => "icons/layout-dashboard.svg",
IconName::Loader => "icons/loader.svg",
IconName::LoaderCircle => "icons/loader-circle.svg",
IconName::Map => "icons/map.svg",
IconName::Maximize => "icons/maximize.svg",
IconName::Menu => "icons/menu.svg",
IconName::Minimize => "icons/minimize.svg",
IconName::Minus => "icons/minus.svg",
IconName::Moon => "icons/moon.svg",
IconName::Palette => "icons/palette.svg",
IconName::PanelBottom => "icons/panel-bottom.svg",
IconName::PanelBottomOpen => "icons/panel-bottom-open.svg",
IconName::PanelLeft => "icons/panel-left.svg",
IconName::PanelLeftClose => "icons/panel-left-close.svg",
IconName::PanelLeftOpen => "icons/panel-left-open.svg",
IconName::PanelRight => "icons/panel-right.svg",
IconName::PanelRightClose => "icons/panel-right-close.svg",
IconName::PanelRightOpen => "icons/panel-right-open.svg",
IconName::Plus => "icons/plus.svg",
IconName::Search => "icons/search.svg",
IconName::Settings => "icons/settings.svg",
IconName::Settings2 => "icons/settings-2.svg",
IconName::SortAscending => "icons/sort-ascending.svg",
IconName::SortDescending => "icons/sort-descending.svg",
IconName::SquareTerminal => "icons/square-terminal.svg",
IconName::Star => "icons/star.svg",
IconName::StarOff => "icons/star-off.svg",
IconName::Sun => "icons/sun.svg",
IconName::ThumbsDown => "icons/thumbs-down.svg",
IconName::ThumbsUp => "icons/thumbs-up.svg",
IconName::TriangleAlert => "icons/triangle-alert.svg",
IconName::WindowClose => "icons/window-close.svg",
IconName::WindowMaximize => "icons/window-maximize.svg",
IconName::WindowMinimize => "icons/window-minimize.svg",
IconName::WindowRestore => "icons/window-restore.svg",
}
.into()
}
/// Return the icon as a View<Icon>
pub fn view(self, cx: &mut WindowContext) -> View<Icon> {
Icon::build(self).view(cx)
}
}
impl From<IconName> for Icon {
fn from(val: IconName) -> Self {
Icon::build(val)
}
}
impl From<IconName> for AnyElement {
fn from(val: IconName) -> Self {
Icon::build(val).into_any_element()
}
}
impl RenderOnce for IconName {
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
Icon::build(self)
}
}
#[derive(IntoElement)]
pub struct Icon {
base: Svg,
path: SharedString,
text_color: Option<Hsla>,
size: Option<Size>,
rotation: Option<Radians>,
}
impl Default for Icon {
fn default() -> Self {
Self {
base: svg().flex_none().size_4(),
path: "".into(),
text_color: None,
size: None,
rotation: None,
}
}
}
impl Clone for Icon {
fn clone(&self) -> Self {
let mut this = Self::default().path(self.path.clone());
if let Some(size) = self.size {
this = this.with_size(size);
}
this
}
}
pub trait IconNamed {
fn path(&self) -> SharedString;
}
impl Icon {
pub fn new(icon: impl Into<Icon>) -> Self {
icon.into()
}
fn build(name: IconName) -> Self {
Self::default().path(name.path())
}
/// Set the icon path of the Assets bundle
///
/// For example: `icons/foo.svg`
pub fn path(mut self, path: impl Into<SharedString>) -> Self {
self.path = path.into();
self
}
/// Create a new view for the icon
pub fn view(self, cx: &mut WindowContext) -> View<Icon> {
cx.new_view(|_| self)
}
pub fn transform(mut self, transformation: gpui::Transformation) -> Self {
self.base = self.base.with_transformation(transformation);
self
}
pub fn empty() -> Self {
Self::default()
}
/// Rotate the icon by the given angle
pub fn rotate(mut self, radians: impl Into<Radians>) -> Self {
self.base = self
.base
.with_transformation(Transformation::rotate(radians));
self
}
}
impl Styled for Icon {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
fn text_color(mut self, color: impl Into<Hsla>) -> Self {
self.text_color = Some(color.into());
self
}
}
impl Sizable for Icon {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = Some(size.into());
self
}
}
impl RenderOnce for Icon {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.text_style().color);
self.base
.text_color(text_color)
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
Size::Small => this.size_3p5(),
Size::Medium => this.size_4(),
Size::Large => this.size_6(),
})
.path(self.path)
}
}
impl From<Icon> for AnyElement {
fn from(val: Icon) -> Self {
val.into_any_element()
}
}
impl Render for Icon {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.theme().foreground);
svg()
.flex_none()
.text_color(text_color)
.when_some(self.size, |this, size| match size {
Size::Size(px) => this.size(px),
Size::XSmall => this.size_3(),
Size::Small => this.size_3p5(),
Size::Medium => this.size_4(),
Size::Large => this.size_6(),
})
.path(self.path.clone())
.when_some(self.rotation, |this, rotation| {
this.with_transformation(Transformation::rotate(rotation))
})
}
}

View File

@@ -0,0 +1,60 @@
use std::time::Duration;
use crate::{Icon, IconName, Sizable, Size};
use gpui::{
div, ease_in_out, percentage, prelude::FluentBuilder as _, Animation, AnimationExt as _, Hsla,
IntoElement, ParentElement, RenderOnce, Styled as _, Transformation, WindowContext,
};
#[derive(IntoElement)]
pub struct Indicator {
size: Size,
icon: Icon,
speed: Duration,
color: Option<Hsla>,
}
impl Indicator {
pub fn new() -> Self {
Self {
size: Size::Medium,
speed: Duration::from_secs_f64(0.8),
icon: Icon::new(IconName::Loader),
color: None,
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = icon.into();
self
}
pub fn color(mut self, color: Hsla) -> Self {
self.color = Some(color);
self
}
}
impl Sizable for Indicator {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl RenderOnce for Indicator {
fn render(self, _: &mut WindowContext) -> impl IntoElement {
div()
.child(
self.icon
.with_size(self.size)
.when_some(self.color, |this, color| this.text_color(color))
.with_animation(
"circle",
Animation::new(self.speed).repeat().with_easing(ease_in_out),
|this, delta| this.transform(Transformation::rotate(percentage(delta))),
),
)
.into_element()
}
}

View File

@@ -0,0 +1,88 @@
use std::time::Duration;
use gpui::{ModelContext, Timer};
static INTERVAL: Duration = Duration::from_millis(500);
static PAUSE_DELAY: Duration = Duration::from_millis(300);
/// To manage the Input cursor blinking.
///
/// It will start blinking with a interval of 500ms.
/// Every loop will notify the view to update the `visible`, and Input will observe this update to touch repaint.
///
/// The input painter will check if this in visible state, then it will draw the cursor.
pub(crate) struct BlinkCursor {
visible: bool,
paused: bool,
epoch: usize,
}
impl BlinkCursor {
pub fn new() -> Self {
Self {
visible: false,
paused: false,
epoch: 0,
}
}
/// Start the blinking
pub fn start(&mut self, cx: &mut ModelContext<Self>) {
self.blink(self.epoch, cx);
}
pub fn stop(&mut self, cx: &mut ModelContext<Self>) {
self.epoch = 0;
cx.notify();
}
fn next_epoch(&mut self) -> usize {
self.epoch += 1;
self.epoch
}
fn blink(&mut self, epoch: usize, cx: &mut ModelContext<Self>) {
if self.paused || epoch != self.epoch {
return;
}
self.visible = !self.visible;
cx.notify();
// Schedule the next blink
let epoch = self.next_epoch();
cx.spawn(|this, mut cx| async move {
Timer::after(INTERVAL).await;
if let Some(this) = this.upgrade() {
this.update(&mut cx, |this, cx| this.blink(epoch, cx)).ok();
}
})
.detach();
}
pub fn visible(&self) -> bool {
// Keep showing the cursor if paused
self.paused || self.visible
}
/// Pause the blinking, and delay 500ms to resume the blinking.
pub fn pause(&mut self, cx: &mut ModelContext<Self>) {
self.paused = true;
cx.notify();
// delay 500ms to start the blinking
let epoch = self.next_epoch();
cx.spawn(|this, mut cx| async move {
Timer::after(PAUSE_DELAY).await;
if let Some(this) = this.upgrade() {
this.update(&mut cx, |this, cx| {
this.paused = false;
this.blink(epoch, cx);
})
.ok();
}
})
.detach();
}
}

View File

@@ -0,0 +1,39 @@
use std::{fmt::Debug, ops::Range};
use crate::history::HistoryItem;
#[derive(Debug, Clone)]
pub struct Change {
pub(crate) old_range: Range<usize>,
pub(crate) old_text: String,
pub(crate) new_range: Range<usize>,
pub(crate) new_text: String,
version: usize,
}
impl Change {
pub fn new(
old_range: Range<usize>,
old_text: &str,
new_range: Range<usize>,
new_text: &str,
) -> Self {
Self {
old_range,
old_text: old_text.to_string(),
new_range,
new_text: new_text.to_string(),
version: 0,
}
}
}
impl HistoryItem for Change {
fn version(&self) -> usize {
self.version
}
fn set_version(&mut self, version: usize) {
self.version = version;
}
}

View File

@@ -0,0 +1,18 @@
use gpui::{Styled, WindowContext};
use crate::{
button::{Button, ButtonVariants as _},
theme::ActiveTheme as _,
Icon, IconName, Sizable as _,
};
pub(crate) struct ClearButton {}
impl ClearButton {
pub fn new(cx: &mut WindowContext) -> Button {
Button::new("clean")
.icon(Icon::new(IconName::CircleX).text_color(cx.theme().muted_foreground))
.ghost()
.xsmall()
}
}

View File

@@ -0,0 +1,537 @@
use gpui::{
fill, point, px, relative, size, Bounds, Corners, Element, ElementId, ElementInputHandler,
GlobalElementId, IntoElement, LayoutId, MouseButton, MouseMoveEvent, PaintQuad, Path, Pixels,
Point, Style, TextRun, UnderlineStyle, View, WindowContext, WrappedLine,
};
use smallvec::SmallVec;
use crate::theme::ActiveTheme as _;
use super::TextInput;
const RIGHT_MARGIN: Pixels = px(5.);
const CURSOR_INSET: Pixels = px(0.5);
pub(super) struct TextElement {
input: View<TextInput>,
}
impl TextElement {
pub(super) fn new(input: View<TextInput>) -> Self {
Self { input }
}
fn paint_mouse_listeners(&mut self, cx: &mut WindowContext) {
cx.on_mouse_event({
let input = self.input.clone();
move |event: &MouseMoveEvent, _, cx| {
if event.pressed_button == Some(MouseButton::Left) {
input.update(cx, |input, cx| {
input.on_drag_move(event, cx);
});
}
}
});
}
fn layout_cursor(
&self,
lines: &[WrappedLine],
line_height: Pixels,
bounds: &mut Bounds<Pixels>,
cx: &mut WindowContext,
) -> (Option<PaintQuad>, Point<Pixels>) {
let input = self.input.read(cx);
let selected_range = &input.selected_range;
let cursor_offset = input.cursor_offset();
let mut scroll_offset = input.scroll_handle.offset();
let mut cursor = None;
// The cursor corresponds to the current cursor position in the text no only the line.
let mut cursor_pos = None;
let mut cursor_start = None;
let mut cursor_end = None;
let mut prev_lines_offset = 0;
let mut offset_y = px(0.);
for line in lines.iter() {
// break loop if all cursor positions are found
if cursor_pos.is_some() && cursor_start.is_some() && cursor_end.is_some() {
break;
}
let line_origin = point(px(0.), offset_y);
if cursor_pos.is_none() {
let offset = cursor_offset.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(offset, line_height) {
cursor_pos = Some(line_origin + pos);
}
}
if cursor_start.is_none() {
let offset = selected_range.start.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(offset, line_height) {
cursor_start = Some(line_origin + pos);
}
}
if cursor_end.is_none() {
let offset = selected_range.end.saturating_sub(prev_lines_offset);
if let Some(pos) = line.position_for_index(offset, line_height) {
cursor_end = Some(line_origin + pos);
}
}
offset_y += line.size(line_height).height;
// +1 for skip the last `\n`
prev_lines_offset += line.len() + 1;
}
if let (Some(cursor_pos), Some(cursor_start), Some(cursor_end)) =
(cursor_pos, cursor_start, cursor_end)
{
let cursor_moved = input.last_cursor_offset != Some(cursor_offset);
let selection_changed = input.last_selected_range != Some(selected_range.clone());
if cursor_moved || selection_changed {
scroll_offset.x =
if scroll_offset.x + cursor_pos.x > (bounds.size.width - RIGHT_MARGIN) {
// cursor is out of right
bounds.size.width - RIGHT_MARGIN - cursor_pos.x
} else if scroll_offset.x + cursor_pos.x < px(0.) {
// cursor is out of left
scroll_offset.x - cursor_pos.x
} else {
scroll_offset.x
};
scroll_offset.y = if scroll_offset.y + cursor_pos.y > (bounds.size.height) {
// cursor is out of bottom
bounds.size.height - cursor_pos.y
} else if scroll_offset.y + cursor_pos.y < px(0.) {
// cursor is out of top
scroll_offset.y - cursor_pos.y
} else {
scroll_offset.y
};
if input.selection_reversed {
if scroll_offset.x + cursor_start.x < px(0.) {
// selection start is out of left
scroll_offset.x = -cursor_start.x;
}
if scroll_offset.y + cursor_start.y < px(0.) {
// selection start is out of top
scroll_offset.y = -cursor_start.y;
}
} else {
if scroll_offset.x + cursor_end.x <= px(0.) {
// selection end is out of left
scroll_offset.x = -cursor_end.x;
}
if scroll_offset.y + cursor_end.y <= px(0.) {
// selection end is out of top
scroll_offset.y = -cursor_end.y;
}
}
}
bounds.origin = bounds.origin + scroll_offset;
if input.show_cursor(cx) {
// cursor blink
cursor = Some(fill(
Bounds::new(
point(
bounds.left() + cursor_pos.x,
bounds.top() + cursor_pos.y + CURSOR_INSET,
),
size(px(1.5), line_height),
),
crate::blue_500(),
))
};
}
(cursor, scroll_offset)
}
fn layout_selections(
&self,
lines: &[WrappedLine],
line_height: Pixels,
bounds: &mut Bounds<Pixels>,
cx: &mut WindowContext,
) -> Option<Path<Pixels>> {
let input = self.input.read(cx);
let selected_range = &input.selected_range;
if selected_range.is_empty() {
return None;
}
let (start_ix, end_ix) = if selected_range.start < selected_range.end {
(selected_range.start, selected_range.end)
} else {
(selected_range.end, selected_range.start)
};
let mut prev_lines_offset = 0;
let mut line_corners = vec![];
let mut offset_y = px(0.);
for line in lines.iter() {
let line_size = line.size(line_height);
let line_wrap_width = line_size.width;
let line_origin = point(px(0.), offset_y);
let line_cursor_start =
line.position_for_index(start_ix.saturating_sub(prev_lines_offset), line_height);
let line_cursor_end =
line.position_for_index(end_ix.saturating_sub(prev_lines_offset), line_height);
if line_cursor_start.is_some() || line_cursor_end.is_some() {
let start = line_cursor_start
.unwrap_or_else(|| line.position_for_index(0, line_height).unwrap());
let end = line_cursor_end
.unwrap_or_else(|| line.position_for_index(line.len(), line_height).unwrap());
// Split the selection into multiple items
let wrapped_lines =
(end.y / line_height).ceil() as usize - (start.y / line_height).ceil() as usize;
let mut end_x = end.x;
if wrapped_lines > 0 {
end_x = line_wrap_width;
}
line_corners.push(Corners {
top_left: line_origin + point(start.x, start.y),
top_right: line_origin + point(end_x, start.y),
bottom_left: line_origin + point(start.x, start.y + line_height),
bottom_right: line_origin + point(end_x, start.y + line_height),
});
// wrapped lines
for i in 1..=wrapped_lines {
let start = point(px(0.), start.y + i as f32 * line_height);
let mut end = point(end.x, end.y + i as f32 * line_height);
if i < wrapped_lines {
end.x = line_size.width;
}
line_corners.push(Corners {
top_left: line_origin + point(start.x, start.y),
top_right: line_origin + point(end.x, start.y),
bottom_left: line_origin + point(start.x, start.y + line_height),
bottom_right: line_origin + point(end.x, start.y + line_height),
});
}
}
if line_cursor_start.is_some() && line_cursor_end.is_some() {
break;
}
offset_y += line_size.height;
// +1 for skip the last `\n`
prev_lines_offset += line.len() + 1;
}
let mut points = vec![];
if line_corners.is_empty() {
return None;
}
// Fix corners to make sure the left to right direction
for corners in &mut line_corners {
if corners.top_left.x > corners.top_right.x {
std::mem::swap(&mut corners.top_left, &mut corners.top_right);
std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right);
}
}
for corners in &line_corners {
points.push(corners.top_right);
points.push(corners.bottom_right);
points.push(corners.bottom_left);
}
let mut rev_line_corners = line_corners.iter().rev().peekable();
while let Some(corners) = rev_line_corners.next() {
points.push(corners.top_left);
if let Some(next) = rev_line_corners.peek() {
if next.top_left.x > corners.top_left.x {
points.push(point(next.top_left.x, corners.top_left.y));
}
}
}
// print_points_as_svg_path(&line_corners, &points);
let first_p = *points.get(0).unwrap();
let mut path = gpui::Path::new(bounds.origin + first_p);
for p in points.iter().skip(1) {
path.line_to(bounds.origin + *p);
}
Some(path)
}
}
pub(super) struct PrepaintState {
lines: SmallVec<[WrappedLine; 1]>,
cursor: Option<PaintQuad>,
cursor_scroll_offset: Point<Pixels>,
selection_path: Option<Path<Pixels>>,
bounds: Bounds<Pixels>,
}
impl IntoElement for TextElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
/// A debug function to print points as SVG path.
#[allow(unused)]
fn print_points_as_svg_path(
line_corners: &Vec<Corners<Point<Pixels>>>,
points: &Vec<Point<Pixels>>,
) {
for corners in line_corners {
println!(
"tl: ({}, {}), tr: ({}, {}), bl: ({}, {}), br: ({}, {})",
corners.top_left.x.0 as i32,
corners.top_left.y.0 as i32,
corners.top_right.x.0 as i32,
corners.top_right.y.0 as i32,
corners.bottom_left.x.0 as i32,
corners.bottom_left.y.0 as i32,
corners.bottom_right.x.0 as i32,
corners.bottom_right.y.0 as i32,
);
}
if points.len() > 0 {
println!("M{},{}", points[0].x.0 as i32, points[0].y.0 as i32);
for p in points.iter().skip(1) {
println!("L{},{}", p.x.0 as i32, p.y.0 as i32);
}
}
}
impl Element for TextElement {
type RequestLayoutState = ();
type PrepaintState = PrepaintState;
fn id(&self) -> Option<ElementId> {
None
}
fn request_layout(
&mut self,
_id: Option<&GlobalElementId>,
cx: &mut WindowContext,
) -> (LayoutId, Self::RequestLayoutState) {
let input = self.input.read(cx);
let mut style = Style::default();
style.size.width = relative(1.).into();
if self.input.read(cx).is_multi_line() {
style.size.height = relative(1.).into();
style.min_size.height = (input.rows.max(1) as f32 * cx.line_height()).into();
} else {
style.size.height = cx.line_height().into();
};
(cx.request_layout(style, []), ())
}
fn prepaint(
&mut self,
_id: Option<&GlobalElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) -> Self::PrepaintState {
let multi_line = self.input.read(cx).is_multi_line();
let line_height = cx.line_height();
let input = self.input.read(cx);
let text = input.text.clone();
let placeholder = input.placeholder.clone();
let style = cx.text_style();
let mut bounds = bounds;
let (display_text, text_color) = if text.is_empty() {
(placeholder, cx.theme().muted_foreground)
} else if input.masked {
(
"*".repeat(text.chars().count()).into(),
cx.theme().foreground,
)
} else {
(text, cx.theme().foreground)
};
let run = TextRun {
len: display_text.len(),
font: style.font(),
color: text_color,
background_color: None,
underline: None,
strikethrough: None,
};
let runs = if let Some(marked_range) = input.marked_range.as_ref() {
vec![
TextRun {
len: marked_range.start,
..run.clone()
},
TextRun {
len: marked_range.end - marked_range.start,
underline: Some(UnderlineStyle {
color: Some(run.color),
thickness: px(1.0),
wavy: false,
}),
..run.clone()
},
TextRun {
len: display_text.len() - marked_range.end,
..run.clone()
},
]
.into_iter()
.filter(|run| run.len > 0)
.collect()
} else {
vec![run]
};
let font_size = style.font_size.to_pixels(cx.rem_size());
let wrap_width = if multi_line {
Some(bounds.size.width - RIGHT_MARGIN)
} else {
None
};
let lines = cx
.text_system()
.shape_text(display_text, font_size, &runs, wrap_width)
.unwrap();
// `position_for_index` for example
//
// #### text
//
// Hello 世界this is GPUI component.
// The GPUI Component is a collection of UI components for
// GPUI framework, including Button, Input, Checkbox, Radio,
// Dropdown, Tab, and more...
//
// wrap_width: 444px, line_height: 20px
//
// #### lines[0]
//
// | index | pos | line |
// |-------|------------------|------|
// | 5 | (37 px, 0.0) | 0 |
// | 38 | (261.7 px, 20.0) | 0 |
// | 40 | None | - |
//
// #### lines[1]
//
// | index | position | line |
// |-------|-----------------------|------|
// | 5 | (43.578125 px, 0.0) | 0 |
// | 56 | (422.21094 px, 0.0) | 0 |
// | 57 | (11.6328125 px, 20.0) | 1 |
// | 114 | (429.85938 px, 20.0) | 1 |
// | 115 | (11.3125 px, 40.0) | 2 |
// Calculate the scroll offset to keep the cursor in view
let (cursor, cursor_scroll_offset) =
self.layout_cursor(&lines, line_height, &mut bounds, cx);
let selection_path = self.layout_selections(&lines, line_height, &mut bounds, cx);
PrepaintState {
bounds,
lines,
cursor,
cursor_scroll_offset,
selection_path,
}
}
fn paint(
&mut self,
_id: Option<&GlobalElementId>,
input_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
let focus_handle = self.input.read(cx).focus_handle.clone();
let focused = focus_handle.is_focused(cx);
let bounds = prepaint.bounds;
let selected_range = self.input.read(cx).selected_range.clone();
cx.handle_input(
&focus_handle,
ElementInputHandler::new(bounds, self.input.clone()),
);
// Paint selections
if let Some(path) = prepaint.selection_path.take() {
cx.paint_path(path, cx.theme().selection);
}
// Paint multi line text
let line_height = cx.line_height();
let origin = bounds.origin;
let mut offset_y = px(0.);
for line in prepaint.lines.iter() {
let p = point(origin.x, origin.y + offset_y);
_ = line.paint(p, line_height, cx);
offset_y += line.size(line_height).height;
}
if focused {
if let Some(cursor) = prepaint.cursor.take() {
cx.paint_quad(cursor);
}
}
let width = prepaint
.lines
.iter()
.map(|l| l.width())
.max()
.unwrap_or_default();
let height = prepaint
.lines
.iter()
.map(|l| l.size(line_height).height.0)
.sum::<f32>();
let scroll_size = size(width, px(height));
self.input.update(cx, |input, _cx| {
input.last_layout = Some(prepaint.lines.clone());
input.last_bounds = Some(bounds);
input.last_cursor_offset = Some(input.cursor_offset());
input.last_line_height = line_height;
input.input_bounds = input_bounds;
input.last_selected_range = Some(selected_range);
input
.scroll_handle
.set_offset(prepaint.cursor_scroll_offset);
input.scroll_size = scroll_size;
});
self.paint_mouse_listeners(cx);
}
}

1269
crates/ui/src/input/input.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
mod blink_cursor;
mod change;
mod clear_button;
mod element;
mod input;
mod otp_input;
pub(crate) use clear_button::*;
pub use input::*;
pub use otp_input::*;

View File

@@ -0,0 +1,274 @@
use gpui::{
div, prelude::FluentBuilder, px, AnyElement, Context, EventEmitter, FocusHandle, FocusableView,
InteractiveElement, IntoElement, KeyDownEvent, Model, MouseButton, MouseDownEvent,
ParentElement as _, Render, SharedString, Styled as _, ViewContext,
};
use crate::{h_flex, theme::ActiveTheme, v_flex, Icon, IconName, Sizable, Size};
use super::{blink_cursor::BlinkCursor, InputEvent};
pub enum InputOptEvent {
/// When all OTP input have filled, this event will be triggered.
Change(SharedString),
}
/// A One Time Password (OTP) input element.
///
/// This can accept a fixed length number and can be masked.
///
/// Use case example:
///
/// - SMS OTP
/// - Authenticator OTP
pub struct OtpInput {
focus_handle: FocusHandle,
length: usize,
number_of_groups: usize,
masked: bool,
value: SharedString,
blink_cursor: Model<BlinkCursor>,
size: Size,
}
impl OtpInput {
pub fn new(length: usize, cx: &mut ViewContext<Self>) -> Self {
let focus_handle = cx.focus_handle();
let blink_cursor = cx.new_model(|_| BlinkCursor::new());
let input = Self {
focus_handle: focus_handle.clone(),
length,
number_of_groups: 2,
value: SharedString::default(),
masked: false,
blink_cursor: blink_cursor.clone(),
size: Size::Medium,
};
// Observe the blink cursor to repaint the view when it changes.
cx.observe(&blink_cursor, |_, _, cx| cx.notify()).detach();
// Blink the cursor when the window is active, pause when it's not.
cx.observe_window_activation(|this, cx| {
if cx.is_window_active() {
let focus_handle = this.focus_handle.clone();
if focus_handle.is_focused(cx) {
this.blink_cursor.update(cx, |blink_cursor, cx| {
blink_cursor.start(cx);
});
}
}
})
.detach();
cx.on_focus(&focus_handle, Self::on_focus).detach();
cx.on_blur(&focus_handle, Self::on_blur).detach();
input
}
/// Set number of groups in the OTP Input.
pub fn groups(mut self, n: usize) -> Self {
self.number_of_groups = n;
self
}
/// Set default value of the OTP Input.
pub fn default_value(mut self, value: impl Into<SharedString>) -> Self {
self.value = value.into();
self
}
/// Set value of the OTP Input.
pub fn set_value(&mut self, value: impl Into<SharedString>, cx: &mut ViewContext<Self>) {
self.value = value.into();
cx.notify();
}
/// Return the value of the OTP Input.
pub fn value(&self) -> SharedString {
self.value.clone()
}
/// Set masked to true use masked input.
pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked;
self
}
/// Set masked to true use masked input.
pub fn set_masked(&mut self, masked: bool, cx: &mut ViewContext<Self>) {
self.masked = masked;
cx.notify();
}
pub fn focus(&self, cx: &mut ViewContext<Self>) {
self.focus_handle.focus(cx);
}
fn on_input_mouse_down(&mut self, _: &MouseDownEvent, cx: &mut ViewContext<Self>) {
cx.focus(&self.focus_handle);
}
fn on_key_down(&mut self, event: &KeyDownEvent, cx: &mut ViewContext<Self>) {
let mut chars: Vec<char> = self.value.chars().collect();
let ix = chars.len();
let key = event.keystroke.key.as_str();
match key {
"backspace" => {
if ix > 0 {
let ix = ix - 1;
chars.remove(ix);
}
cx.prevent_default();
cx.stop_propagation();
}
_ => {
let c = key.chars().next().unwrap();
if !matches!(c, '0'..='9') {
return;
}
if ix >= self.length {
return;
}
chars.push(c);
cx.prevent_default();
cx.stop_propagation();
}
}
self.pause_blink_cursor(cx);
self.value = SharedString::from(chars.iter().collect::<String>());
if self.value.chars().count() == self.length {
cx.emit(InputEvent::Change(self.value.clone()));
}
cx.notify()
}
fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
self.blink_cursor.update(cx, |cursor, cx| {
cursor.start(cx);
});
cx.emit(InputEvent::Focus);
}
fn on_blur(&mut self, cx: &mut ViewContext<Self>) {
self.blink_cursor.update(cx, |cursor, cx| {
cursor.stop(cx);
});
cx.emit(InputEvent::Blur);
}
fn pause_blink_cursor(&mut self, cx: &mut ViewContext<Self>) {
self.blink_cursor.update(cx, |cursor, cx| {
cursor.pause(cx);
});
}
}
impl Sizable for OtpInput {
fn with_size(mut self, size: impl Into<crate::Size>) -> Self {
self.size = size.into();
self
}
}
impl FocusableView for OtpInput {
fn focus_handle(&self, _: &gpui::AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<InputEvent> for OtpInput {}
impl Render for OtpInput {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let blink_show = self.blink_cursor.read(cx).visible();
let is_focused = self.focus_handle.is_focused(cx);
let text_size = match self.size {
Size::XSmall => px(14.),
Size::Small => px(14.),
Size::Medium => px(16.),
Size::Large => px(18.),
Size::Size(v) => v * 0.5,
};
let mut groups: Vec<Vec<AnyElement>> = Vec::with_capacity(self.number_of_groups);
let mut group_ix = 0;
let group_items_count = self.length / self.number_of_groups;
for _ in 0..self.number_of_groups {
groups.push(vec![]);
}
for i in 0..self.length {
let c = self.value.chars().nth(i);
if i % group_items_count == 0 && i != 0 {
group_ix += 1;
}
let is_input_focused = i == self.value.chars().count() && is_focused;
groups[group_ix].push(
h_flex()
.id(("input-otp", i))
.border_1()
.border_color(cx.theme().input)
.bg(cx.theme().background)
.when(is_input_focused, |this| this.border_color(cx.theme().ring))
.when(cx.theme().shadow, |this| this.shadow_sm())
.items_center()
.justify_center()
.rounded_md()
.text_size(text_size)
.map(|this| match self.size {
Size::XSmall => this.w_6().h_6(),
Size::Small => this.w_6().h_6(),
Size::Medium => this.w_8().h_8(),
Size::Large => this.w_11().h_11(),
Size::Size(px) => this.w(px).h(px),
})
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_input_mouse_down))
.map(|this| match c {
Some(c) => {
if self.masked {
this.child(
Icon::new(IconName::Asterisk)
.text_color(cx.theme().secondary_foreground)
.with_size(text_size),
)
} else {
this.child(c.to_string())
}
}
None => this.when(is_input_focused && blink_show, |this| {
this.child(
div()
.h_4()
.w_0()
.border_l_3()
.border_color(crate::blue_500()),
)
}),
})
.into_any_element(),
);
}
v_flex()
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(Self::on_key_down))
.items_center()
.child(
h_flex().items_center().gap_5().children(
groups
.into_iter()
.map(|inputs| h_flex().items_center().gap_1().children(inputs)),
),
)
}
}

94
crates/ui/src/label.rs Normal file
View File

@@ -0,0 +1,94 @@
use gpui::{
div, prelude::FluentBuilder, rems, Div, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, WindowContext,
};
use crate::{h_flex, theme::ActiveTheme};
const MASKED: &str = "";
#[derive(Default, PartialEq, Eq)]
pub enum TextAlign {
#[default]
Left,
Center,
Right,
}
#[derive(IntoElement)]
pub struct Label {
base: Div,
label: SharedString,
align: TextAlign,
marked: bool,
}
impl Label {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
base: h_flex().line_height(rems(1.25)),
label: label.into(),
align: TextAlign::default(),
marked: false,
}
}
pub fn text_align(mut self, align: TextAlign) -> Self {
self.align = align;
self
}
pub fn text_left(mut self) -> Self {
self.align = TextAlign::Left;
self
}
pub fn text_center(mut self) -> Self {
self.align = TextAlign::Center;
self
}
pub fn text_right(mut self) -> Self {
self.align = TextAlign::Right;
self
}
pub fn masked(mut self, masked: bool) -> Self {
self.marked = masked;
self
}
}
impl Styled for Label {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Label {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let text = self.label;
let text_display = if self.marked {
MASKED.repeat(text.chars().count())
} else {
text.to_string()
};
div().text_color(cx.theme().foreground).child(
self.base
.map(|this| match self.align {
TextAlign::Left => this.justify_start(),
TextAlign::Center => this.justify_center(),
TextAlign::Right => this.justify_end(),
})
.map(|this| {
if self.align == TextAlign::Left {
this.child(div().size_full().child(text_display))
} else {
this.child(text_display)
}
}),
)
}
}

View File

@@ -1 +1,82 @@
mod colors;
mod event;
mod focusable;
mod icon;
mod root;
mod styled;
mod svg_img;
mod title_bar;
pub mod accordion;
pub mod animation;
pub mod badge;
pub mod breadcrumb;
pub mod button;
pub mod button_group;
pub mod checkbox;
pub mod clipboard;
pub mod color_picker;
pub mod context_menu;
pub mod divider;
pub mod dock;
pub mod drawer;
pub mod dropdown;
pub mod history;
pub mod indicator;
pub mod input;
pub mod label;
pub mod link;
pub mod list;
pub mod modal;
pub mod notification;
pub mod number_input;
pub mod popover;
pub mod popup_menu;
pub mod prelude;
pub mod progress;
pub mod radio;
pub mod resizable;
pub mod scroll;
pub mod sidebar;
pub mod skeleton;
pub mod slider;
pub mod switch;
pub mod tab;
pub mod table;
pub mod theme;
pub mod tooltip;
pub use crate::Disableable;
pub use event::InteractiveElementExt;
pub use focusable::FocusableCycle;
pub use root::{ContextModal, Root};
pub use styled::*;
pub use title_bar::*;
pub use colors::*;
pub use icon::*;
pub use svg_img::*;
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "../../assets"]
pub struct Assets;
/// Initialize the UI module.
///
/// This must be called before using any of the UI components.
/// You can initialize the UI module at your application's entry point.
pub fn init(cx: &mut gpui::AppContext) {
theme::init(cx);
dock::init(cx);
drawer::init(cx);
dropdown::init(cx);
input::init(cx);
number_input::init(cx);
list::init(cx);
modal::init(cx);
popover::init(cx);
popup_menu::init(cx);
table::init(cx);
}

93
crates/ui/src/link.rs Normal file
View File

@@ -0,0 +1,93 @@
use gpui::{
div, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, Stateful, StatefulInteractiveElement, Styled,
};
use crate::theme::ActiveTheme as _;
/// A Link element like a `<a>` tag in HTML.
#[derive(IntoElement)]
pub struct Link {
base: Stateful<Div>,
href: Option<SharedString>,
disabled: bool,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut gpui::WindowContext) + 'static>>,
}
impl Link {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().id(id),
href: None,
on_click: None,
disabled: false,
}
}
pub fn href(mut self, href: impl Into<SharedString>) -> Self {
self.href = Some(href.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut gpui::WindowContext) + 'static,
) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Styled for Link {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl ParentElement for Link {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.base.extend(elements)
}
}
impl RenderOnce for Link {
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
let href = self.href.clone();
let on_click = self.on_click;
div()
.text_color(cx.theme().link)
.text_decoration_1()
.text_decoration_color(cx.theme().link)
.hover(|this| {
this.text_color(cx.theme().link.opacity(0.8))
.text_decoration_1()
})
.cursor_pointer()
.child(
self.base
.active(|this| {
this.text_color(cx.theme().link.opacity(0.6))
.text_decoration_1()
})
.on_mouse_down(MouseButton::Left, |_, cx| {
cx.stop_propagation();
})
.on_click({
move |e, cx| {
if let Some(href) = &href {
cx.open_url(&href.clone());
}
if let Some(on_click) = &on_click {
on_click(e, cx);
}
}
}),
)
}
}

465
crates/ui/src/list/list.rs Normal file
View File

@@ -0,0 +1,465 @@
use std::time::Duration;
use std::{cell::Cell, rc::Rc};
use crate::Icon;
use crate::{
input::{InputEvent, TextInput},
scroll::{Scrollbar, ScrollbarState},
theme::ActiveTheme,
v_flex, IconName, Size,
};
use gpui::{
actions, div, prelude::FluentBuilder, uniform_list, AnyElement, AppContext, Entity,
FocusHandle, FocusableView, InteractiveElement, IntoElement, KeyBinding, Length,
ListSizingBehavior, MouseButton, ParentElement, Render, SharedString, Styled, Task,
UniformListScrollHandle, View, ViewContext, VisualContext, WindowContext,
};
use gpui::{px, ScrollStrategy};
use smol::Timer;
actions!(list, [Cancel, Confirm, SelectPrev, SelectNext]);
pub fn init(cx: &mut AppContext) {
let context: Option<&str> = Some("List");
cx.bind_keys([
KeyBinding::new("escape", Cancel, context),
KeyBinding::new("enter", Confirm, context),
KeyBinding::new("up", SelectPrev, context),
KeyBinding::new("down", SelectNext, context),
]);
}
/// A delegate for the List.
#[allow(unused)]
pub trait ListDelegate: Sized + 'static {
type Item: IntoElement;
/// When Query Input change, this method will be called.
/// You can perform search here.
fn perform_search(&mut self, query: &str, cx: &mut ViewContext<List<Self>>) -> Task<()> {
Task::Ready(Some(()))
}
/// Return the number of items in the list.
fn items_count(&self, cx: &AppContext) -> usize;
/// Render the item at the given index.
///
/// Return None will skip the item.
fn render_item(&self, ix: usize, cx: &mut ViewContext<List<Self>>) -> Option<Self::Item>;
/// Return a Element to show when list is empty.
fn render_empty(&self, cx: &mut ViewContext<List<Self>>) -> impl IntoElement {
div()
}
/// Returns Some(AnyElement) to render the initial state of the list.
///
/// This can be used to show a view for the list before the user has interacted with it.
///
/// For example: The last search results, or the last selected item.
///
/// Default is None, that means no initial state.
fn render_initial(&self, cx: &mut ViewContext<List<Self>>) -> Option<AnyElement> {
None
}
/// Return the confirmed index of the selected item.
fn confirmed_index(&self, cx: &AppContext) -> Option<usize> {
None
}
/// Set the selected index, just store the ix, don't confirm.
fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut ViewContext<List<Self>>);
/// Set the confirm and give the selected index, this is means user have clicked the item or pressed Enter.
fn confirm(&mut self, ix: Option<usize>, cx: &mut ViewContext<List<Self>>) {}
/// Cancel the selection, e.g.: Pressed ESC.
fn cancel(&mut self, cx: &mut ViewContext<List<Self>>) {}
}
pub struct List<D: ListDelegate> {
focus_handle: FocusHandle,
delegate: D,
max_height: Option<Length>,
query_input: Option<View<TextInput>>,
last_query: Option<String>,
loading: bool,
enable_scrollbar: bool,
vertical_scroll_handle: UniformListScrollHandle,
scrollbar_state: Rc<Cell<ScrollbarState>>,
pub(crate) size: Size,
selected_index: Option<usize>,
right_clicked_index: Option<usize>,
_search_task: Task<()>,
}
impl<D> List<D>
where
D: ListDelegate,
{
pub fn new(delegate: D, cx: &mut ViewContext<Self>) -> Self {
let query_input = cx.new_view(|cx| {
TextInput::new(cx)
.appearance(false)
.prefix(|cx| Icon::new(IconName::Search).text_color(cx.theme().muted_foreground))
.placeholder("Search...")
.cleanable()
});
cx.subscribe(&query_input, Self::on_query_input_event)
.detach();
Self {
focus_handle: cx.focus_handle(),
delegate,
query_input: Some(query_input),
last_query: None,
selected_index: None,
right_clicked_index: None,
vertical_scroll_handle: UniformListScrollHandle::new(),
scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())),
max_height: None,
enable_scrollbar: true,
loading: false,
size: Size::default(),
_search_task: Task::Ready(None),
}
}
/// Set the size
pub fn set_size(&mut self, size: Size, cx: &mut ViewContext<Self>) {
if let Some(input) = &self.query_input {
input.update(cx, |input, cx| {
input.set_size(size, cx);
})
}
self.size = size;
}
pub fn max_h(mut self, height: impl Into<Length>) -> Self {
self.max_height = Some(height.into());
self
}
pub fn no_scrollbar(mut self) -> Self {
self.enable_scrollbar = false;
self
}
pub fn no_query(mut self) -> Self {
self.query_input = None;
self
}
pub fn set_query_input(&mut self, query_input: View<TextInput>, cx: &mut ViewContext<Self>) {
cx.subscribe(&query_input, Self::on_query_input_event)
.detach();
self.query_input = Some(query_input);
}
pub fn delegate(&self) -> &D {
&self.delegate
}
pub fn delegate_mut(&mut self) -> &mut D {
&mut self.delegate
}
pub fn focus(&mut self, cx: &mut WindowContext) {
self.focus_handle(cx).focus(cx);
}
pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut ViewContext<Self>) {
self.selected_index = ix;
self.delegate.set_selected_index(ix, cx);
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index
}
/// Set the query_input text
pub fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
if let Some(query_input) = &self.query_input {
let query = query.to_owned();
query_input.update(cx, |input, cx| input.set_text(query, cx))
}
}
/// Get the query_input text
pub fn query(&self, cx: &mut ViewContext<Self>) -> Option<SharedString> {
self.query_input.as_ref().map(|input| input.read(cx).text())
}
fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<impl IntoElement> {
if !self.enable_scrollbar {
return None;
}
Some(Scrollbar::uniform_scroll(
cx.view().entity_id(),
self.scrollbar_state.clone(),
self.vertical_scroll_handle.clone(),
))
}
fn scroll_to_selected_item(&mut self, _cx: &mut ViewContext<Self>) {
if let Some(ix) = self.selected_index {
self.vertical_scroll_handle
.scroll_to_item(ix, ScrollStrategy::Top);
}
}
fn on_query_input_event(
&mut self,
_: View<TextInput>,
event: &InputEvent,
cx: &mut ViewContext<Self>,
) {
match event {
InputEvent::Change(text) => {
let text = text.trim().to_string();
if Some(&text) == self.last_query.as_ref() {
return;
}
self.set_loading(true, cx);
let search = self.delegate.perform_search(&text, cx);
self._search_task = cx.spawn(|this, mut cx| async move {
search.await;
let _ = this.update(&mut cx, |this, _| {
this.vertical_scroll_handle
.scroll_to_item(0, ScrollStrategy::Top);
this.last_query = Some(text);
});
// Always wait 100ms to avoid flicker
Timer::after(Duration::from_millis(100)).await;
let _ = this.update(&mut cx, |this, cx| {
this.set_loading(false, cx);
});
});
}
InputEvent::PressEnter => self.on_action_confirm(&Confirm, cx),
_ => {}
}
}
fn set_loading(&mut self, loading: bool, cx: &mut ViewContext<Self>) {
self.loading = loading;
if let Some(input) = &self.query_input {
input.update(cx, |input, cx| input.set_loading(loading, cx))
}
cx.notify();
}
fn on_action_cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
self.set_selected_index(None, cx);
self.delegate.cancel(cx);
cx.notify();
}
fn on_action_confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if self.delegate.items_count(cx) == 0 {
return;
}
self.delegate.confirm(self.selected_index, cx);
cx.notify();
}
fn on_action_select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
if self.delegate.items_count(cx) == 0 {
return;
}
let selected_index = self.selected_index.unwrap_or(0);
if selected_index > 0 {
self.selected_index = Some(selected_index - 1);
} else {
self.selected_index = Some(self.delegate.items_count(cx) - 1);
}
self.delegate.set_selected_index(self.selected_index, cx);
self.scroll_to_selected_item(cx);
cx.notify();
}
fn on_action_select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
if self.delegate.items_count(cx) == 0 {
return;
}
if let Some(selected_index) = self.selected_index {
if selected_index < self.delegate.items_count(cx) - 1 {
self.selected_index = Some(selected_index + 1);
} else {
self.selected_index = Some(0);
}
} else {
self.selected_index = Some(0);
}
self.delegate.set_selected_index(self.selected_index, cx);
self.scroll_to_selected_item(cx);
cx.notify();
}
fn render_list_item(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.id("list-item")
.w_full()
.relative()
.children(self.delegate.render_item(ix, cx))
.when_some(self.selected_index, |this, selected_index| {
this.when(ix == selected_index, |this| {
this.child(
div()
.absolute()
.top(px(0.))
.left(px(0.))
.right(px(0.))
.bottom(px(0.))
.bg(cx.theme().list_active)
.border_1()
.border_color(cx.theme().list_active_border),
)
})
})
.when(self.right_clicked_index == Some(ix), |this| {
this.child(
div()
.absolute()
.top(px(0.))
.left(px(0.))
.right(px(0.))
.bottom(px(0.))
.border_1()
.border_color(cx.theme().list_active_border),
)
})
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, cx| {
this.right_clicked_index = None;
this.selected_index = Some(ix);
this.on_action_confirm(&Confirm, cx);
}),
)
.on_mouse_down(
MouseButton::Right,
cx.listener(move |this, _, cx| {
this.right_clicked_index = Some(ix);
cx.notify();
}),
)
}
}
impl<D> FocusableView for List<D>
where
D: ListDelegate,
{
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
if let Some(query_input) = &self.query_input {
query_input.focus_handle(cx)
} else {
self.focus_handle.clone()
}
}
}
impl<D> Render for List<D>
where
D: ListDelegate,
{
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let view = cx.view().clone();
let vertical_scroll_handle = self.vertical_scroll_handle.clone();
let items_count = self.delegate.items_count(cx);
let sizing_behavior = if self.max_height.is_some() {
ListSizingBehavior::Infer
} else {
ListSizingBehavior::Auto
};
let initial_view = if let Some(input) = &self.query_input {
if input.read(cx).text().is_empty() {
self.delegate().render_initial(cx)
} else {
None
}
} else {
None
};
v_flex()
.key_context("List")
.id("list")
.track_focus(&self.focus_handle)
.size_full()
.relative()
.overflow_hidden()
.on_action(cx.listener(Self::on_action_cancel))
.on_action(cx.listener(Self::on_action_confirm))
.on_action(cx.listener(Self::on_action_select_next))
.on_action(cx.listener(Self::on_action_select_prev))
.when_some(self.query_input.clone(), |this, input| {
this.child(
div()
.map(|this| match self.size {
Size::Small => this.py_0().px_1p5(),
_ => this.py_1().px_2(),
})
.border_b_1()
.border_color(cx.theme().border)
.child(input),
)
})
.map(|this| {
if let Some(view) = initial_view {
this.child(view)
} else {
this.child(
v_flex()
.flex_grow()
.relative()
.when_some(self.max_height, |this, h| this.max_h(h))
.overflow_hidden()
.when(items_count == 0, |this| {
this.child(self.delegate().render_empty(cx))
})
.when(items_count > 0, |this| {
this.child(
uniform_list(view, "uniform-list", items_count, {
move |list, visible_range, cx| {
visible_range
.map(|ix| list.render_list_item(ix, cx))
.collect::<Vec<_>>()
}
})
.flex_grow()
.with_sizing_behavior(sizing_behavior)
.track_scroll(vertical_scroll_handle)
.into_any_element(),
)
})
.children(self.render_scrollbar(cx)),
)
}
})
// Click out to cancel right clicked row
.when(self.right_clicked_index.is_some(), |this| {
this.on_mouse_down_out(cx.listener(|this, _, cx| {
this.right_clicked_index = None;
cx.notify();
}))
})
}
}

View File

@@ -0,0 +1,169 @@
use crate::{h_flex, theme::ActiveTheme, Disableable, Icon, IconName, Selectable, Sizable as _};
use gpui::{
div, prelude::FluentBuilder as _, AnyElement, ClickEvent, Div, ElementId, InteractiveElement,
IntoElement, MouseButton, MouseMoveEvent, ParentElement, RenderOnce, Stateful,
StatefulInteractiveElement as _, Styled, WindowContext,
};
use smallvec::SmallVec;
#[derive(IntoElement)]
pub struct ListItem {
id: ElementId,
base: Stateful<Div>,
disabled: bool,
selected: bool,
confirmed: bool,
check_icon: Option<Icon>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
on_mouse_enter: Option<Box<dyn Fn(&MouseMoveEvent, &mut WindowContext) + 'static>>,
suffix: Option<Box<dyn Fn(&mut WindowContext) -> AnyElement + 'static>>,
children: SmallVec<[AnyElement; 2]>,
}
impl ListItem {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
base: h_flex().id(id).gap_x_1().py_1().px_2().text_base(),
disabled: false,
selected: false,
confirmed: false,
on_click: None,
on_mouse_enter: None,
check_icon: None,
suffix: None,
children: SmallVec::new(),
}
}
/// Set to show check icon, default is None.
pub fn check_icon(mut self, icon: IconName) -> Self {
self.check_icon = Some(Icon::new(icon));
self
}
/// Set ListItem as the selected item style.
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Set ListItem as the confirmed item style, it will show a check icon.
pub fn confirmed(mut self, confirmed: bool) -> Self {
self.confirmed = confirmed;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the suffix element of the input field, for example a clear button.
pub fn suffix<F, E>(mut self, builder: F) -> Self
where
F: Fn(&mut WindowContext) -> E + 'static,
E: IntoElement,
{
self.suffix = Some(Box::new(move |cx| builder(cx).into_any_element()));
self
}
pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
pub fn on_mouse_enter(
mut self,
handler: impl Fn(&MouseMoveEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_mouse_enter = Some(Box::new(handler));
self
}
}
impl Disableable for ListItem {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Selectable for ListItem {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl Styled for ListItem {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl ParentElement for ListItem {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ListItem {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let is_active = self.selected || self.confirmed;
self.base
.text_color(cx.theme().foreground)
.relative()
.items_center()
.justify_between()
.when_some(self.on_click, |this, on_click| {
if !self.disabled {
this.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, cx| {
cx.stop_propagation();
})
.on_click(on_click)
} else {
this
}
})
.when(is_active, |this| this.bg(cx.theme().list_active))
.when(!is_active && !self.disabled, |this| {
this.hover(|this| this.bg(cx.theme().list_hover))
})
// Mouse enter
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
if !self.disabled {
this.on_mouse_move(move |ev, cx| (on_mouse_enter)(ev, cx))
} else {
this
}
})
.child(
h_flex()
.w_full()
.items_center()
.justify_between()
.gap_x_1()
.child(div().w_full().children(self.children))
.when_some(self.check_icon, |this, icon| {
this.child(
div().w_5().items_center().justify_center().when(
self.confirmed,
|this| {
this.child(icon.small().text_color(cx.theme().muted_foreground))
},
),
)
}),
)
.when_some(self.suffix, |this, suffix| this.child(suffix(cx)))
}
}

View File

@@ -0,0 +1,5 @@
mod list;
mod list_item;
pub use list::*;
pub use list_item::*;

248
crates/ui/src/modal.rs Normal file
View File

@@ -0,0 +1,248 @@
use std::{rc::Rc, time::Duration};
use gpui::{
actions, anchored, div, hsla, prelude::FluentBuilder, px, relative, Animation,
AnimationExt as _, AnyElement, AppContext, Bounds, ClickEvent, Div, FocusHandle, Hsla,
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, Styled, WindowContext,
};
use crate::{
animation::cubic_bezier,
button::{Button, ButtonVariants as _},
theme::ActiveTheme as _,
v_flex, ContextModal, IconName, Sizable as _,
};
actions!(modal, [Escape]);
const CONTEXT: &str = "Modal";
pub fn init(cx: &mut AppContext) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
}
#[derive(IntoElement)]
pub struct Modal {
base: Div,
title: Option<AnyElement>,
footer: Option<AnyElement>,
content: Div,
width: Pixels,
max_width: Option<Pixels>,
margin_top: Option<Pixels>,
on_close: Rc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>,
show_close: bool,
overlay: bool,
keyboard: bool,
/// This will be change when open the modal, the focus handle is create when open the modal.
pub(crate) focus_handle: FocusHandle,
pub(crate) layer_ix: usize,
pub(crate) overlay_visible: bool,
}
pub(crate) fn overlay_color(overlay: bool, cx: &WindowContext) -> Hsla {
if !overlay {
return hsla(0., 0., 0., 0.);
}
if cx.theme().mode.is_dark() {
hsla(0., 1., 1., 0.06)
} else {
hsla(0., 0., 0., 0.06)
}
}
impl Modal {
pub fn new(cx: &mut WindowContext) -> Self {
let base = v_flex()
.bg(cx.theme().background)
.border_1()
.border_color(cx.theme().border)
.rounded_lg()
.shadow_xl()
.min_h_48()
.p_4()
.gap_4();
Self {
base,
focus_handle: cx.focus_handle(),
title: None,
footer: None,
content: v_flex(),
margin_top: None,
width: px(480.),
max_width: None,
overlay: true,
keyboard: true,
layer_ix: 0,
overlay_visible: true,
on_close: Rc::new(|_, _| {}),
show_close: true,
}
}
/// Sets the title of the modal.
pub fn title(mut self, title: impl IntoElement) -> Self {
self.title = Some(title.into_any_element());
self
}
/// Set the footer of the modal.
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
/// Sets the callback for when the modal is closed.
pub fn on_close(
mut self,
on_close: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_close = Rc::new(on_close);
self
}
/// Sets the false to hide close icon, default: true
pub fn show_close(mut self, show_close: bool) -> Self {
self.show_close = show_close;
self
}
/// Set the top offset of the modal, defaults to None, will use the 1/10 of the viewport height.
pub fn margin_top(mut self, margin_top: Pixels) -> Self {
self.margin_top = Some(margin_top);
self
}
/// Sets the width of the modal, defaults to 480px.
pub fn width(mut self, width: Pixels) -> Self {
self.width = width;
self
}
/// Set the maximum width of the modal, defaults to `None`.
pub fn max_w(mut self, max_width: Pixels) -> Self {
self.max_width = Some(max_width);
self
}
/// Set the overlay of the modal, defaults to `true`.
pub fn overlay(mut self, overlay: bool) -> Self {
self.overlay = overlay;
self
}
/// Set whether to support keyboard esc to close the modal, defaults to `true`.
pub fn keyboard(mut self, keyboard: bool) -> Self {
self.keyboard = keyboard;
self
}
pub(crate) fn has_overlay(&self) -> bool {
self.overlay
}
}
impl ParentElement for Modal {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.content.extend(elements);
}
}
impl Styled for Modal {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Modal {
fn render(self, cx: &mut WindowContext) -> impl gpui::IntoElement {
let layer_ix = self.layer_ix;
let on_close = self.on_close.clone();
let view_size = cx.viewport_size();
let bounds = Bounds {
origin: Point::default(),
size: view_size,
};
let offset_top = px(layer_ix as f32 * 16.);
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
let x = bounds.center().x - self.width / 2.;
anchored().snap_to_window().child(
div()
.occlude()
.w(view_size.width)
.h(view_size.height)
.when(self.overlay_visible, |this| {
this.bg(overlay_color(self.overlay, cx))
})
.when(self.overlay, |this| {
this.on_mouse_down(MouseButton::Left, {
let on_close = self.on_close.clone();
move |_, cx| {
on_close(&ClickEvent::default(), cx);
cx.close_modal();
}
})
})
.child(
self.base
.id(SharedString::from(format!("modal-{layer_ix}")))
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.when(self.keyboard, |this| {
this.on_action({
let on_close = self.on_close.clone();
move |_: &Escape, cx| {
// FIXME:
//
// Here some Modal have no focus_handle, so it will not work will Escape key.
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work.
on_close(&ClickEvent::default(), cx);
cx.close_modal();
}
})
})
.absolute()
.occlude()
.relative()
.left(x)
.top(y)
.w(self.width)
.when_some(self.max_width, |this, w| this.max_w(w))
.when_some(self.title, |this, title| {
this.child(div().line_height(relative(1.)).child(title))
})
.when(self.show_close, |this| {
this.child(
Button::new(SharedString::from(format!("modal-close-{layer_ix}")))
.absolute()
.top_2()
.right_2()
.small()
.ghost()
.icon(IconName::Close)
.on_click(move |_, cx| {
on_close(&ClickEvent::default(), cx);
cx.close_modal();
}),
)
})
.child(self.content)
.children(self.footer)
.with_animation(
"slide-down",
Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.)),
move |this, delta| {
let y_offset = px(0.) + delta * px(30.);
this.top(y + y_offset).opacity(delta)
},
),
),
)
}
}

View File

@@ -0,0 +1,369 @@
use std::{any::TypeId, collections::VecDeque, sync::Arc, time::Duration};
use gpui::{
div, prelude::FluentBuilder, px, Animation, AnimationExt, ClickEvent, DismissEvent, ElementId,
EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString,
StatefulInteractiveElement, Styled, View, ViewContext, VisualContext, WindowContext,
};
use smol::Timer;
use crate::{
animation::cubic_bezier,
button::{Button, ButtonVariants as _},
h_flex,
theme::ActiveTheme as _,
v_flex, Icon, IconName, Sizable as _, StyledExt,
};
pub enum NotificationType {
Info,
Success,
Warning,
Error,
}
#[derive(Debug, PartialEq, Clone)]
pub(crate) enum NotificationId {
Id(TypeId),
IdAndElementId(TypeId, ElementId),
}
impl From<TypeId> for NotificationId {
fn from(type_id: TypeId) -> Self {
Self::Id(type_id)
}
}
impl From<(TypeId, ElementId)> for NotificationId {
fn from((type_id, id): (TypeId, ElementId)) -> Self {
Self::IdAndElementId(type_id, id)
}
}
/// A notification element.
pub struct Notification {
/// The id is used make the notification unique.
/// Then you push a notification with the same id, the previous notification will be replaced.
///
/// None means the notification will be added to the end of the list.
id: NotificationId,
type_: NotificationType,
title: Option<SharedString>,
message: SharedString,
icon: Option<Icon>,
autohide: bool,
on_click: Option<Arc<dyn Fn(&ClickEvent, &mut WindowContext)>>,
closing: bool,
}
impl From<String> for Notification {
fn from(s: String) -> Self {
Self::new(s)
}
}
impl From<SharedString> for Notification {
fn from(s: SharedString) -> Self {
Self::new(s)
}
}
impl From<&'static str> for Notification {
fn from(s: &'static str) -> Self {
Self::new(s)
}
}
impl From<(NotificationType, &'static str)> for Notification {
fn from((type_, content): (NotificationType, &'static str)) -> Self {
Self::new(content).with_type(type_)
}
}
impl From<(NotificationType, SharedString)> for Notification {
fn from((type_, content): (NotificationType, SharedString)) -> Self {
Self::new(content).with_type(type_)
}
}
struct DefaultIdType;
impl Notification {
/// Create a new notification with the given content.
///
/// default width is 320px.
pub fn new(message: impl Into<SharedString>) -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
let id = (TypeId::of::<DefaultIdType>(), id.into());
Self {
id: id.into(),
title: None,
message: message.into(),
type_: NotificationType::Info,
icon: None,
autohide: true,
on_click: None,
closing: false,
}
}
pub fn info(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Info)
}
pub fn success(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Success)
}
pub fn warning(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Warning)
}
pub fn error(message: impl Into<SharedString>) -> Self {
Self::new(message).with_type(NotificationType::Error)
}
/// Set the type for unique identification of the notification.
///
/// ```rs
/// struct MyNotificationKind;
/// let notification = Notification::new("Hello").id::<MyNotificationKind>();
/// ```
pub fn id<T: Sized + 'static>(mut self) -> Self {
self.id = TypeId::of::<T>().into();
self
}
/// Set the type and id of the notification, used to uniquely identify the notification.
pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
self.id = (TypeId::of::<T>(), key.into()).into();
self
}
/// Set the title of the notification, default is None.
///
/// If title is None, the notification will not have a title.
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Set the icon of the notification.
///
/// If icon is None, the notification will use the default icon of the type.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set the type of the notification, default is NotificationType::Info.
pub fn with_type(mut self, type_: NotificationType) -> Self {
self.type_ = type_;
self
}
/// Set the auto hide of the notification, default is true.
pub fn autohide(mut self, autohide: bool) -> Self {
self.autohide = autohide;
self
}
/// Set the click callback of the notification.
pub fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_click = Some(Arc::new(on_click));
self
}
fn dismiss(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
self.closing = true;
cx.notify();
// Dismiss the notification after 0.15s to show the animation.
cx.spawn(|view, mut cx| async move {
Timer::after(Duration::from_secs_f32(0.15)).await;
cx.update(|cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| {
view.closing = false;
cx.emit(DismissEvent);
});
}
})
})
.detach()
}
}
impl EventEmitter<DismissEvent> for Notification {}
impl FluentBuilder for Notification {}
impl Render for Notification {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let closing = self.closing;
let icon = match self.icon.clone() {
Some(icon) => icon,
None => match self.type_ {
NotificationType::Info => Icon::new(IconName::Info).text_color(crate::blue_500()),
NotificationType::Success => {
Icon::new(IconName::CircleCheck).text_color(crate::green_500())
}
NotificationType::Warning => {
Icon::new(IconName::TriangleAlert).text_color(crate::yellow_500())
}
NotificationType::Error => {
Icon::new(IconName::CircleX).text_color(crate::red_500())
}
},
};
div()
.id("notification")
.group("")
.occlude()
.relative()
.w_96()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().popover)
.rounded_md()
.shadow_md()
.py_2()
.px_4()
.gap_3()
.child(div().absolute().top_3().left_4().child(icon))
.child(
v_flex()
.pl_6()
.gap_1()
.when_some(self.title.clone(), |this, title| {
this.child(div().text_sm().font_semibold().child(title))
})
.overflow_hidden()
.child(div().text_sm().child(self.message.clone())),
)
.when_some(self.on_click.clone(), |this, on_click| {
this.cursor_pointer()
.on_click(cx.listener(move |view, event, cx| {
view.dismiss(event, cx);
on_click(event, cx);
}))
})
.when(!self.autohide, |this| {
this.child(
h_flex()
.absolute()
.top_1()
.right_1()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new("close")
.icon(IconName::Close)
.ghost()
.xsmall()
.on_click(cx.listener(Self::dismiss)),
),
)
})
.with_animation(
ElementId::NamedInteger("slide-down".into(), closing as usize),
Animation::new(Duration::from_secs_f64(0.15))
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
move |this, delta| {
if closing {
let x_offset = px(0.) + delta * px(45.);
this.left(px(0.) + x_offset).opacity(1. - delta)
} else {
let y_offset = px(-45.) + delta * px(45.);
this.top(px(0.) + y_offset).opacity(delta)
}
},
)
}
}
/// A list of notifications.
pub struct NotificationList {
/// Notifications that will be auto hidden.
pub(crate) notifications: VecDeque<View<Notification>>,
expanded: bool,
}
impl NotificationList {
pub fn new(_cx: &mut ViewContext<Self>) -> Self {
Self {
notifications: VecDeque::new(),
expanded: false,
}
}
pub fn push(&mut self, notification: impl Into<Notification>, cx: &mut ViewContext<Self>) {
let notification = notification.into();
let id = notification.id.clone();
let autohide = notification.autohide;
// Remove the notification by id, for keep unique.
self.notifications.retain(|note| note.read(cx).id != id);
let notification = cx.new_view(|_| notification);
cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
view.notifications.retain(|note| id != note.read(cx).id);
})
.detach();
self.notifications.push_back(notification.clone());
if autohide {
// Sleep for 5 seconds to autohide the notification
cx.spawn(|_, mut cx| async move {
Timer::after(Duration::from_secs(5)).await;
if let Err(err) = notification
.update(&mut cx, |note, cx| note.dismiss(&ClickEvent::default(), cx))
{
println!("failed to auto hide notification: {:?}", err);
}
})
.detach();
}
cx.notify();
}
pub fn clear(&mut self, cx: &mut ViewContext<Self>) {
self.notifications.clear();
cx.notify();
}
pub fn notifications(&self) -> Vec<View<Notification>> {
self.notifications.iter().cloned().collect()
}
}
impl Render for NotificationList {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
let size = cx.viewport_size();
let items = self.notifications.iter().rev().take(10).rev().cloned();
div()
.absolute()
.flex()
.top_4()
.bottom_4()
.right_4()
.justify_end()
.child(
v_flex()
.id("notification-list")
.absolute()
.relative()
.right_0()
.h(size.height - px(8.))
.on_hover(cx.listener(|view, hovered, cx| {
view.expanded = *hovered;
cx.notify()
}))
.gap_3()
.children(items),
)
}
}

View File

@@ -0,0 +1,168 @@
use gpui::{
actions, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement,
KeyBinding, ParentElement, Render, SharedString, Styled, Subscription, View, ViewContext,
VisualContext,
};
use regex::Regex;
use crate::{
button::{Button, ButtonVariants as _},
h_flex,
input::{InputEvent, TextInput},
prelude::FluentBuilder,
theme::ActiveTheme,
IconName, Sizable, Size, StyledExt,
};
actions!(number_input, [Increment, Decrement]);
const KEY_CONTENT: &str = "NumberInput";
pub fn init(cx: &mut AppContext) {
cx.bind_keys(vec![
KeyBinding::new("up", Increment, Some(KEY_CONTENT)),
KeyBinding::new("down", Decrement, Some(KEY_CONTENT)),
]);
}
pub struct NumberInput {
input: View<TextInput>,
_subscriptions: Vec<Subscription>,
}
impl NumberInput {
pub fn new(cx: &mut ViewContext<Self>) -> Self {
// Default pattern for the number input.
let pattern = Regex::new(r"^-?(\d+)?\.?(\d+)?$").unwrap();
let input = cx.new_view(|cx| TextInput::new(cx).pattern(pattern).appearance(false));
let _subscriptions = vec![cx.subscribe(&input, |_, _, event: &InputEvent, cx| {
cx.emit(NumberInputEvent::Input(event.clone()));
})];
Self {
input,
_subscriptions,
}
}
pub fn placeholder(
self,
placeholder: impl Into<SharedString>,
cx: &mut ViewContext<Self>,
) -> Self {
self.input
.update(cx, |input, _| input.set_placeholder(placeholder));
self
}
pub fn set_placeholder(&self, text: impl Into<SharedString>, cx: &mut ViewContext<Self>) {
self.input.update(cx, |input, _| {
input.set_placeholder(text);
});
}
pub fn pattern(self, pattern: regex::Regex, cx: &mut ViewContext<Self>) -> Self {
self.input.update(cx, |input, _| input.set_pattern(pattern));
self
}
pub fn set_size(self, size: Size, cx: &mut ViewContext<Self>) -> Self {
self.input.update(cx, |input, cx| input.set_size(size, cx));
self
}
pub fn small(self, cx: &mut ViewContext<Self>) -> Self {
self.set_size(Size::Small, cx)
}
pub fn xsmall(self, cx: &mut ViewContext<Self>) -> Self {
self.set_size(Size::XSmall, cx)
}
pub fn large(self, cx: &mut ViewContext<Self>) -> Self {
self.set_size(Size::Large, cx)
}
pub fn set_value(&self, text: impl Into<SharedString>, cx: &mut ViewContext<Self>) {
self.input.update(cx, |input, cx| input.set_text(text, cx))
}
pub fn set_disabled(&self, disabled: bool, cx: &mut ViewContext<Self>) {
self.input
.update(cx, |input, cx| input.set_disabled(disabled, cx));
}
pub fn increment(&mut self, cx: &mut ViewContext<Self>) {
self.handle_increment(&Increment, cx);
}
pub fn decrement(&mut self, cx: &mut ViewContext<Self>) {
self.handle_decrement(&Decrement, cx);
}
fn handle_increment(&mut self, _: &Increment, cx: &mut ViewContext<Self>) {
self.on_step(StepAction::Increment, cx);
}
fn handle_decrement(&mut self, _: &Decrement, cx: &mut ViewContext<Self>) {
self.on_step(StepAction::Decrement, cx);
}
fn on_step(&mut self, action: StepAction, cx: &mut ViewContext<Self>) {
cx.emit(NumberInputEvent::Step(action));
}
}
impl FocusableView for NumberInput {
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.input.focus_handle(cx)
}
}
pub enum StepAction {
Decrement,
Increment,
}
pub enum NumberInputEvent {
Input(InputEvent),
Step(StepAction),
}
impl EventEmitter<NumberInputEvent> for NumberInput {}
impl Render for NumberInput {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let focused = self.input.focus_handle(cx).is_focused(cx);
h_flex()
.key_context(KEY_CONTENT)
.on_action(cx.listener(Self::handle_increment))
.on_action(cx.listener(Self::handle_decrement))
.flex_1()
.px_1()
.gap_x_3()
.bg(cx.theme().background)
.border_color(cx.theme().border)
.border_1()
.rounded_md()
.when(focused, |this| this.outline(cx))
.child(
Button::new("minus")
.ghost()
.xsmall()
.icon(IconName::Minus)
.on_click(cx.listener(|this, _, cx| this.on_step(StepAction::Decrement, cx))),
)
.child(self.input.clone())
.child(
Button::new("plus")
.ghost()
.xsmall()
.icon(IconName::Plus)
.on_click(cx.listener(|this, _, cx| this.on_step(StepAction::Increment, cx))),
)
}
}

408
crates/ui/src/popover.rs Normal file
View File

@@ -0,0 +1,408 @@
use gpui::{
actions, anchored, deferred, div, prelude::FluentBuilder as _, px, AnchorCorner, AnyElement,
AppContext, Bounds, DismissEvent, DispatchPhase, Element, ElementId, EventEmitter, FocusHandle,
FocusableView, GlobalElementId, Hitbox, InteractiveElement as _, IntoElement, KeyBinding,
LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
Style, StyleRefinement, Styled, View, ViewContext, VisualContext, WindowContext,
};
use std::{cell::RefCell, rc::Rc};
use crate::{Selectable, StyledExt as _};
const CONTEXT: &str = "Popover";
actions!(popover, [Escape]);
pub fn init(cx: &mut AppContext) {
cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))])
}
pub struct PopoverContent {
focus_handle: FocusHandle,
content: Rc<dyn Fn(&mut ViewContext<Self>) -> AnyElement>,
max_width: Option<Pixels>,
}
impl PopoverContent {
pub fn new<B>(cx: &mut WindowContext, content: B) -> Self
where
B: Fn(&mut ViewContext<Self>) -> AnyElement + 'static,
{
let focus_handle = cx.focus_handle();
Self {
focus_handle,
content: Rc::new(content),
max_width: None,
}
}
pub fn max_w(mut self, max_width: Pixels) -> Self {
self.max_width = Some(max_width);
self
}
}
impl EventEmitter<DismissEvent> for PopoverContent {}
impl FocusableView for PopoverContent {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PopoverContent {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.track_focus(&self.focus_handle)
.key_context(CONTEXT)
.on_action(cx.listener(|_, _: &Escape, cx| cx.emit(DismissEvent)))
.p_2()
.when_some(self.max_width, |this, v| this.max_w(v))
.child(self.content.clone()(cx))
}
}
pub struct Popover<M: ManagedView> {
id: ElementId,
anchor: AnchorCorner,
trigger: Option<Box<dyn FnOnce(bool, &WindowContext) -> AnyElement + 'static>>,
content: Option<Rc<dyn Fn(&mut WindowContext) -> View<M> + 'static>>,
/// Style for trigger element.
/// This is used for hotfix the trigger element style to support w_full.
trigger_style: Option<StyleRefinement>,
mouse_button: MouseButton,
no_style: bool,
}
impl<M> Popover<M>
where
M: ManagedView,
{
/// Create a new Popover with `view` mode.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
anchor: AnchorCorner::TopLeft,
trigger: None,
trigger_style: None,
content: None,
mouse_button: MouseButton::Left,
no_style: false,
}
}
pub fn anchor(mut self, anchor: AnchorCorner) -> Self {
self.anchor = anchor;
self
}
/// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
pub fn trigger<T>(mut self, trigger: T) -> Self
where
T: Selectable + IntoElement + 'static,
{
self.trigger = Some(Box::new(|is_open, _| {
trigger.selected(is_open).into_any_element()
}));
self
}
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.trigger_style = Some(style);
self
}
/// Set the content of the popover.
///
/// The `content` is a closure that returns an `AnyElement`.
pub fn content<C>(mut self, content: C) -> Self
where
C: Fn(&mut WindowContext) -> View<M> + 'static,
{
self.content = Some(Rc::new(content));
self
}
/// Set whether the popover no style, default is `false`.
///
/// If no style:
///
/// - The popover will not have a bg, border, shadow, or padding.
/// - The click out of the popover will not dismiss it.
pub fn no_style(mut self) -> Self {
self.no_style = true;
self
}
fn render_trigger(&mut self, is_open: bool, cx: &mut WindowContext) -> AnyElement {
let Some(trigger) = self.trigger.take() else {
return div().into_any_element();
};
(trigger)(is_open, cx)
}
fn resolved_corner(&self, bounds: Bounds<Pixels>) -> Point<Pixels> {
match self.anchor {
AnchorCorner::TopLeft => AnchorCorner::BottomLeft,
AnchorCorner::TopRight => AnchorCorner::BottomRight,
AnchorCorner::BottomLeft => AnchorCorner::TopLeft,
AnchorCorner::BottomRight => AnchorCorner::TopRight,
}
.corner(bounds)
}
fn with_element_state<R>(
&mut self,
id: &GlobalElementId,
cx: &mut WindowContext,
f: impl FnOnce(&mut Self, &mut PopoverElementState<M>, &mut WindowContext) -> R,
) -> R {
cx.with_optional_element_state::<PopoverElementState<M>, _>(
Some(id),
|element_state, cx| {
let mut element_state = element_state.unwrap().unwrap_or_default();
let result = f(self, &mut element_state, cx);
(result, Some(element_state))
},
)
}
}
impl<M> IntoElement for Popover<M>
where
M: ManagedView,
{
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
pub struct PopoverElementState<M> {
trigger_layout_id: Option<LayoutId>,
popover_layout_id: Option<LayoutId>,
popover_element: Option<AnyElement>,
trigger_element: Option<AnyElement>,
content_view: Rc<RefCell<Option<View<M>>>>,
/// Trigger bounds for positioning the popover.
trigger_bounds: Option<Bounds<Pixels>>,
}
impl<M> Default for PopoverElementState<M> {
fn default() -> Self {
Self {
trigger_layout_id: None,
popover_layout_id: None,
popover_element: None,
trigger_element: None,
content_view: Rc::new(RefCell::new(None)),
trigger_bounds: None,
}
}
}
pub struct PrepaintState {
hitbox: Hitbox,
/// Trigger bounds for limit a rect to handle mouse click.
trigger_bounds: Option<Bounds<Pixels>>,
}
impl<M: ManagedView> Element for Popover<M> {
type RequestLayoutState = PopoverElementState<M>;
type PrepaintState = PrepaintState;
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn request_layout(
&mut self,
id: Option<&gpui::GlobalElementId>,
cx: &mut WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
// FIXME: Remove this and find a better way to handle this.
// Apply trigger style, for support w_full for trigger.
//
// If remove this, the trigger will not support w_full.
if let Some(trigger_style) = self.trigger_style.clone() {
if let Some(width) = trigger_style.size.width {
style.size.width = width;
}
if let Some(display) = trigger_style.display {
style.display = display;
}
}
self.with_element_state(id.unwrap(), cx, |view, element_state, cx| {
let mut popover_layout_id = None;
let mut popover_element = None;
let mut is_open = false;
if let Some(content_view) = element_state.content_view.borrow_mut().as_mut() {
is_open = true;
let mut anchored = anchored()
.snap_to_window_with_margin(px(8.))
.anchor(view.anchor);
if let Some(trigger_bounds) = element_state.trigger_bounds {
anchored = anchored.position(view.resolved_corner(trigger_bounds));
}
let mut element = {
let content_view_mut = element_state.content_view.clone();
let anchor = view.anchor;
let no_style = view.no_style;
deferred(
anchored.child(
div()
.size_full()
.occlude()
.when(!no_style, |this| this.popover_style(cx))
.map(|this| match anchor {
AnchorCorner::TopLeft | AnchorCorner::TopRight => {
this.top_1p5()
}
AnchorCorner::BottomLeft | AnchorCorner::BottomRight => {
this.bottom_1p5()
}
})
.child(content_view.clone())
.when(!no_style, |this| {
this.on_mouse_down_out(move |_, cx| {
// Update the element_state.content_view to `None`,
// so that the `paint`` method will not paint it.
*content_view_mut.borrow_mut() = None;
cx.refresh();
})
}),
),
)
.with_priority(1)
.into_any()
};
popover_layout_id = Some(element.request_layout(cx));
popover_element = Some(element);
}
let mut trigger_element = view.render_trigger(is_open, cx);
let trigger_layout_id = trigger_element.request_layout(cx);
let layout_id = cx.request_layout(
style,
Some(trigger_layout_id).into_iter().chain(popover_layout_id),
);
(
layout_id,
PopoverElementState {
trigger_layout_id: Some(trigger_layout_id),
popover_layout_id,
popover_element,
trigger_element: Some(trigger_element),
..Default::default()
},
)
})
}
fn prepaint(
&mut self,
_id: Option<&gpui::GlobalElementId>,
_bounds: gpui::Bounds<gpui::Pixels>,
request_layout: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) -> Self::PrepaintState {
if let Some(element) = &mut request_layout.trigger_element {
element.prepaint(cx);
}
if let Some(element) = &mut request_layout.popover_element {
element.prepaint(cx);
}
let trigger_bounds = request_layout
.trigger_layout_id
.map(|id| cx.layout_bounds(id));
// Prepare the popover, for get the bounds of it for open window size.
let _ = request_layout
.popover_layout_id
.map(|id| cx.layout_bounds(id));
let hitbox = cx.insert_hitbox(trigger_bounds.unwrap_or_default(), false);
PrepaintState {
trigger_bounds,
hitbox,
}
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_bounds: Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
prepaint: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
self.with_element_state(id.unwrap(), cx, |this, element_state, cx| {
element_state.trigger_bounds = prepaint.trigger_bounds;
if let Some(mut element) = request_layout.trigger_element.take() {
element.paint(cx);
}
if let Some(mut element) = request_layout.popover_element.take() {
element.paint(cx);
return;
}
// When mouse click down in the trigger bounds, open the popover.
let Some(content_build) = this.content.take() else {
return;
};
let old_content_view = element_state.content_view.clone();
let hitbox_id = prepaint.hitbox.id;
let mouse_button = this.mouse_button;
cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| {
if phase == DispatchPhase::Bubble
&& event.button == mouse_button
&& hitbox_id.is_hovered(cx)
{
cx.stop_propagation();
cx.prevent_default();
let new_content_view = (content_build)(cx);
let old_content_view1 = old_content_view.clone();
let previous_focus_handle = cx.focused();
cx.subscribe(&new_content_view, move |modal, _: &DismissEvent, cx| {
if modal.focus_handle(cx).contains_focused(cx) {
if let Some(previous_focus_handle) = previous_focus_handle.as_ref() {
cx.focus(previous_focus_handle);
}
}
*old_content_view1.borrow_mut() = None;
cx.refresh();
})
.detach();
cx.focus_view(&new_content_view);
*old_content_view.borrow_mut() = Some(new_content_view);
cx.refresh();
}
});
});
}
}

778
crates/ui/src/popup_menu.rs Normal file
View File

@@ -0,0 +1,778 @@
use std::cell::Cell;
use std::ops::Deref;
use std::rc::Rc;
use gpui::{
actions, div, prelude::FluentBuilder, px, Action, AppContext, DismissEvent, EventEmitter,
FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render,
SharedString, View, ViewContext, VisualContext as _, WindowContext,
};
use gpui::{
anchored, canvas, rems, AnchorCorner, AnyElement, Bounds, Edges, FocusableView, Keystroke,
ScrollHandle, StatefulInteractiveElement, Styled, WeakView,
};
use crate::scroll::{Scrollbar, ScrollbarState};
use crate::StyledExt;
use crate::{
button::Button, h_flex, list::ListItem, popover::Popover, theme::ActiveTheme, v_flex, Icon,
IconName, Selectable, Sizable as _,
};
actions!(menu, [Confirm, Dismiss, SelectNext, SelectPrev]);
pub fn init(cx: &mut AppContext) {
let context = Some("PopupMenu");
cx.bind_keys([
KeyBinding::new("enter", Confirm, context),
KeyBinding::new("escape", Dismiss, context),
KeyBinding::new("up", SelectPrev, context),
KeyBinding::new("down", SelectNext, context),
]);
}
pub trait PopupMenuExt: Styled + Selectable + IntoElement + 'static {
/// Create a popup menu with the given items, anchored to the TopLeft corner
fn popup_menu(
self,
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
) -> Popover<PopupMenu> {
self.popup_menu_with_anchor(AnchorCorner::TopLeft, f)
}
/// Create a popup menu with the given items, anchored to the given corner
fn popup_menu_with_anchor(
mut self,
anchor: impl Into<AnchorCorner>,
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
) -> Popover<PopupMenu> {
let style = self.style().clone();
let element_id = self.element_id();
Popover::new(SharedString::from(format!("popup-menu:{:?}", element_id)))
.no_style()
.trigger(self)
.trigger_style(style)
.anchor(anchor.into())
.content(move |cx| PopupMenu::build(cx, |menu, cx| f(menu, cx)))
}
}
impl PopupMenuExt for Button {}
enum PopupMenuItem {
Separator,
Item {
icon: Option<Icon>,
label: SharedString,
action: Option<Box<dyn Action>>,
handler: Rc<dyn Fn(&mut WindowContext)>,
},
ElementItem {
render: Box<dyn Fn(&mut WindowContext) -> AnyElement + 'static>,
handler: Rc<dyn Fn(&mut WindowContext)>,
},
Submenu {
icon: Option<Icon>,
label: SharedString,
menu: View<PopupMenu>,
},
}
impl PopupMenuItem {
fn is_clickable(&self) -> bool {
!matches!(self, PopupMenuItem::Separator)
}
fn is_separator(&self) -> bool {
matches!(self, PopupMenuItem::Separator)
}
fn has_icon(&self) -> bool {
matches!(self, PopupMenuItem::Item { icon: Some(_), .. })
}
}
pub struct PopupMenu {
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakView<Self>>,
focus_handle: FocusHandle,
menu_items: Vec<PopupMenuItem>,
has_icon: bool,
selected_index: Option<usize>,
min_width: Pixels,
max_width: Pixels,
hovered_menu_ix: Option<usize>,
bounds: Bounds<Pixels>,
scrollable: bool,
scroll_handle: ScrollHandle,
scroll_state: Rc<Cell<ScrollbarState>>,
action_focus_handle: Option<FocusHandle>,
_subscriptions: [gpui::Subscription; 1],
}
impl PopupMenu {
pub fn build(
cx: &mut WindowContext,
f: impl FnOnce(Self, &mut ViewContext<PopupMenu>) -> Self,
) -> View<Self> {
cx.new_view(|cx| {
let focus_handle = cx.focus_handle();
let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut PopupMenu, cx| {
this.dismiss(&Dismiss, cx)
});
let menu = Self {
focus_handle,
action_focus_handle: None,
parent_menu: None,
menu_items: Vec::new(),
selected_index: None,
min_width: px(120.),
max_width: px(500.),
has_icon: false,
hovered_menu_ix: None,
bounds: Bounds::default(),
scrollable: false,
scroll_handle: ScrollHandle::default(),
scroll_state: Rc::new(Cell::new(ScrollbarState::default())),
_subscriptions: [_on_blur_subscription],
};
cx.refresh();
f(menu, cx)
})
}
/// Bind the focus handle of the menu, when clicked, it will focus back to this handle and then dispatch the action
pub fn track_focus(mut self, focus_handle: &FocusHandle) -> Self {
self.action_focus_handle = Some(focus_handle.clone());
self
}
/// Set min width of the popup menu, default is 120px
pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
self.min_width = width.into();
self
}
/// Set max width of the popup menu, default is 500px
pub fn max_w(mut self, width: impl Into<Pixels>) -> Self {
self.max_width = width.into();
self
}
/// Set the menu to be scrollable to show vertical scrollbar.
///
/// NOTE: If this is true, the sub-menus will cannot be support.
pub fn scrollable(mut self) -> Self {
self.scrollable = true;
self
}
/// Add Menu Item
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.add_menu_item(label, None, action);
self
}
/// Add Menu to open link
pub fn link(mut self, label: impl Into<SharedString>, href: impl Into<String>) -> Self {
let href = href.into();
self.menu_items.push(PopupMenuItem::Item {
icon: None,
label: label.into(),
action: None,
handler: Rc::new(move |cx| cx.open_url(&href)),
});
self
}
/// Add Menu to open link
pub fn link_with_icon(
mut self,
label: impl Into<SharedString>,
icon: impl Into<Icon>,
href: impl Into<String>,
) -> Self {
let href = href.into();
self.menu_items.push(PopupMenuItem::Item {
icon: Some(icon.into()),
label: label.into(),
action: None,
handler: Rc::new(move |cx| cx.open_url(&href)),
});
self
}
/// Add Menu Item with Icon
pub fn menu_with_icon(
mut self,
label: impl Into<SharedString>,
icon: impl Into<Icon>,
action: Box<dyn Action>,
) -> Self {
self.add_menu_item(label, Some(icon.into()), action);
self
}
/// Add Menu Item with check icon
pub fn menu_with_check(
mut self,
label: impl Into<SharedString>,
checked: bool,
action: Box<dyn Action>,
) -> Self {
if checked {
self.add_menu_item(label, Some(IconName::Check.into()), action);
} else {
self.add_menu_item(label, None, action);
}
self
}
/// Add Menu Item with custom element render.
pub fn menu_with_element<F, E>(mut self, builder: F, action: Box<dyn Action>) -> Self
where
F: Fn(&mut WindowContext) -> E + 'static,
E: IntoElement,
{
self.menu_items.push(PopupMenuItem::ElementItem {
render: Box::new(move |cx| builder(cx).into_any_element()),
handler: self.wrap_handler(action),
});
self
}
fn wrap_handler(&self, action: Box<dyn Action>) -> Rc<dyn Fn(&mut WindowContext)> {
let action_focus_handle = self.action_focus_handle.clone();
Rc::new(move |cx| {
cx.activate_window();
// Focus back to the user expected focus handle
// Then the actions listened on that focus handle can be received
//
// For example:
//
// TabPanel
// |- PopupMenu
// |- PanelContent (actions are listened here)
//
// The `PopupMenu` and `PanelContent` are at the same level in the TabPanel
// If the actions are listened on the `PanelContent`,
// it can't receive the actions from the `PopupMenu`, unless we focus on `PanelContent`.
if let Some(handle) = action_focus_handle.as_ref() {
cx.focus(&handle);
}
cx.dispatch_action(action.boxed_clone());
})
}
fn add_menu_item(
&mut self,
label: impl Into<SharedString>,
icon: Option<Icon>,
action: Box<dyn Action>,
) -> &mut Self {
if icon.is_some() {
self.has_icon = true;
}
self.menu_items.push(PopupMenuItem::Item {
icon,
label: label.into(),
action: Some(action.boxed_clone()),
handler: self.wrap_handler(action),
});
self
}
/// Add a separator Menu Item
pub fn separator(mut self) -> Self {
if self.menu_items.is_empty() {
return self;
}
if let Some(PopupMenuItem::Separator) = self.menu_items.last() {
return self;
}
self.menu_items.push(PopupMenuItem::Separator);
self
}
pub fn submenu(
self,
label: impl Into<SharedString>,
cx: &mut ViewContext<Self>,
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
self.submenu_with_icon(None, label, cx, f)
}
/// Add a Submenu item with icon
pub fn submenu_with_icon(
mut self,
icon: Option<Icon>,
label: impl Into<SharedString>,
cx: &mut ViewContext<Self>,
f: impl Fn(PopupMenu, &mut ViewContext<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
let submenu = PopupMenu::build(cx, f);
let parent_menu = cx.view().downgrade();
submenu.update(cx, |view, _| {
view.parent_menu = Some(parent_menu);
});
self.menu_items.push(PopupMenuItem::Submenu {
icon,
label: label.into(),
menu: submenu,
});
self
}
pub(crate) fn active_submenu(&self) -> Option<View<PopupMenu>> {
if let Some(ix) = self.hovered_menu_ix {
if let Some(item) = self.menu_items.get(ix) {
return match item {
PopupMenuItem::Submenu { menu, .. } => Some(menu.clone()),
_ => None,
};
}
}
None
}
pub fn is_empty(&self) -> bool {
self.menu_items.is_empty()
}
fn clickable_menu_items(&self) -> impl Iterator<Item = (usize, &PopupMenuItem)> {
self.menu_items
.iter()
.enumerate()
.filter(|(_, item)| item.is_clickable())
}
fn on_click(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
cx.stop_propagation();
cx.prevent_default();
self.selected_index = Some(ix);
self.confirm(&Confirm, cx);
}
fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
match self.selected_index {
Some(index) => {
let item = self.menu_items.get(index);
match item {
Some(PopupMenuItem::Item { handler, .. }) => {
handler(cx);
self.dismiss(&Dismiss, cx)
}
Some(PopupMenuItem::ElementItem { handler, .. }) => {
handler(cx);
self.dismiss(&Dismiss, cx)
}
_ => {}
}
}
_ => {}
}
}
fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
let count = self.clickable_menu_items().count();
if count > 0 {
let ix = self
.selected_index
.map(|index| if index == count - 1 { 0 } else { index + 1 })
.unwrap_or(0);
self.selected_index = Some(ix);
cx.notify();
}
}
fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
let count = self.clickable_menu_items().count();
if count > 0 {
let ix = self
.selected_index
.map(|index| if index == count - 1 { 0 } else { index - 1 })
.unwrap_or(count - 1);
self.selected_index = Some(ix);
cx.notify();
}
}
fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
if self.active_submenu().is_some() {
return;
}
cx.emit(DismissEvent);
// Dismiss parent menu, when this menu is dismissed
if let Some(parent_menu) = self.parent_menu.clone().and_then(|menu| menu.upgrade()) {
parent_menu.update(cx, |view, cx| {
view.hovered_menu_ix = None;
view.dismiss(&Dismiss, cx);
})
}
}
fn render_keybinding(
action: Option<Box<dyn Action>>,
cx: &ViewContext<Self>,
) -> Option<impl IntoElement> {
if let Some(action) = action {
if let Some(keybinding) = cx.bindings_for_action(action.deref()).first() {
let el = div().text_color(cx.theme().muted_foreground).children(
keybinding
.keystrokes()
.into_iter()
.map(|key| key_shortcut(key.clone())),
);
return Some(el);
}
}
return None;
}
fn render_icon(
has_icon: bool,
icon: Option<Icon>,
_: &ViewContext<Self>,
) -> Option<impl IntoElement> {
let icon_placeholder = if has_icon { Some(Icon::empty()) } else { None };
if !has_icon {
return None;
}
let icon = h_flex()
.w_3p5()
.h_3p5()
.items_center()
.justify_center()
.text_sm()
.map(|this| {
if let Some(icon) = icon {
this.child(icon.clone().small())
} else {
this.children(icon_placeholder.clone())
}
});
Some(icon)
}
}
impl FluentBuilder for PopupMenu {}
impl EventEmitter<DismissEvent> for PopupMenu {}
impl FocusableView for PopupMenu {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PopupMenu {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let view = cx.view().clone();
let has_icon = self.menu_items.iter().any(|item| item.has_icon());
let items_count = self.menu_items.len();
let max_width = self.max_width;
let bounds = self.bounds;
let window_haft_height = cx.window_bounds().get_bounds().size.height * 0.5;
let max_height = window_haft_height.min(px(450.));
const ITEM_HEIGHT: Pixels = px(26.);
v_flex()
.id("popup-menu")
.key_context("PopupMenu")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::select_prev))
.on_action(cx.listener(Self::confirm))
.on_action(cx.listener(Self::dismiss))
.on_mouse_down_out(cx.listener(|this, _, cx| this.dismiss(&Dismiss, cx)))
.popover_style(cx)
.text_color(cx.theme().popover_foreground)
.relative()
.p_1()
.child(
div()
.id("popup-menu-items")
.when(self.scrollable, |this| {
this.max_h(max_height)
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
})
.child(
v_flex()
.gap_y_0p5()
.min_w(self.min_width)
.max_w(self.max_width)
.min_w(rems(8.))
.child({
canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
)
.absolute()
.size_full()
})
.children(
self.menu_items
.iter_mut()
.enumerate()
// Skip last separator
.filter(|(ix, item)| {
!(*ix == items_count - 1 && item.is_separator())
})
.map(|(ix, item)| {
let this = ListItem::new(("menu-item", ix))
.relative()
.text_sm()
.py_0()
.px_2()
.rounded_md()
.items_center()
.on_mouse_enter(cx.listener(move |this, _, cx| {
this.hovered_menu_ix = Some(ix);
cx.notify();
}));
match item {
PopupMenuItem::Separator => {
this.h_auto().p_0().disabled(true).child(
div()
.rounded_none()
.h(px(1.))
.mx_neg_1()
.my_0p5()
.bg(cx.theme().muted),
)
}
PopupMenuItem::ElementItem { render, .. } => this
.on_click(cx.listener(move |this, _, cx| {
this.on_click(ix, cx)
}))
.child(
h_flex()
.min_h(ITEM_HEIGHT)
.items_center()
.gap_x_1p5()
.children(Self::render_icon(
has_icon, None, cx,
))
.child((render)(cx)),
),
PopupMenuItem::Item {
icon,
label,
action,
..
} => {
let action = action
.as_ref()
.map(|action| action.boxed_clone());
let key = Self::render_keybinding(action, cx);
this.on_click(cx.listener(move |this, _, cx| {
this.on_click(ix, cx)
}))
.child(
h_flex()
.h(ITEM_HEIGHT)
.items_center()
.gap_x_1p5()
.children(Self::render_icon(
has_icon,
icon.clone(),
cx,
))
.child(
h_flex()
.flex_1()
.gap_2()
.items_center()
.justify_between()
.child(label.clone())
.children(key),
),
)
}
PopupMenuItem::Submenu { icon, label, menu } => this
.when(self.hovered_menu_ix == Some(ix), |this| {
this.selected(true)
})
.child(
h_flex()
.items_start()
.child(
h_flex()
.size_full()
.items_center()
.gap_x_1p5()
.children(Self::render_icon(
has_icon,
icon.clone(),
cx,
))
.child(
h_flex()
.flex_1()
.gap_2()
.items_center()
.justify_between()
.child(label.clone())
.child(
IconName::ChevronRight,
),
),
)
.when_some(
self.hovered_menu_ix,
|this, hovered_ix| {
let (anchor, left) =
if cx.bounds().size.width
- bounds.origin.x
< max_width
{
(
AnchorCorner::TopRight,
-px(15.),
)
} else {
(
AnchorCorner::TopLeft,
bounds.size.width
- px(10.),
)
};
let top = if bounds.origin.y
+ bounds.size.height
> cx.bounds().size.height
{
px(32.)
} else {
-px(10.)
};
if hovered_ix == ix {
this.child(
anchored()
.anchor(anchor)
.child(
div()
.occlude()
.top(top)
.left(left)
.child(menu.clone()),
)
.snap_to_window_with_margin(
Edges::all(px(8.)),
),
)
} else {
this
}
},
),
),
}
}),
),
),
)
.when(self.scrollable, |this| {
// TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
this.child(
div()
.absolute()
.top_0()
.left_0()
.right_0p5()
.bottom_0()
.child(Scrollbar::vertical(
cx.entity_id(),
self.scroll_state.clone(),
self.scroll_handle.clone(),
self.bounds.size,
)),
)
})
}
}
/// Return the Platform specific keybinding string by KeyStroke
pub fn key_shortcut(key: Keystroke) -> String {
if cfg!(target_os = "macos") {
return format!("{}", key);
}
let mut parts = vec![];
if key.modifiers.control {
parts.push("Ctrl");
}
if key.modifiers.alt {
parts.push("Alt");
}
if key.modifiers.platform {
parts.push("Win");
}
if key.modifiers.shift {
parts.push("Shift");
}
// Capitalize the first letter
let key = if let Some(first_c) = key.key.chars().next() {
format!("{}{}", first_c.to_uppercase(), &key.key[1..])
} else {
key.key.to_string()
};
parts.push(&key);
parts.join("+")
}
#[cfg(test)]
mod tests {
#[test]
fn test_key_shortcut() {
use super::key_shortcut;
use gpui::Keystroke;
if cfg!(target_os = "windows") {
assert_eq!(key_shortcut(Keystroke::parse("a").unwrap()), "A");
assert_eq!(key_shortcut(Keystroke::parse("ctrl-a").unwrap()), "Ctrl+A");
assert_eq!(
key_shortcut(Keystroke::parse("ctrl-alt-a").unwrap()),
"Ctrl+Alt+A"
);
assert_eq!(
key_shortcut(Keystroke::parse("ctrl-alt-shift-a").unwrap()),
"Ctrl+Alt+Shift+A"
);
assert_eq!(
key_shortcut(Keystroke::parse("ctrl-alt-shift-win-a").unwrap()),
"Ctrl+Alt+Win+Shift+A"
);
assert_eq!(
key_shortcut(Keystroke::parse("ctrl-shift-backspace").unwrap()),
"Ctrl+Shift+Backspace"
);
}
}
}

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

@@ -0,0 +1,9 @@
//! The prelude of this crate. When building UI in Zed you almost always want to import this.
pub use gpui::prelude::*;
#[allow(unused_imports)]
pub use gpui::{
div, px, relative, rems, AbsoluteLength, DefiniteLength, Div, Element, ElementId,
InteractiveElement, ParentElement, Pixels, Rems, RenderOnce, SharedString, Styled, ViewContext,
WindowContext,
};

56
crates/ui/src/progress.rs Normal file
View File

@@ -0,0 +1,56 @@
use crate::theme::ActiveTheme;
use gpui::{
div, prelude::FluentBuilder, px, relative, IntoElement, ParentElement, RenderOnce, Styled,
WindowContext,
};
/// A Progress bar element.
#[derive(IntoElement)]
pub struct Progress {
value: f32,
height: f32,
}
impl Progress {
pub fn new() -> Self {
Progress {
value: Default::default(),
height: 8.,
}
}
pub fn value(mut self, value: f32) -> Self {
self.value = value;
self
}
}
impl RenderOnce for Progress {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let rounded = px(self.height / 2.);
let relative_w = relative(match self.value {
v if v < 0. => 0.,
v if v > 100. => 1.,
v => v / 100.,
});
div()
.relative()
.h(px(self.height))
.rounded(rounded)
.bg(cx.theme().progress_bar.opacity(0.2))
.child(
div()
.absolute()
.top_0()
.left_0()
.h_full()
.w(relative_w)
.bg(cx.theme().progress_bar)
.map(|this| match self.value {
v if v >= 100. => this.rounded(rounded),
_ => this.rounded_l(rounded),
}),
)
}
}

109
crates/ui/src/radio.rs Normal file
View File

@@ -0,0 +1,109 @@
use crate::{h_flex, theme::ActiveTheme, IconName};
use gpui::{
div, prelude::FluentBuilder, relative, svg, ElementId, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, WindowContext,
};
/// A Radio element.
///
/// This is not included the Radio group implementation, you can manage the group by yourself.
#[derive(IntoElement)]
pub struct Radio {
id: ElementId,
label: Option<SharedString>,
checked: bool,
disabled: bool,
on_click: Option<Box<dyn Fn(&bool, &mut WindowContext) + 'static>>,
}
impl Radio {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
label: None,
checked: false,
disabled: false,
on_click: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn on_click(mut self, handler: impl Fn(&bool, &mut WindowContext) + 'static) -> Self {
self.on_click = Some(Box::new(handler));
self
}
}
impl RenderOnce for Radio {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let color = if self.disabled {
cx.theme().primary.opacity(0.5)
} else {
cx.theme().primary
};
h_flex()
.id(self.id)
.gap_x_2()
.text_color(cx.theme().foreground)
.items_center()
.line_height(relative(1.))
.child(
div()
.relative()
.size_4()
.flex_shrink_0()
.rounded_full()
.border_1()
.border_color(color)
.when(self.checked, |this| this.bg(color))
.child(
svg()
.absolute()
.top_px()
.left_px()
.size_3()
.text_color(color)
.when(self.checked, |this| {
this.text_color(cx.theme().primary_foreground)
})
.map(|this| match self.checked {
true => this.path(IconName::Check.path()),
false => this,
}),
),
)
.when_some(self.label, |this, label| {
this.child(
div()
.size_full()
.overflow_x_hidden()
.text_ellipsis()
.line_height(relative(1.))
.child(label),
)
})
.when_some(
self.on_click.filter(|_| !self.disabled),
|this, on_click| {
this.on_click(move |_event, cx| {
on_click(&!self.checked, cx);
})
},
)
}
}

View File

@@ -0,0 +1,18 @@
use gpui::{Axis, ViewContext};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
pub fn h_resizable(cx: &mut ViewContext<ResizablePanelGroup>) -> ResizablePanelGroup {
ResizablePanelGroup::new(cx).axis(Axis::Horizontal)
}
pub fn v_resizable(cx: &mut ViewContext<ResizablePanelGroup>) -> ResizablePanelGroup {
ResizablePanelGroup::new(cx).axis(Axis::Vertical)
}
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}

View File

@@ -0,0 +1,487 @@
use std::rc::Rc;
use gpui::{
canvas, div, prelude::FluentBuilder, px, relative, Along, AnyElement, AnyView, Axis, Bounds,
Element, Entity, EntityId, EventEmitter, IntoElement, IsZero, MouseMoveEvent, MouseUpEvent,
ParentElement, Pixels, Render, StatefulInteractiveElement as _, Style, Styled, View,
ViewContext, VisualContext as _, WeakView, WindowContext,
};
use crate::{h_flex, v_flex, AxisExt};
use super::resize_handle;
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub enum ResizablePanelEvent {
Resized,
}
#[derive(Clone, Render)]
pub struct DragPanel(pub (EntityId, usize, Axis));
#[derive(Clone)]
pub struct ResizablePanelGroup {
panels: Vec<View<ResizablePanel>>,
sizes: Vec<Pixels>,
axis: Axis,
size: Option<Pixels>,
bounds: Bounds<Pixels>,
resizing_panel_ix: Option<usize>,
}
impl ResizablePanelGroup {
pub(super) fn new(_cx: &mut ViewContext<Self>) -> Self {
Self {
axis: Axis::Horizontal,
sizes: Vec::new(),
panels: Vec::new(),
size: None,
bounds: Bounds::default(),
resizing_panel_ix: None,
}
}
pub fn load(&mut self, sizes: Vec<Pixels>, panels: Vec<View<ResizablePanel>>) {
self.sizes = sizes;
self.panels = panels;
}
/// Set the axis of the resizable panel group, default is horizontal.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
pub(crate) fn set_axis(&mut self, axis: Axis, cx: &mut ViewContext<Self>) {
self.axis = axis;
cx.notify();
}
/// Add a resizable panel to the group.
pub fn child(mut self, panel: ResizablePanel, cx: &mut ViewContext<Self>) -> Self {
self.add_child(panel, cx);
self
}
/// Add a ResizablePanelGroup as a child to the group.
pub fn group(self, group: ResizablePanelGroup, cx: &mut ViewContext<Self>) -> Self {
let group: ResizablePanelGroup = group;
let size = group.size;
let panel = ResizablePanel::new()
.content_view(cx.new_view(|_| group).into())
.when_some(size, |this, size| this.size(size));
self.child(panel, cx)
}
/// Set size of the resizable panel group
///
/// - When the axis is horizontal, the size is the height of the group.
/// - When the axis is vertical, the size is the width of the group.
pub fn size(mut self, size: Pixels) -> Self {
self.size = Some(size);
self
}
/// Returns the sizes of the resizable panels.
pub(crate) fn sizes(&self) -> Vec<Pixels> {
self.sizes.clone()
}
/// Calculates the sum of all panel sizes within the group.
pub fn total_size(&self) -> Pixels {
self.sizes.iter().fold(px(0.0), |acc, &size| acc + size)
}
pub fn add_child(&mut self, panel: ResizablePanel, cx: &mut ViewContext<Self>) {
let mut panel = panel;
panel.axis = self.axis;
panel.group = Some(cx.view().downgrade());
self.sizes.push(panel.initial_size.unwrap_or_default());
self.panels.push(cx.new_view(|_| panel));
}
pub fn insert_child(&mut self, panel: ResizablePanel, ix: usize, cx: &mut ViewContext<Self>) {
let mut panel = panel;
panel.axis = self.axis;
panel.group = Some(cx.view().downgrade());
self.sizes
.insert(ix, panel.initial_size.unwrap_or_default());
self.panels.insert(ix, cx.new_view(|_| panel));
cx.notify()
}
/// Replace a child panel with a new panel at the given index.
pub(crate) fn replace_child(
&mut self,
panel: ResizablePanel,
ix: usize,
cx: &mut ViewContext<Self>,
) {
let mut panel = panel;
let old_panel = self.panels[ix].clone();
let old_panel_initial_size = old_panel.read(cx).initial_size;
let old_panel_size_ratio = old_panel.read(cx).size_ratio;
panel.initial_size = old_panel_initial_size;
panel.size_ratio = old_panel_size_ratio;
panel.axis = self.axis;
panel.group = Some(cx.view().downgrade());
self.sizes[ix] = panel.initial_size.unwrap_or_default();
self.panels[ix] = cx.new_view(|_| panel);
cx.notify()
}
pub fn remove_child(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
self.sizes.remove(ix);
self.panels.remove(ix);
cx.notify()
}
pub(crate) fn remove_all_children(&mut self, cx: &mut ViewContext<Self>) {
self.sizes.clear();
self.panels.clear();
cx.notify()
}
fn render_resize_handle(&self, ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement {
let view = cx.view().clone();
resize_handle(("resizable-handle", ix), self.axis).on_drag(
DragPanel((cx.entity_id(), ix, self.axis)),
move |drag_panel, _, cx| {
cx.stop_propagation();
// Set current resizing panel ix
view.update(cx, |view, _| {
view.resizing_panel_ix = Some(ix);
});
cx.new_view(|_| drag_panel.clone())
},
)
}
fn done_resizing(&mut self, cx: &mut ViewContext<Self>) {
cx.emit(ResizablePanelEvent::Resized);
self.resizing_panel_ix = None;
}
fn sync_real_panel_sizes(&mut self, cx: &WindowContext) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.read(cx).bounds.size.along(self.axis)
}
}
/// The `ix`` is the index of the panel to resize,
/// and the `size` is the new size for the panel.
fn resize_panels(&mut self, ix: usize, size: Pixels, cx: &mut ViewContext<Self>) {
let mut ix = ix;
// Only resize the left panels.
if ix >= self.panels.len() - 1 {
return;
}
let size = size.floor();
let container_size = self.bounds.size.along(self.axis);
self.sync_real_panel_sizes(cx);
let mut changed = size - self.sizes[ix];
let is_expand = changed > px(0.);
let main_ix = ix;
let mut new_sizes = self.sizes.clone();
if is_expand {
new_sizes[ix] = size;
// Now to expand logic is correct.
while changed > px(0.) && ix < self.panels.len() - 1 {
ix += 1;
let available_size = (new_sizes[ix] - PANEL_MIN_SIZE).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let new_size = size.max(PANEL_MIN_SIZE);
new_sizes[ix] = new_size;
changed = size - PANEL_MIN_SIZE;
new_sizes[ix + 1] += self.sizes[ix] - new_size;
while changed < px(0.) && ix > 0 {
ix -= 1;
let available_size = self.sizes[ix] - PANEL_MIN_SIZE;
let to_increase = (changed).min(available_size);
new_sizes[ix] += to_increase;
changed += to_increase;
}
}
// If total size exceeds container size, adjust the main panel
let total_size: Pixels = new_sizes.iter().map(|s| s.0).sum::<f32>().into();
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(PANEL_MIN_SIZE);
}
let total_size = new_sizes.iter().fold(px(0.0), |acc, &size| acc + size);
self.sizes = new_sizes;
for (i, panel) in self.panels.iter().enumerate() {
let size = self.sizes[i];
if size > px(0.) {
panel.update(cx, |this, _| {
this.size = Some(size);
this.size_ratio = Some(size / total_size);
});
}
}
}
}
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
impl Render for ResizablePanelGroup {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let view = cx.view().clone();
let container = if self.axis.is_horizontal() {
h_flex()
} else {
v_flex()
};
container
.size_full()
.children(self.panels.iter().enumerate().map(|(ix, panel)| {
if ix > 0 {
let handle = self.render_resize_handle(ix - 1, cx);
panel.update(cx, |view, _| {
view.resize_handle = Some(handle.into_any_element())
});
}
panel.clone()
}))
.child({
canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
)
.absolute()
.size_full()
})
.child(ResizePanelGroupElement {
view: cx.view().clone(),
axis: self.axis,
})
}
}
pub struct ResizablePanel {
group: Option<WeakView<ResizablePanelGroup>>,
/// Initial size is the size that the panel has when it is created.
initial_size: Option<Pixels>,
/// size is the size that the panel has when it is resized or adjusted by flex layout.
size: Option<Pixels>,
/// the size ratio that the panel has relative to its group
size_ratio: Option<f32>,
axis: Axis,
content_builder: Option<Rc<dyn Fn(&mut WindowContext) -> AnyElement>>,
content_view: Option<AnyView>,
/// The bounds of the resizable panel, when render the bounds will be updated.
bounds: Bounds<Pixels>,
resize_handle: Option<AnyElement>,
}
impl ResizablePanel {
pub(super) fn new() -> Self {
Self {
group: None,
initial_size: None,
size: None,
size_ratio: None,
axis: Axis::Horizontal,
content_builder: None,
content_view: None,
bounds: Bounds::default(),
resize_handle: None,
}
}
pub fn content<F>(mut self, content: F) -> Self
where
F: Fn(&mut WindowContext) -> AnyElement + 'static,
{
self.content_builder = Some(Rc::new(content));
self
}
pub fn content_view(mut self, content: AnyView) -> Self {
self.content_view = Some(content);
self
}
/// Set the initial size of the panel.
pub fn size(mut self, size: Pixels) -> Self {
self.initial_size = Some(size);
self
}
/// Save the real panel size, and update group sizes
fn update_size(&mut self, bounds: Bounds<Pixels>, cx: &mut ViewContext<Self>) {
let new_size = bounds.size.along(self.axis);
self.bounds = bounds;
self.size = Some(new_size);
let panel_view = cx.view().clone();
if let Some(group) = self.group.as_ref() {
_ = group.update(cx, |view, _| {
if let Some(ix) = view
.panels
.iter()
.position(|v| v.entity_id() == panel_view.entity_id())
{
view.sizes[ix] = new_size;
}
});
}
cx.notify();
}
}
impl FluentBuilder for ResizablePanel {}
impl Render for ResizablePanel {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let view = cx.view().clone();
let total_size = self
.group
.as_ref()
.and_then(|group| group.upgrade())
.map(|group| group.read(cx).total_size());
div()
.flex()
.flex_grow()
.size_full()
.relative()
.when(self.initial_size.is_none(), |this| this.flex_shrink())
.when(self.axis.is_vertical(), |this| this.min_h(PANEL_MIN_SIZE))
.when(self.axis.is_horizontal(), |this| this.min_w(PANEL_MIN_SIZE))
.when_some(self.initial_size, |this, size| {
if size.is_zero() {
this
} else {
// The `self.size` is None, that mean the initial size for the panel, so we need set flex_shrink_0
// To let it keep the initial size.
this.when(self.size.is_none() && size > px(0.), |this| {
this.flex_shrink_0()
})
.flex_basis(size)
}
})
.map(|this| match (self.size_ratio, self.size, total_size) {
(Some(size_ratio), _, _) => this.flex_basis(relative(size_ratio)),
(None, Some(size), Some(total_size)) => {
this.flex_basis(relative(size / total_size))
}
(None, Some(size), None) => this.flex_basis(size),
_ => this,
})
.child({
canvas(
move |bounds, cx| view.update(cx, |r, cx| r.update_size(bounds, cx)),
|_, _, _| {},
)
.absolute()
.size_full()
})
.when_some(self.content_builder.clone(), |this, c| this.child(c(cx)))
.when_some(self.content_view.clone(), |this, c| this.child(c))
.when_some(self.resize_handle.take(), |this, c| this.child(c))
}
}
struct ResizePanelGroupElement {
axis: Axis,
view: View<ResizablePanelGroup>,
}
impl IntoElement for ResizePanelGroupElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ResizePanelGroupElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
cx: &mut WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(cx.request_layout(Style::default(), None), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut WindowContext,
) -> Self::PrepaintState {
()
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
cx.on_mouse_event({
let view = self.view.clone();
let axis = self.axis;
let current_ix = view.read(cx).resizing_panel_ix;
move |e: &MouseMoveEvent, phase, cx| {
if phase.bubble() {
if let Some(ix) = current_ix {
view.update(cx, |view, cx| {
let panel = view
.panels
.get(ix)
.expect("BUG: invalid panel index")
.read(cx);
match axis {
Axis::Horizontal => {
view.resize_panels(ix, e.position.x - panel.bounds.left(), cx)
}
Axis::Vertical => {
view.resize_panels(ix, e.position.y - panel.bounds.top(), cx);
}
}
})
}
}
}
});
// When any mouse up, stop dragging
cx.on_mouse_event({
let view = self.view.clone();
move |_: &MouseUpEvent, phase, cx| {
if phase.bubble() {
view.update(cx, |view, cx| view.done_resizing(cx));
}
}
})
}
}

View File

@@ -0,0 +1,72 @@
use gpui::{
div, prelude::FluentBuilder as _, px, Axis, Div, ElementId, InteractiveElement, IntoElement,
ParentElement as _, Pixels, RenderOnce, Stateful, StatefulInteractiveElement, Styled as _,
WindowContext,
};
use crate::{theme::ActiveTheme as _, AxisExt as _};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
#[derive(IntoElement)]
pub(crate) struct ResizeHandle {
base: Stateful<Div>,
axis: Axis,
}
impl ResizeHandle {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
Self {
base: div().id(id.into()),
axis,
}
}
}
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle(id: impl Into<ElementId>, axis: Axis) -> ResizeHandle {
ResizeHandle::new(id, axis)
}
impl InteractiveElement for ResizeHandle {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for ResizeHandle {}
impl RenderOnce for ResizeHandle {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let neg_offset = -HANDLE_PADDING;
self.base
.occlude()
.absolute()
.flex_shrink_0()
.when(self.axis.is_horizontal(), |this| {
this.cursor_col_resize()
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(self.axis.is_vertical(), |this| {
this.cursor_row_resize()
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
})
.child(
div()
.bg(cx.theme().border)
.when(self.axis.is_horizontal(), |this| {
this.h_full().w(HANDLE_SIZE)
})
.when(self.axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
}
}

355
crates/ui/src/root.rs Normal file
View File

@@ -0,0 +1,355 @@
use crate::{
drawer::Drawer,
modal::Modal,
notification::{Notification, NotificationList},
theme::ActiveTheme,
};
use gpui::{
div, AnyView, FocusHandle, InteractiveElement, IntoElement, ParentElement as _, Render, Styled,
View, ViewContext, VisualContext as _, WindowContext,
};
use std::{
ops::{Deref, DerefMut},
rc::Rc,
};
/// Extension trait for [`WindowContext`] and [`ViewContext`] to add drawer functionality.
pub trait ContextModal: Sized {
/// Opens a Drawer.
fn open_drawer<F>(&mut self, build: F)
where
F: Fn(Drawer, &mut WindowContext) -> Drawer + 'static;
/// Return true, if there is an active Drawer.
fn has_active_drawer(&self) -> bool;
/// Closes the active Drawer.
fn close_drawer(&mut self);
/// Opens a Modal.
fn open_modal<F>(&mut self, build: F)
where
F: Fn(Modal, &mut WindowContext) -> Modal + 'static;
/// Return true, if there is an active Modal.
fn has_active_modal(&self) -> bool;
/// Closes the last active Modal.
fn close_modal(&mut self);
/// Closes all active Modals.
fn close_all_modals(&mut self);
/// Pushes a notification to the notification list.
fn push_notification(&mut self, note: impl Into<Notification>);
fn clear_notifications(&mut self);
/// Returns number of notifications.
fn notifications(&self) -> Rc<Vec<View<Notification>>>;
}
impl ContextModal for WindowContext<'_> {
fn open_drawer<F>(&mut self, build: F)
where
F: Fn(Drawer, &mut WindowContext) -> Drawer + 'static,
{
Root::update(self, move |root, cx| {
if root.active_drawer.is_none() {
root.previous_focus_handle = cx.focused();
}
let focus_handle = cx.focus_handle();
focus_handle.focus(cx);
root.active_drawer = Some(ActiveDrawer {
focus_handle,
builder: Rc::new(build),
});
cx.notify();
})
}
fn has_active_drawer(&self) -> bool {
Root::read(&self).active_drawer.is_some()
}
fn close_drawer(&mut self) {
Root::update(self, |root, cx| {
root.active_drawer = None;
root.focus_back(cx);
cx.notify();
})
}
fn open_modal<F>(&mut self, build: F)
where
F: Fn(Modal, &mut WindowContext) -> Modal + 'static,
{
Root::update(self, move |root, cx| {
// Only save focus handle if there are no active modals.
// This is used to restore focus when all modals are closed.
if root.active_modals.len() == 0 {
root.previous_focus_handle = cx.focused();
}
let focus_handle = cx.focus_handle();
focus_handle.focus(cx);
root.active_modals.push(ActiveModal {
focus_handle,
builder: Rc::new(build),
});
cx.notify();
})
}
fn has_active_modal(&self) -> bool {
Root::read(&self).active_modals.len() > 0
}
fn close_modal(&mut self) {
Root::update(self, move |root, cx| {
root.active_modals.pop();
if let Some(top_modal) = root.active_modals.last() {
// Focus the next modal.
top_modal.focus_handle.focus(cx);
} else {
// Restore focus if there are no more modals.
root.focus_back(cx);
}
cx.notify();
})
}
fn close_all_modals(&mut self) {
Root::update(self, |root, cx| {
root.active_modals.clear();
root.focus_back(cx);
cx.notify();
})
}
fn push_notification(&mut self, note: impl Into<Notification>) {
let note = note.into();
Root::update(self, move |root, cx| {
root.notification.update(cx, |view, cx| view.push(note, cx));
cx.notify();
})
}
fn clear_notifications(&mut self) {
Root::update(self, move |root, cx| {
root.notification.update(cx, |view, cx| view.clear(cx));
cx.notify();
})
}
fn notifications(&self) -> Rc<Vec<View<Notification>>> {
Rc::new(Root::read(&self).notification.read(&self).notifications())
}
}
impl<V> ContextModal for ViewContext<'_, V> {
fn open_drawer<F>(&mut self, build: F)
where
F: Fn(Drawer, &mut WindowContext) -> Drawer + 'static,
{
self.deref_mut().open_drawer(build)
}
fn has_active_modal(&self) -> bool {
self.deref().has_active_modal()
}
fn close_drawer(&mut self) {
self.deref_mut().close_drawer()
}
fn open_modal<F>(&mut self, build: F)
where
F: Fn(Modal, &mut WindowContext) -> Modal + 'static,
{
self.deref_mut().open_modal(build)
}
fn has_active_drawer(&self) -> bool {
self.deref().has_active_drawer()
}
/// Close the last active modal.
fn close_modal(&mut self) {
self.deref_mut().close_modal()
}
/// Close all modals.
fn close_all_modals(&mut self) {
self.deref_mut().close_all_modals()
}
fn push_notification(&mut self, note: impl Into<Notification>) {
self.deref_mut().push_notification(note)
}
fn clear_notifications(&mut self) {
self.deref_mut().clear_notifications()
}
fn notifications(&self) -> Rc<Vec<View<Notification>>> {
self.deref().notifications()
}
}
/// Root is a view for the App window for as the top level view (Must be the first view in the window).
///
/// It is used to manage the Drawer, Modal, and Notification.
pub struct Root {
/// Used to store the focus handle of the previous view.
/// When the Modal, Drawer closes, we will focus back to the previous view.
previous_focus_handle: Option<FocusHandle>,
active_drawer: Option<ActiveDrawer>,
active_modals: Vec<ActiveModal>,
pub notification: View<NotificationList>,
view: AnyView,
}
#[derive(Clone)]
struct ActiveDrawer {
focus_handle: FocusHandle,
builder: Rc<dyn Fn(Drawer, &mut WindowContext) -> Drawer + 'static>,
}
#[derive(Clone)]
struct ActiveModal {
focus_handle: FocusHandle,
builder: Rc<dyn Fn(Modal, &mut WindowContext) -> Modal + 'static>,
}
impl Root {
pub fn new(view: AnyView, cx: &mut ViewContext<Self>) -> Self {
Self {
previous_focus_handle: None,
active_drawer: None,
active_modals: Vec::new(),
notification: cx.new_view(NotificationList::new),
view,
}
}
pub fn update<F>(cx: &mut WindowContext, f: F)
where
F: FnOnce(&mut Self, &mut ViewContext<Self>) + 'static,
{
let root = cx
.window_handle()
.downcast::<Root>()
.and_then(|w| w.root_view(cx).ok())
.expect("The window root view should be of type `ui::Root`.");
root.update(cx, |root, cx| f(root, cx))
}
pub fn read<'a>(cx: &'a WindowContext) -> &'a Self {
let root = cx
.window_handle()
.downcast::<Root>()
.and_then(|w| w.root_view(cx).ok())
.expect("The window root view should be of type `ui::Root`.");
root.read(cx)
}
fn focus_back(&mut self, cx: &mut WindowContext) {
if let Some(handle) = self.previous_focus_handle.clone() {
cx.focus(&handle);
}
}
// Render Notification layer.
pub fn render_notification_layer(cx: &mut WindowContext) -> Option<impl IntoElement> {
let root = cx
.window_handle()
.downcast::<Root>()
.and_then(|w| w.root_view(cx).ok())
.expect("The window root view should be of type `ui::Root`.");
Some(div().child(root.read(cx).notification.clone()))
}
/// Render the Drawer layer.
pub fn render_drawer_layer(cx: &mut WindowContext) -> Option<impl IntoElement> {
let root = cx
.window_handle()
.downcast::<Root>()
.and_then(|w| w.root_view(cx).ok())
.expect("The window root view should be of type `ui::Root`.");
if let Some(active_drawer) = root.read(cx).active_drawer.clone() {
let mut drawer = Drawer::new(cx);
drawer = (active_drawer.builder)(drawer, cx);
drawer.focus_handle = active_drawer.focus_handle.clone();
return Some(div().child(drawer));
}
None
}
/// Render the Modal layer.
pub fn render_modal_layer(cx: &mut WindowContext) -> Option<impl IntoElement> {
let root = cx
.window_handle()
.downcast::<Root>()
.and_then(|w| w.root_view(cx).ok())
.expect("The window root view should be of type `ui::Root`.");
let active_modals = root.read(cx).active_modals.clone();
let mut has_overlay = false;
if active_modals.is_empty() {
return None;
}
Some(
div().children(active_modals.iter().enumerate().map(|(i, active_modal)| {
let mut modal = Modal::new(cx);
modal = (active_modal.builder)(modal, cx);
modal.layer_ix = i;
// Give the modal the focus handle, because `modal` is a temporary value, is not possible to
// keep the focus handle in the modal.
//
// So we keep the focus handle in the `active_modal`, this is owned by the `Root`.
modal.focus_handle = active_modal.focus_handle.clone();
// Keep only have one overlay, we only render the first modal with overlay.
if has_overlay {
modal.overlay_visible = false;
}
if modal.has_overlay() {
has_overlay = true;
}
modal
})),
)
}
/// Return the root view of the Root.
pub fn view(&self) -> &AnyView {
&self.view
}
}
impl Render for Root {
fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
let base_font_size = cx.theme().font_size;
cx.set_rem_size(base_font_size);
div()
.id("root")
.size_full()
.font_family(".SystemUIFont")
.bg(cx.theme().background)
.text_color(cx.theme().foreground)
.child(self.view.clone())
}
}

View File

@@ -0,0 +1,7 @@
mod scrollable;
mod scrollable_mask;
mod scrollbar;
pub use scrollable::*;
pub use scrollable_mask::*;
pub use scrollbar::*;

View File

@@ -0,0 +1,231 @@
use std::{cell::Cell, rc::Rc};
use super::{Scrollbar, ScrollbarAxis, ScrollbarState};
use gpui::{
canvas, div, relative, AnyElement, Div, Element, ElementId, EntityId, GlobalElementId,
InteractiveElement, IntoElement, ParentElement, Pixels, Position, ScrollHandle, SharedString,
Size, Stateful, StatefulInteractiveElement, Style, StyleRefinement, Styled, WindowContext,
};
/// A scroll view is a container that allows the user to scroll through a large amount of content.
pub struct Scrollable<E> {
id: ElementId,
element: Option<E>,
view_id: EntityId,
axis: ScrollbarAxis,
/// This is a fake element to handle Styled, InteractiveElement, not used.
_element: Stateful<Div>,
}
impl<E> Scrollable<E>
where
E: Element,
{
pub(crate) fn new(view_id: EntityId, element: E, axis: ScrollbarAxis) -> Self {
let id = ElementId::Name(SharedString::from(format!(
"ScrollView:{}-{:?}",
view_id,
element.id(),
)));
Self {
element: Some(element),
_element: div().id("fake"),
id,
view_id,
axis,
}
}
/// Set only a vertical scrollbar.
pub fn vertical(mut self) -> Self {
self.set_axis(ScrollbarAxis::Vertical);
self
}
/// Set only a horizontal scrollbar.
/// In current implementation, this is not supported yet.
pub fn horizontal(mut self) -> Self {
self.set_axis(ScrollbarAxis::Horizontal);
self
}
/// Set the axis of the scroll view.
pub fn set_axis(&mut self, axis: ScrollbarAxis) {
self.axis = axis;
}
fn with_element_state<R>(
&mut self,
id: &GlobalElementId,
cx: &mut WindowContext,
f: impl FnOnce(&mut Self, &mut ScrollViewState, &mut WindowContext) -> R,
) -> R {
cx.with_optional_element_state::<ScrollViewState, _>(Some(id), |element_state, cx| {
let mut element_state = element_state.unwrap().unwrap_or_default();
let result = f(self, &mut element_state, cx);
(result, Some(element_state))
})
}
}
pub struct ScrollViewState {
scroll_size: Rc<Cell<Size<Pixels>>>,
state: Rc<Cell<ScrollbarState>>,
handle: ScrollHandle,
}
impl Default for ScrollViewState {
fn default() -> Self {
Self {
handle: ScrollHandle::new(),
scroll_size: Rc::new(Cell::new(Size::default())),
state: Rc::new(Cell::new(ScrollbarState::default())),
}
}
}
impl<E> ParentElement for Scrollable<E>
where
E: Element + ParentElement,
{
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
if let Some(element) = &mut self.element {
element.extend(elements);
}
}
}
impl<E> Styled for Scrollable<E>
where
E: Element + Styled,
{
fn style(&mut self) -> &mut StyleRefinement {
if let Some(element) = &mut self.element {
element.style()
} else {
self._element.style()
}
}
}
impl<E> InteractiveElement for Scrollable<E>
where
E: Element + InteractiveElement,
{
fn interactivity(&mut self) -> &mut gpui::Interactivity {
if let Some(element) = &mut self.element {
element.interactivity()
} else {
self._element.interactivity()
}
}
}
impl<E> StatefulInteractiveElement for Scrollable<E> where E: Element + StatefulInteractiveElement {}
impl<E> IntoElement for Scrollable<E>
where
E: Element,
{
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl<E> Element for Scrollable<E>
where
E: Element,
{
type RequestLayoutState = AnyElement;
type PrepaintState = ScrollViewState;
fn id(&self) -> Option<gpui::ElementId> {
Some(self.id.clone())
}
fn request_layout(
&mut self,
id: Option<&gpui::GlobalElementId>,
cx: &mut gpui::WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.flex_grow = 1.0;
style.position = Position::Relative;
style.size.width = relative(1.0).into();
style.size.height = relative(1.0).into();
let axis = self.axis;
let view_id = self.view_id;
let scroll_id = self.id.clone();
let content = self.element.take().map(|c| c.into_any_element());
self.with_element_state(id.unwrap(), cx, |_, element_state, cx| {
let handle = element_state.handle.clone();
let state = element_state.state.clone();
let scroll_size = element_state.scroll_size.clone();
let mut element = div()
.relative()
.size_full()
.overflow_hidden()
.child(
div()
.id(scroll_id)
.track_scroll(&handle)
.overflow_scroll()
.relative()
.size_full()
.child(div().children(content).child({
let scroll_size = element_state.scroll_size.clone();
canvas(move |b, _| scroll_size.set(b.size), |_, _, _| {})
.absolute()
.size_full()
})),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(
Scrollbar::both(view_id, state, handle.clone(), scroll_size.get())
.axis(axis),
),
)
.into_any_element();
let element_id = element.request_layout(cx);
let layout_id = cx.request_layout(style, vec![element_id]);
(layout_id, element)
})
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<Pixels>,
element: &mut Self::RequestLayoutState,
cx: &mut gpui::WindowContext,
) -> Self::PrepaintState {
element.prepaint(cx);
// do nothing
ScrollViewState::default()
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<Pixels>,
element: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
cx: &mut gpui::WindowContext,
) {
element.paint(cx)
}
}

View File

@@ -0,0 +1,160 @@
use gpui::{
px, relative, AnyView, Bounds, ContentMask, Corners, Edges, Element, ElementId,
GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, Point,
Position, ScrollHandle, ScrollWheelEvent, Style, WindowContext,
};
/// The scroll axis direction.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollableAxis {
/// Horizontal scroll.
Horizontal,
/// Vertical scroll.
Vertical,
}
/// Make a scrollable mask element to cover the parent view with the mouse wheel event listening.
///
/// When the mouse wheel is scrolled, will move the `scroll_handle` scrolling with the `axis` direction.
/// You can use this `scroll_handle` to control what you want to scroll.
/// This is only can handle once axis scrolling.
pub struct ScrollableMask {
view: AnyView,
axis: ScrollableAxis,
scroll_handle: ScrollHandle,
debug: Option<Hsla>,
}
impl ScrollableMask {
/// Create a new scrollable mask element.
pub fn new(
view: impl Into<AnyView>,
axis: ScrollableAxis,
scroll_handle: &ScrollHandle,
) -> Self {
Self {
view: view.into(),
scroll_handle: scroll_handle.clone(),
axis,
debug: None,
}
}
/// Enable the debug border, to show the mask bounds.
#[allow(dead_code)]
pub fn debug(mut self) -> Self {
self.debug = Some(gpui::yellow());
self
}
}
impl IntoElement for ScrollableMask {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ScrollableMask {
type RequestLayoutState = ();
type PrepaintState = Hitbox;
fn id(&self) -> Option<ElementId> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
cx: &mut WindowContext,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
// Set the layout style relative to the table view to get same size.
style.position = Position::Absolute;
style.flex_grow = 1.0;
style.flex_shrink = 1.0;
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
(cx.request_layout(style, None), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) -> Self::PrepaintState {
// Move y to bounds height to cover the parent view.
let cover_bounds = Bounds {
origin: Point {
x: bounds.origin.x,
y: bounds.origin.y - bounds.size.height,
},
size: bounds.size,
};
cx.insert_hitbox(cover_bounds, false)
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
let line_height = cx.line_height();
let bounds = hitbox.bounds;
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
if let Some(color) = self.debug {
cx.paint_quad(PaintQuad {
bounds,
border_widths: Edges::all(px(1.0)),
border_color: color,
background: gpui::transparent_white(),
corner_radii: Corners::all(px(0.)),
});
}
cx.on_mouse_event({
let hitbox = hitbox.clone();
let mouse_position = cx.mouse_position();
let scroll_handle = self.scroll_handle.clone();
let old_offset = scroll_handle.offset();
let view_id = self.view.entity_id();
let is_horizontal = self.axis == ScrollableAxis::Horizontal;
move |event: &ScrollWheelEvent, phase, cx| {
if bounds.contains(&mouse_position) && phase.bubble() && hitbox.is_hovered(cx) {
let delta = event.delta.pixel_delta(line_height);
if is_horizontal && !delta.x.is_zero() {
// When is horizontal scroll, move the horizontal scroll handle to make scrolling.
let mut offset = scroll_handle.offset();
offset.x += delta.x;
scroll_handle.set_offset(offset);
}
if !is_horizontal && !delta.y.is_zero() {
// When is vertical scroll, move the vertical scroll handle to make scrolling.
let mut offset = scroll_handle.offset();
offset.y += delta.y;
scroll_handle.set_offset(offset);
}
if old_offset != scroll_handle.offset() {
cx.notify(Some(view_id));
cx.stop_propagation();
}
}
}
});
});
}
}

View File

@@ -0,0 +1,630 @@
use std::{cell::Cell, rc::Rc, time::Instant};
use crate::theme::ActiveTheme;
use gpui::{
fill, point, px, relative, Bounds, ContentMask, Edges, Element, EntityId, Hitbox, IntoElement,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle,
ScrollWheelEvent, Style, UniformListScrollHandle,
};
const MIN_THUMB_SIZE: f32 = 80.;
const THUMB_RADIUS: Pixels = Pixels(3.0);
const THUMB_INSET: Pixels = Pixels(4.);
pub trait ScrollHandleOffsetable {
fn offset(&self) -> Point<Pixels>;
fn set_offset(&self, offset: Point<Pixels>);
fn is_uniform_list(&self) -> bool {
false
}
}
impl ScrollHandleOffsetable for ScrollHandle {
fn offset(&self) -> Point<Pixels> {
self.offset()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.set_offset(offset);
}
}
impl ScrollHandleOffsetable for UniformListScrollHandle {
fn offset(&self) -> Point<Pixels> {
self.0.borrow().base_handle.offset()
}
fn set_offset(&self, offset: Point<Pixels>) {
self.0.borrow_mut().base_handle.set_offset(offset)
}
fn is_uniform_list(&self) -> bool {
true
}
}
#[derive(Debug, Clone, Copy)]
pub struct ScrollbarState {
hovered_axis: Option<ScrollbarAxis>,
hovered_on_thumb: Option<ScrollbarAxis>,
dragged_axis: Option<ScrollbarAxis>,
drag_pos: Point<Pixels>,
last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>,
}
impl Default for ScrollbarState {
fn default() -> Self {
Self {
hovered_axis: None,
hovered_on_thumb: None,
dragged_axis: None,
drag_pos: point(px(0.), px(0.)),
last_scroll_offset: point(px(0.), px(0.)),
last_scroll_time: None,
}
}
}
impl ScrollbarState {
pub fn new() -> Self {
Self::default()
}
fn with_drag_pos(&self, axis: ScrollbarAxis, pos: Point<Pixels>) -> Self {
let mut state = *self;
if axis.is_vertical() {
state.drag_pos.y = pos.y;
} else {
state.drag_pos.x = pos.x;
}
state.dragged_axis = Some(axis);
state
}
fn with_unset_drag_pos(&self) -> Self {
let mut state = *self;
state.dragged_axis = None;
state
}
fn with_hovered(&self, axis: Option<ScrollbarAxis>) -> Self {
let mut state = *self;
state.hovered_axis = axis;
state
}
fn with_hovered_on_thumb(&self, axis: Option<ScrollbarAxis>) -> Self {
let mut state = *self;
state.hovered_on_thumb = axis;
state
}
fn with_last_scroll(
&self,
last_scroll_offset: Point<Pixels>,
last_scroll_time: Option<Instant>,
) -> Self {
let mut state = *self;
state.last_scroll_offset = last_scroll_offset;
state.last_scroll_time = last_scroll_time;
state
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarAxis {
Vertical,
Horizontal,
Both,
}
impl ScrollbarAxis {
#[inline]
fn is_vertical(&self) -> bool {
matches!(self, Self::Vertical)
}
#[inline]
fn is_both(&self) -> bool {
matches!(self, Self::Both)
}
#[inline]
pub fn has_vertical(&self) -> bool {
matches!(self, Self::Vertical | Self::Both)
}
#[inline]
pub fn has_horizontal(&self) -> bool {
matches!(self, Self::Horizontal | Self::Both)
}
#[inline]
fn all(&self) -> Vec<ScrollbarAxis> {
match self {
Self::Vertical => vec![Self::Vertical],
Self::Horizontal => vec![Self::Horizontal],
// This should keep vertical first, vertical is the primary axis
// if vertical not need display, then horizontal will not keep right margin.
Self::Both => vec![Self::Vertical, Self::Horizontal],
}
}
}
/// Scrollbar control for scroll-area or a uniform-list.
pub struct Scrollbar {
view_id: EntityId,
axis: ScrollbarAxis,
/// When is vertical, this is the height of the scrollbar.
width: Pixels,
scroll_handle: Rc<Box<dyn ScrollHandleOffsetable>>,
scroll_size: gpui::Size<Pixels>,
state: Rc<Cell<ScrollbarState>>,
}
impl Scrollbar {
fn new(
view_id: EntityId,
state: Rc<Cell<ScrollbarState>>,
axis: ScrollbarAxis,
scroll_handle: impl ScrollHandleOffsetable + 'static,
scroll_size: gpui::Size<Pixels>,
) -> Self {
Self {
view_id,
state,
axis,
scroll_size,
width: px(12.),
scroll_handle: Rc::new(Box::new(scroll_handle)),
}
}
/// Create with vertical and horizontal scrollbar.
pub fn both(
view_id: EntityId,
state: Rc<Cell<ScrollbarState>>,
scroll_handle: impl ScrollHandleOffsetable + 'static,
scroll_size: gpui::Size<Pixels>,
) -> Self {
Self::new(
view_id,
state,
ScrollbarAxis::Both,
scroll_handle,
scroll_size,
)
}
/// Create with horizontal scrollbar.
pub fn horizontal(
view_id: EntityId,
state: Rc<Cell<ScrollbarState>>,
scroll_handle: impl ScrollHandleOffsetable + 'static,
scroll_size: gpui::Size<Pixels>,
) -> Self {
Self::new(
view_id,
state,
ScrollbarAxis::Horizontal,
scroll_handle,
scroll_size,
)
}
/// Create with vertical scrollbar.
pub fn vertical(
view_id: EntityId,
state: Rc<Cell<ScrollbarState>>,
scroll_handle: impl ScrollHandleOffsetable + 'static,
scroll_size: gpui::Size<Pixels>,
) -> Self {
Self::new(
view_id,
state,
ScrollbarAxis::Vertical,
scroll_handle,
scroll_size,
)
}
/// Create vertical scrollbar for uniform list.
pub fn uniform_scroll(
view_id: EntityId,
state: Rc<Cell<ScrollbarState>>,
scroll_handle: UniformListScrollHandle,
) -> Self {
let scroll_size = scroll_handle
.0
.borrow()
.last_item_size
.map(|size| size.contents)
.unwrap_or_default();
Self::new(
view_id,
state,
ScrollbarAxis::Vertical,
scroll_handle,
scroll_size,
)
}
/// Set scrollbar axis.
pub fn axis(mut self, axis: ScrollbarAxis) -> Self {
self.axis = axis;
self
}
}
impl IntoElement for Scrollbar {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for Scrollbar {
type RequestLayoutState = ();
type PrepaintState = Hitbox;
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
cx: &mut gpui::WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.position = Position::Absolute;
style.flex_grow = 1.0;
style.flex_shrink = 1.0;
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
(cx.request_layout(style, None), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
cx: &mut gpui::WindowContext,
) -> Self::PrepaintState {
cx.with_content_mask(Some(ContentMask { bounds }), |cx| {
cx.insert_hitbox(bounds, false)
})
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
cx: &mut gpui::WindowContext,
) {
let hitbox_bounds = hitbox.bounds;
let mut has_both = self.axis.is_both();
for axis in self.axis.all().into_iter() {
const NORMAL_OPACITY: f32 = 0.6;
let is_vertical = axis.is_vertical();
let (scroll_area_size, container_size, scroll_position) = if is_vertical {
(
self.scroll_size.height,
hitbox_bounds.size.height,
self.scroll_handle.offset().y,
)
} else {
(
self.scroll_size.width,
hitbox_bounds.size.width,
self.scroll_handle.offset().x,
)
};
// The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible.
let margin_end = if has_both && !is_vertical {
self.width
} else {
px(0.)
};
// Hide scrollbar, if the scroll area is smaller than the container.
if scroll_area_size <= container_size {
has_both = false;
continue;
}
let thumb_length =
(container_size / scroll_area_size * container_size).max(px(MIN_THUMB_SIZE));
let thumb_start = -(scroll_position / (scroll_area_size - container_size)
* (container_size - margin_end - thumb_length));
let thumb_end = (thumb_start + thumb_length).min(container_size - margin_end);
let bounds = Bounds {
origin: if is_vertical {
point(
hitbox_bounds.origin.x + hitbox_bounds.size.width - self.width,
hitbox_bounds.origin.y,
)
} else {
point(
hitbox_bounds.origin.x,
hitbox_bounds.origin.y + hitbox_bounds.size.height - self.width,
)
},
size: gpui::Size {
width: if is_vertical {
self.width
} else {
hitbox_bounds.size.width
},
height: if is_vertical {
hitbox_bounds.size.height
} else {
self.width
},
},
};
let state = self.state.clone();
let (thumb_bg, bar_bg, bar_border, inset, radius) =
if state.get().dragged_axis == Some(axis) {
(
cx.theme().scrollbar_thumb,
cx.theme().scrollbar,
cx.theme().border,
THUMB_INSET - px(1.),
THUMB_RADIUS,
)
} else if state.get().hovered_axis == Some(axis) {
if state.get().hovered_on_thumb == Some(axis) {
(
cx.theme().scrollbar_thumb,
cx.theme().scrollbar,
cx.theme().border,
THUMB_INSET - px(1.),
THUMB_RADIUS,
)
} else {
(
cx.theme().scrollbar_thumb.opacity(NORMAL_OPACITY),
gpui::transparent_black(),
gpui::transparent_black(),
THUMB_INSET,
THUMB_RADIUS,
)
}
} else {
let mut idle_state = (
gpui::transparent_black(),
gpui::transparent_black(),
gpui::transparent_black(),
THUMB_INSET,
THUMB_RADIUS - px(1.),
);
if let Some(last_time) = state.get().last_scroll_time {
let elapsed = Instant::now().duration_since(last_time).as_secs_f32();
if elapsed < 1.0 {
let y_value = NORMAL_OPACITY - elapsed.powi(10); // y = 1 - x^10
idle_state.0 = cx.theme().scrollbar_thumb.opacity(y_value);
cx.request_animation_frame();
}
}
idle_state
};
let border_width = px(0.);
let thumb_bounds = if is_vertical {
Bounds::from_corners(
point(
bounds.origin.x + inset + border_width,
bounds.origin.y + thumb_start + inset,
),
point(
bounds.origin.x + self.width - inset,
bounds.origin.y + thumb_end - inset,
),
)
} else {
Bounds::from_corners(
point(
bounds.origin.x + thumb_start + inset,
bounds.origin.y + inset + border_width,
),
point(
bounds.origin.x + thumb_end - inset,
bounds.origin.y + self.width - inset,
),
)
};
cx.paint_layer(hitbox_bounds, |cx| {
cx.paint_quad(fill(bounds, bar_bg));
cx.paint_quad(PaintQuad {
bounds,
corner_radii: (0.).into(),
background: gpui::transparent_black(),
border_widths: if is_vertical {
Edges {
top: px(0.),
right: px(0.),
bottom: px(0.),
left: border_width,
}
} else {
Edges {
top: border_width,
right: px(0.),
bottom: px(0.),
left: px(0.),
}
},
border_color: bar_border,
});
cx.paint_quad(fill(thumb_bounds, thumb_bg).corner_radii(radius));
});
cx.on_mouse_event({
let state = self.state.clone();
let view_id = self.view_id;
let scroll_handle = self.scroll_handle.clone();
move |event: &ScrollWheelEvent, phase, cx| {
if phase.bubble() && hitbox_bounds.contains(&event.position) {
if scroll_handle.offset() != state.get().last_scroll_offset {
state.set(
state
.get()
.with_last_scroll(scroll_handle.offset(), Some(Instant::now())),
);
cx.notify(Some(view_id));
}
}
}
});
let safe_range = (-scroll_area_size + container_size)..px(0.);
cx.on_mouse_event({
let state = self.state.clone();
let view_id = self.view_id;
let scroll_handle = self.scroll_handle.clone();
move |event: &MouseDownEvent, phase, cx| {
if phase.bubble() && bounds.contains(&event.position) {
cx.stop_propagation();
if thumb_bounds.contains(&event.position) {
// click on the thumb bar, set the drag position
let pos = event.position - thumb_bounds.origin;
state.set(state.get().with_drag_pos(axis, pos));
cx.notify(Some(view_id));
} else {
// click on the scrollbar, jump to the position
// Set the thumb bar center to the click position
let offset = scroll_handle.offset();
let percentage = if is_vertical {
(event.position.y - thumb_length / 2. - bounds.origin.y)
/ (bounds.size.height - thumb_length)
} else {
(event.position.x - thumb_length / 2. - bounds.origin.x)
/ (bounds.size.width - thumb_length)
}
.min(1.);
if is_vertical {
scroll_handle.set_offset(point(
offset.x,
(-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end),
));
} else {
scroll_handle.set_offset(point(
(-scroll_area_size * percentage)
.clamp(safe_range.start, safe_range.end),
offset.y,
));
}
}
}
}
});
cx.on_mouse_event({
let scroll_handle = self.scroll_handle.clone();
let state = self.state.clone();
let view_id = self.view_id;
move |event: &MouseMoveEvent, _, cx| {
// Update hovered state for scrollbar
if bounds.contains(&event.position) {
if state.get().hovered_axis != Some(axis) {
state.set(state.get().with_hovered(Some(axis)));
cx.notify(Some(view_id));
}
} else {
if state.get().hovered_axis == Some(axis) {
if state.get().hovered_axis.is_some() {
state.set(state.get().with_hovered(None));
cx.notify(Some(view_id));
}
}
}
// Update hovered state for scrollbar thumb
if thumb_bounds.contains(&event.position) {
if state.get().hovered_on_thumb != Some(axis) {
state.set(state.get().with_hovered_on_thumb(Some(axis)));
cx.notify(Some(view_id));
}
} else {
if state.get().hovered_on_thumb == Some(axis) {
state.set(state.get().with_hovered_on_thumb(None));
cx.notify(Some(view_id));
}
}
// Move thumb position on dragging
if state.get().dragged_axis == Some(axis) && event.dragging() {
// drag_pos is the position of the mouse down event
// We need to keep the thumb bar still at the origin down position
let drag_pos = state.get().drag_pos;
let percentage = (if is_vertical {
(event.position.y - drag_pos.y - bounds.origin.y)
/ (bounds.size.height - thumb_length)
} else {
(event.position.x - drag_pos.x - bounds.origin.x)
/ (bounds.size.width - thumb_length - margin_end)
})
.clamp(0., 1.);
let offset = if is_vertical {
point(
scroll_handle.offset().x,
(-(scroll_area_size - container_size) * percentage)
.clamp(safe_range.start, safe_range.end),
)
} else {
point(
(-(scroll_area_size - container_size) * percentage)
.clamp(safe_range.start, safe_range.end),
scroll_handle.offset().y,
)
};
scroll_handle.set_offset(offset);
cx.notify(Some(view_id));
}
}
});
cx.on_mouse_event({
let view_id = self.view_id;
let state = self.state.clone();
move |_event: &MouseUpEvent, phase, cx| {
if phase.bubble() {
state.set(state.get().with_unset_drag_pos());
cx.notify(Some(view_id));
}
}
});
}
}
}

View File

@@ -0,0 +1,77 @@
use gpui::{
prelude::FluentBuilder as _, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled,
};
use crate::{h_flex, popup_menu::PopupMenuExt, theme::ActiveTheme as _, Collapsible, Selectable};
#[derive(IntoElement)]
pub struct SidebarFooter {
id: ElementId,
base: Div,
selected: bool,
is_collapsed: bool,
}
impl SidebarFooter {
pub fn new() -> Self {
Self {
id: SharedString::from("sidebar-footer").into(),
base: h_flex().gap_2().w_full(),
selected: false,
is_collapsed: false,
}
}
}
impl Selectable for SidebarFooter {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn element_id(&self) -> &gpui::ElementId {
&self.id
}
}
impl Collapsible for SidebarFooter {
fn is_collapsed(&self) -> bool {
self.is_collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.is_collapsed = collapsed;
self
}
}
impl ParentElement for SidebarFooter {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.base.extend(elements);
}
}
impl Styled for SidebarFooter {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl PopupMenuExt for SidebarFooter {}
impl RenderOnce for SidebarFooter {
fn render(self, cx: &mut gpui::WindowContext) -> impl gpui::IntoElement {
h_flex()
.id(self.id)
.gap_2()
.p_2()
.w_full()
.justify_between()
.cursor_pointer()
.rounded_md()
.hover(|this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when(self.selected, |this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.child(self.base)
}
}

View File

@@ -0,0 +1,71 @@
use crate::{theme::ActiveTheme, v_flex, Collapsible};
use gpui::{
div, prelude::FluentBuilder as _, Div, IntoElement, ParentElement, RenderOnce, SharedString,
Styled as _, WindowContext,
};
/// A sidebar group
#[derive(IntoElement)]
pub struct SidebarGroup<E: Collapsible + IntoElement + 'static> {
base: Div,
label: SharedString,
is_collapsed: bool,
children: Vec<E>,
}
impl<E: Collapsible + IntoElement> SidebarGroup<E> {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
base: div().gap_2().flex_col(),
label: label.into(),
is_collapsed: false,
children: Vec::new(),
}
}
pub fn child(mut self, child: E) -> Self {
self.children.push(child);
self
}
pub fn children(mut self, children: impl IntoIterator<Item = E>) -> Self {
self.children.extend(children);
self
}
}
impl<E: Collapsible + IntoElement> Collapsible for SidebarGroup<E> {
fn is_collapsed(&self) -> bool {
self.is_collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.is_collapsed = collapsed;
self
}
}
impl<E: Collapsible + IntoElement> RenderOnce for SidebarGroup<E> {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
v_flex()
.relative()
.p_2()
.when(!self.is_collapsed, |this| {
this.child(
div()
.flex_shrink_0()
.px_2()
.rounded_md()
.text_xs()
.text_color(cx.theme().sidebar_foreground.opacity(0.7))
.h_8()
.child(self.label),
)
})
.child(
self.base.children(
self.children
.into_iter()
.map(|child| child.collapsed(self.is_collapsed)),
),
)
}
}

View File

@@ -0,0 +1,77 @@
use gpui::{
prelude::FluentBuilder as _, Div, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled,
};
use crate::{h_flex, popup_menu::PopupMenuExt, theme::ActiveTheme as _, Collapsible, Selectable};
#[derive(IntoElement)]
pub struct SidebarHeader {
id: ElementId,
base: Div,
selected: bool,
is_collapsed: bool,
}
impl SidebarHeader {
pub fn new() -> Self {
Self {
id: SharedString::from("sidebar-header").into(),
base: h_flex().gap_2().w_full(),
selected: false,
is_collapsed: false,
}
}
}
impl Selectable for SidebarHeader {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn element_id(&self) -> &gpui::ElementId {
&self.id
}
}
impl Collapsible for SidebarHeader {
fn is_collapsed(&self) -> bool {
self.is_collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.is_collapsed = collapsed;
self
}
}
impl ParentElement for SidebarHeader {
fn extend(&mut self, elements: impl IntoIterator<Item = gpui::AnyElement>) {
self.base.extend(elements);
}
}
impl Styled for SidebarHeader {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl PopupMenuExt for SidebarHeader {}
impl RenderOnce for SidebarHeader {
fn render(self, cx: &mut gpui::WindowContext) -> impl gpui::IntoElement {
h_flex()
.id(self.id)
.gap_2()
.p_2()
.w_full()
.justify_between()
.cursor_pointer()
.rounded_md()
.hover(|this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when(self.selected, |this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.child(self.base)
}
}

View File

@@ -0,0 +1,237 @@
use crate::{h_flex, theme::ActiveTheme as _, v_flex, Collapsible, Icon, IconName, StyledExt};
use gpui::{
div, percentage, prelude::FluentBuilder as _, ClickEvent, InteractiveElement as _, IntoElement,
ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _,
WindowContext,
};
use std::rc::Rc;
#[derive(IntoElement)]
pub struct SidebarMenu {
is_collapsed: bool,
items: Vec<SidebarMenuItem>,
}
impl SidebarMenu {
pub fn new() -> Self {
Self {
items: Vec::new(),
is_collapsed: false,
}
}
pub fn menu(
mut self,
label: impl Into<SharedString>,
icon: Option<Icon>,
active: bool,
handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.items.push(SidebarMenuItem::Item {
icon,
label: label.into(),
handler: Rc::new(handler),
active,
is_collapsed: self.is_collapsed,
});
self
}
pub fn submenu(
mut self,
label: impl Into<SharedString>,
icon: Option<Icon>,
open: bool,
items: impl FnOnce(SidebarMenu) -> Self,
handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
let menu = SidebarMenu::new();
let menu = items(menu);
self.items.push(SidebarMenuItem::Submenu {
icon,
label: label.into(),
items: menu.items,
is_open: open,
is_collapsed: self.is_collapsed,
handler: Rc::new(handler),
});
self
}
}
impl Collapsible for SidebarMenu {
fn is_collapsed(&self) -> bool {
self.is_collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.is_collapsed = collapsed;
self
}
}
impl RenderOnce for SidebarMenu {
fn render(self, _: &mut WindowContext) -> impl IntoElement {
v_flex()
.gap_2()
.children(self.items.into_iter().map(|mut item| {
match &mut item {
SidebarMenuItem::Item { is_collapsed, .. } => *is_collapsed = self.is_collapsed,
SidebarMenuItem::Submenu { is_collapsed, .. } => {
*is_collapsed = self.is_collapsed
}
}
item
}))
}
}
/// A sidebar menu item
#[derive(IntoElement)]
enum SidebarMenuItem {
Item {
icon: Option<Icon>,
label: SharedString,
handler: Rc<dyn Fn(&ClickEvent, &mut WindowContext)>,
active: bool,
is_collapsed: bool,
},
Submenu {
icon: Option<Icon>,
label: SharedString,
handler: Rc<dyn Fn(&ClickEvent, &mut WindowContext)>,
items: Vec<SidebarMenuItem>,
is_open: bool,
is_collapsed: bool,
},
}
impl SidebarMenuItem {
fn is_submenu(&self) -> bool {
matches!(self, SidebarMenuItem::Submenu { .. })
}
fn icon(&self) -> Option<Icon> {
match self {
SidebarMenuItem::Item { icon, .. } => icon.clone(),
SidebarMenuItem::Submenu { icon, .. } => icon.clone(),
}
}
fn label(&self) -> SharedString {
match self {
SidebarMenuItem::Item { label, .. } => label.clone(),
SidebarMenuItem::Submenu { label, .. } => label.clone(),
}
}
fn is_active(&self) -> bool {
match self {
SidebarMenuItem::Item { active, .. } => *active,
SidebarMenuItem::Submenu { .. } => false,
}
}
fn is_open(&self) -> bool {
match self {
SidebarMenuItem::Item { .. } => false,
SidebarMenuItem::Submenu { is_open, items, .. } => {
*is_open || items.iter().any(|item| item.is_active())
}
}
}
fn is_collapsed(&self) -> bool {
match self {
SidebarMenuItem::Item { is_collapsed, .. } => *is_collapsed,
SidebarMenuItem::Submenu { is_collapsed, .. } => *is_collapsed,
}
}
fn render_menu_item(
&self,
is_submenu: bool,
is_active: bool,
is_open: bool,
cx: &WindowContext,
) -> impl IntoElement {
let handler = match &self {
SidebarMenuItem::Item { handler, .. } => Some(handler.clone()),
SidebarMenuItem::Submenu { handler, .. } => Some(handler.clone()),
};
let is_collapsed = self.is_collapsed();
h_flex()
.id("sidebar-menu-item")
.overflow_hidden()
.flex_shrink_0()
.p_2()
.gap_2()
.items_center()
.rounded_md()
.text_sm()
.cursor_pointer()
.hover(|this| {
this.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when(is_active, |this| {
this.font_medium()
.bg(cx.theme().sidebar_accent)
.text_color(cx.theme().sidebar_accent_foreground)
})
.when_some(self.icon(), |this, icon| this.child(icon.size_4()))
.when(is_collapsed, |this| {
this.justify_center().size_7().mx_auto()
})
.when(!is_collapsed, |this| {
this.h_7()
.child(div().flex_1().child(self.label()))
.when(is_submenu, |this| {
this.child(
Icon::new(IconName::ChevronRight)
.size_4()
.when(is_open, |this| this.rotate(percentage(90. / 360.))),
)
})
})
.when_some(handler, |this, handler| {
this.on_click(move |ev, cx| handler(ev, cx))
})
}
}
impl RenderOnce for SidebarMenuItem {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let is_submenu = self.is_submenu();
let is_active = self.is_active();
let is_open = self.is_open();
div()
.w_full()
.child(self.render_menu_item(is_submenu, is_active, is_open, cx))
.when(is_open, |this| {
this.map(|this| match self {
SidebarMenuItem::Submenu {
items,
is_collapsed,
..
} => {
if is_collapsed {
this
} else {
this.child(
v_flex()
.border_l_1()
.border_color(cx.theme().sidebar_border)
.gap_1()
.mx_3p5()
.px_2p5()
.py_0p5()
.children(items),
)
}
}
_ => this,
})
})
}
}

View File

@@ -0,0 +1,212 @@
use crate::{
button::{Button, ButtonVariants},
h_flex,
scroll::ScrollbarAxis,
theme::ActiveTheme,
v_flex, Collapsible, Icon, IconName, Side, Sizable, StyledExt,
};
use gpui::{
div, prelude::FluentBuilder, px, AnyElement, ClickEvent, Entity, EntityId,
InteractiveElement as _, IntoElement, ParentElement, Pixels, Render, RenderOnce, Styled, View,
WindowContext,
};
use std::rc::Rc;
mod footer;
mod group;
mod header;
mod menu;
pub use footer::*;
pub use group::*;
pub use header::*;
pub use menu::*;
const DEFAULT_WIDTH: Pixels = px(255.);
const COLLAPSED_WIDTH: Pixels = px(48.);
/// A sidebar
#[derive(IntoElement)]
pub struct Sidebar<E: Collapsible + IntoElement + 'static> {
/// The parent view id
view_id: EntityId,
content: Vec<E>,
/// header view
header: Option<AnyElement>,
/// footer view
footer: Option<AnyElement>,
/// The side of the sidebar
side: Side,
collapsible: bool,
width: Pixels,
is_collapsed: bool,
}
impl<E: Collapsible + IntoElement> Sidebar<E> {
fn new(view_id: EntityId, side: Side) -> Self {
Self {
view_id,
content: vec![],
header: None,
footer: None,
side,
collapsible: true,
width: DEFAULT_WIDTH,
is_collapsed: false,
}
}
pub fn left<V: Render + 'static>(view: &View<V>) -> Self {
Self::new(view.entity_id(), Side::Left)
}
pub fn right<V: Render + 'static>(view: &View<V>) -> Self {
Self::new(view.entity_id(), Side::Right)
}
/// Set the width of the sidebar
pub fn width(mut self, width: Pixels) -> Self {
self.width = width;
self
}
/// Set the sidebar to be collapsible, default is true
pub fn collapsible(mut self, collapsible: bool) -> Self {
self.collapsible = collapsible;
self
}
/// Set the sidebar to be collapsed
pub fn collapsed(mut self, collapsed: bool) -> Self {
self.is_collapsed = collapsed;
self
}
/// Set the header of the sidebar.
pub fn header(mut self, header: impl IntoElement) -> Self {
self.header = Some(header.into_any_element());
self
}
/// Set the footer of the sidebar.
pub fn footer(mut self, footer: impl IntoElement) -> Self {
self.footer = Some(footer.into_any_element());
self
}
/// Add a child element to the sidebar, the child must implement `Collapsible`
pub fn child(mut self, child: E) -> Self {
self.content.push(child);
self
}
/// Add multiple children to the sidebar, the children must implement `Collapsible`
pub fn children(mut self, children: impl IntoIterator<Item = E>) -> Self {
self.content.extend(children);
self
}
}
/// Sidebar collapse button with Icon.
#[derive(IntoElement)]
pub struct SidebarToggleButton {
btn: Button,
is_collapsed: bool,
side: Side,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut WindowContext)>>,
}
impl SidebarToggleButton {
fn new(side: Side) -> Self {
Self {
btn: Button::new("sidebar-collapse").ghost().small(),
is_collapsed: false,
side,
on_click: None,
}
}
pub fn left() -> Self {
Self::new(Side::Left)
}
pub fn right() -> Self {
Self::new(Side::Right)
}
pub fn collapsed(mut self, is_collapsed: bool) -> Self {
self.is_collapsed = is_collapsed;
self
}
pub fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
self.on_click = Some(Rc::new(on_click));
self
}
}
impl RenderOnce for SidebarToggleButton {
fn render(self, _: &mut WindowContext) -> impl IntoElement {
let is_collapsed = self.is_collapsed;
let on_click = self.on_click.clone();
let icon = if is_collapsed {
if self.side.is_left() {
IconName::PanelLeftOpen
} else {
IconName::PanelRightOpen
}
} else {
if self.side.is_left() {
IconName::PanelLeftClose
} else {
IconName::PanelRightClose
}
};
self.btn
.when_some(on_click, |this, on_click| {
this.on_click(move |ev, cx| {
on_click(ev, cx);
})
})
.icon(Icon::new(icon).size_4())
}
}
impl<E: Collapsible + IntoElement> RenderOnce for Sidebar<E> {
fn render(mut self, cx: &mut WindowContext) -> impl IntoElement {
let is_collaped = self.is_collapsed;
v_flex()
.id("sidebar")
.w(self.width)
.when(self.is_collapsed, |this| this.w(COLLAPSED_WIDTH))
.flex_shrink_0()
.h_full()
.overflow_hidden()
.relative()
.bg(cx.theme().sidebar)
.text_color(cx.theme().sidebar_foreground)
.border_color(cx.theme().sidebar_border)
.map(|this| match self.side {
Side::Left => this.border_r_1(),
Side::Right => this.text_2xl(),
})
.when_some(self.header.take(), |this, header| {
this.child(h_flex().id("header").p_2().gap_2().child(header))
})
.child(
v_flex().id("content").flex_1().min_h_0().child(
div()
.children(self.content.into_iter().map(|c| c.collapsed(is_collaped)))
.gap_2()
.scrollable(self.view_id, ScrollbarAxis::Vertical),
),
)
.when_some(self.footer.take(), |this, footer| {
this.child(h_flex().id("footer").gap_2().p_2().child(footer))
})
}
}

42
crates/ui/src/skeleton.rs Normal file
View File

@@ -0,0 +1,42 @@
use crate::theme::ActiveTheme;
use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, Div, IntoElement, ParentElement as _,
RenderOnce, Styled,
};
use std::time::Duration;
#[derive(IntoElement)]
pub struct Skeleton {
base: Div,
}
impl Skeleton {
pub fn new() -> Self {
Self {
base: div().w_full().h_4().rounded_md(),
}
}
}
impl Styled for Skeleton {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Skeleton {
fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement {
div().child(
self.base.bg(cx.theme().skeleton).with_animation(
"skeleton",
Animation::new(Duration::from_secs(2))
.repeat()
.with_easing(bounce(ease_in_out)),
move |this, delta| {
let v = 1.0 - delta * 0.5;
this.opacity(v)
},
),
)
}
}

196
crates/ui/src/slider.rs Normal file
View File

@@ -0,0 +1,196 @@
use crate::{theme::ActiveTheme, tooltip::Tooltip};
use gpui::{
canvas, div, prelude::FluentBuilder as _, px, relative, Axis, Bounds, DragMoveEvent, EntityId,
EventEmitter, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement as _,
Pixels, Point, Render, StatefulInteractiveElement as _, Styled, ViewContext,
VisualContext as _,
};
#[derive(Clone, Render)]
pub struct DragThumb(EntityId);
pub enum SliderEvent {
Change(f32),
}
/// A Slider element.
pub struct Slider {
axis: Axis,
min: f32,
max: f32,
step: f32,
value: f32,
bounds: Bounds<Pixels>,
}
impl Slider {
fn new(axis: Axis) -> Self {
Self {
axis,
min: 0.0,
max: 100.0,
step: 1.0,
value: 0.0,
bounds: Bounds::default(),
}
}
pub fn horizontal() -> Self {
Self::new(Axis::Horizontal)
}
/// Set the minimum value of the slider, default: 0.0
pub fn min(mut self, min: f32) -> Self {
self.min = min;
self
}
/// Set the maximum value of the slider, default: 100.0
pub fn max(mut self, max: f32) -> Self {
self.max = max;
self
}
/// Set the step value of the slider, default: 1.0
pub fn step(mut self, step: f32) -> Self {
self.step = step;
self
}
/// Set the default value of the slider, default: 0.0
pub fn default_value(mut self, value: f32) -> Self {
self.value = value;
self
}
/// Set the value of the slider.
pub fn set_value(&mut self, value: f32, cx: &mut gpui::ViewContext<Self>) {
self.value = value;
cx.notify();
}
/// Return percentage value of the slider, range of 0.0..1.0
fn relative_value(&self) -> f32 {
let step = self.step;
let value = self.value;
let min = self.min;
let max = self.max;
let relative_value = (value - min) / (max - min);
let relative_step = step / (max - min);
let relative_value = (relative_value / relative_step).round() * relative_step;
relative_value.clamp(0.0, 1.0)
}
/// Update value by mouse position
fn update_value_by_position(
&mut self,
position: Point<Pixels>,
cx: &mut gpui::ViewContext<Self>,
) {
let bounds = self.bounds;
let axis = self.axis;
let min = self.min;
let max = self.max;
let step = self.step;
let value = match axis {
Axis::Horizontal => {
let relative = (position.x - bounds.left()) / bounds.size.width;
min + (max - min) * relative
}
Axis::Vertical => {
let relative = (position.y - bounds.top()) / bounds.size.height;
max - (max - min) * relative
}
};
let value = (value / step).round() * step;
self.value = value.clamp(self.min, self.max);
cx.emit(SliderEvent::Change(self.value));
cx.notify();
}
fn render_thumb(&self, cx: &mut ViewContext<Self>) -> impl gpui::IntoElement {
let value = self.value;
let entity_id = cx.entity_id();
div()
.id("slider-thumb")
.on_drag(DragThumb(entity_id), |drag, _, cx| {
cx.stop_propagation();
cx.new_view(|_| drag.clone())
})
.on_drag_move(cx.listener(
move |view, e: &DragMoveEvent<DragThumb>, cx| match e.drag(cx) {
DragThumb(id) => {
if *id != entity_id {
return;
}
// set value by mouse position
view.update_value_by_position(e.event.position, cx)
}
},
))
.absolute()
.top(px(-5.))
.left(relative(self.relative_value()))
.ml(-px(8.))
.size_4()
.rounded_full()
.border_1()
.border_color(cx.theme().slider_bar.opacity(0.9))
.when(cx.theme().shadow, |this| this.shadow_md())
.bg(cx.theme().slider_thumb)
.tooltip(move |cx| Tooltip::new(format!("{}", value), cx))
}
fn on_mouse_down(&mut self, event: &MouseDownEvent, cx: &mut gpui::ViewContext<Self>) {
self.update_value_by_position(event.position, cx);
}
}
impl EventEmitter<SliderEvent> for Slider {}
impl Render for Slider {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
.id("slider")
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
.h_5()
.child(
div()
.id("slider-bar")
.relative()
.w_full()
.my_1p5()
.h_1p5()
.bg(cx.theme().slider_bar.opacity(0.2))
.active(|this| this.bg(cx.theme().slider_bar.opacity(0.4)))
.rounded(px(3.))
.child(
div()
.absolute()
.top_0()
.left_0()
.h_full()
.w(relative(self.relative_value()))
.bg(cx.theme().slider_bar)
.rounded_l(px(3.)),
)
.child(self.render_thumb(cx))
.child({
let view = cx.view().clone();
canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
)
.absolute()
.size_full()
}),
)
}
}

426
crates/ui/src/styled.rs Normal file
View File

@@ -0,0 +1,426 @@
use std::fmt::{self, Display, Formatter};
use crate::{
scroll::{Scrollable, ScrollbarAxis},
theme::ActiveTheme,
};
use gpui::{
div, px, Axis, Div, Edges, Element, ElementId, EntityId, FocusHandle, Pixels, Styled,
WindowContext,
};
use serde::{Deserialize, Serialize};
/// Returns a `Div` as horizontal flex layout.
pub fn h_flex() -> Div {
div().h_flex()
}
/// Returns a `Div` as vertical flex layout.
pub fn v_flex() -> Div {
div().v_flex()
}
macro_rules! font_weight {
($fn:ident, $const:ident) => {
/// [docs](https://tailwindcss.com/docs/font-weight)
fn $fn(self) -> Self {
self.font_weight(gpui::FontWeight::$const)
}
};
}
/// Extends [`gpui::Styled`] with specific styling methods.
pub trait StyledExt: Styled + Sized {
/// Apply self into a horizontal flex layout.
fn h_flex(self) -> Self {
self.flex().flex_row().items_center()
}
/// Apply self into a vertical flex layout.
fn v_flex(self) -> Self {
self.flex().flex_col()
}
/// Render a border with a width of 1px, color red
fn debug_red(self) -> Self {
if cfg!(debug_assertions) {
self.border_1().border_color(crate::red_500())
} else {
self
}
}
/// Render a border with a width of 1px, color blue
fn debug_blue(self) -> Self {
if cfg!(debug_assertions) {
self.border_1().border_color(crate::blue_500())
} else {
self
}
}
/// Render a border with a width of 1px, color yellow
fn debug_yellow(self) -> Self {
if cfg!(debug_assertions) {
self.border_1().border_color(crate::yellow_500())
} else {
self
}
}
/// Render a border with a width of 1px, color green
fn debug_green(self) -> Self {
if cfg!(debug_assertions) {
self.border_1().border_color(crate::green_500())
} else {
self
}
}
/// Render a border with a width of 1px, color pink
fn debug_pink(self) -> Self {
if cfg!(debug_assertions) {
self.border_1().border_color(crate::pink_500())
} else {
self
}
}
/// Render a 1px blue border, when if the element is focused
fn debug_focused(self, focus_handle: &FocusHandle, cx: &WindowContext) -> Self {
if cfg!(debug_assertions) {
if focus_handle.contains_focused(cx) {
self.debug_blue()
} else {
self
}
} else {
self
}
}
/// Render a border with a width of 1px, color ring color
fn outline(self, cx: &WindowContext) -> Self {
self.border_color(cx.theme().ring)
}
/// Wraps the element in a ScrollView.
///
/// Current this is only have a vertical scrollbar.
fn scrollable(self, view_id: EntityId, axis: ScrollbarAxis) -> Scrollable<Self>
where
Self: Element,
{
Scrollable::new(view_id, self, axis)
}
font_weight!(font_thin, THIN);
font_weight!(font_extralight, EXTRA_LIGHT);
font_weight!(font_light, LIGHT);
font_weight!(font_normal, NORMAL);
font_weight!(font_medium, MEDIUM);
font_weight!(font_semibold, SEMIBOLD);
font_weight!(font_bold, BOLD);
font_weight!(font_extrabold, EXTRA_BOLD);
font_weight!(font_black, BLACK);
/// Set as Popover style
fn popover_style(self, cx: &mut WindowContext) -> Self {
self.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.shadow_lg()
.rounded(px(cx.theme().radius))
}
}
impl<E: Styled> StyledExt for E {}
/// A size for elements.
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub enum Size {
Size(Pixels),
XSmall,
Small,
#[default]
Medium,
Large,
}
impl Size {
/// Returns the height for table row.
pub fn table_row_height(&self) -> Pixels {
match self {
Size::XSmall => px(26.),
Size::Small => px(30.),
Size::Large => px(40.),
_ => px(32.),
}
}
/// Returns the padding for a table cell.
pub fn table_cell_padding(&self) -> Edges<Pixels> {
match self {
Size::XSmall => Edges {
top: px(2.),
bottom: px(2.),
left: px(4.),
right: px(4.),
},
Size::Small => Edges {
top: px(3.),
bottom: px(3.),
left: px(6.),
right: px(6.),
},
Size::Large => Edges {
top: px(8.),
bottom: px(8.),
left: px(12.),
right: px(12.),
},
_ => Edges {
top: px(4.),
bottom: px(4.),
left: px(8.),
right: px(8.),
},
}
}
}
impl From<Pixels> for Size {
fn from(size: Pixels) -> Self {
Size::Size(size)
}
}
/// A trait for defining element that can be selected.
pub trait Selectable: Sized {
fn element_id(&self) -> &ElementId;
/// Set the selected state of the element.
fn selected(self, selected: bool) -> Self;
}
/// A trait for defining element that can be disabled.
pub trait Disableable {
/// Set the disabled state of the element.
fn disabled(self, disabled: bool) -> Self;
}
/// A trait for setting the size of an element.
pub trait Sizable: Sized {
/// Set the ui::Size of this element.
///
/// Also can receive a `ButtonSize` to convert to `IconSize`,
/// Or a `Pixels` to set a custom size: `px(30.)`
fn with_size(self, size: impl Into<Size>) -> Self;
/// Set to Size::Small
fn small(self) -> Self {
self.with_size(Size::Small)
}
/// Set to Size::XSmall
fn xsmall(self) -> Self {
self.with_size(Size::XSmall)
}
/// Set to Size::Medium
fn large(self) -> Self {
self.with_size(Size::Large)
}
}
#[allow(unused)]
pub trait StyleSized<T: Styled> {
fn input_text_size(self, size: Size) -> Self;
fn input_size(self, size: Size) -> Self;
fn input_pl(self, size: Size) -> Self;
fn input_pr(self, size: Size) -> Self;
fn input_px(self, size: Size) -> Self;
fn input_py(self, size: Size) -> Self;
fn input_h(self, size: Size) -> Self;
fn list_size(self, size: Size) -> Self;
fn list_px(self, size: Size) -> Self;
fn list_py(self, size: Size) -> Self;
/// Apply size with the given `Size`.
fn size_with(self, size: Size) -> Self;
/// Apply the table cell size (Font size, padding) with the given `Size`.
fn table_cell_size(self, size: Size) -> Self;
}
impl<T: Styled> StyleSized<T> for T {
fn input_text_size(self, size: Size) -> Self {
match size {
Size::XSmall => self.text_xs(),
Size::Small => self.text_sm(),
Size::Medium => self.text_base(),
Size::Large => self.text_lg(),
Size::Size(size) => self.text_size(size),
}
}
fn input_size(self, size: Size) -> Self {
self.input_px(size).input_py(size).input_h(size)
}
fn input_pl(self, size: Size) -> Self {
match size {
Size::Large => self.pl_5(),
Size::Medium => self.pl_3(),
_ => self.pl_2(),
}
}
fn input_pr(self, size: Size) -> Self {
match size {
Size::Large => self.pr_5(),
Size::Medium => self.pr_3(),
_ => self.pr_2(),
}
}
fn input_px(self, size: Size) -> Self {
match size {
Size::Large => self.px_5(),
Size::Medium => self.px_3(),
_ => self.px_2(),
}
}
fn input_py(self, size: Size) -> Self {
match size {
Size::Large => self.py_5(),
Size::Medium => self.py_2(),
_ => self.py_1(),
}
}
fn input_h(self, size: Size) -> Self {
match size {
Size::Large => self.h_11(),
Size::Medium => self.h_8(),
_ => self.h(px(26.)),
}
.input_text_size(size)
}
fn list_size(self, size: Size) -> Self {
self.list_px(size).list_py(size).input_text_size(size)
}
fn list_px(self, size: Size) -> Self {
match size {
Size::Small => self.px_2(),
_ => self.px_3(),
}
}
fn list_py(self, size: Size) -> Self {
match size {
Size::Large => self.py_2(),
Size::Medium => self.py_1(),
Size::Small => self.py_0p5(),
_ => self.py_1(),
}
}
fn size_with(self, size: Size) -> Self {
match size {
Size::Large => self.size_11(),
Size::Medium => self.size_8(),
Size::Small => self.size_5(),
Size::XSmall => self.size_4(),
Size::Size(size) => self.size(size),
}
}
fn table_cell_size(self, size: Size) -> Self {
let padding = size.table_cell_padding();
match size {
Size::XSmall => self.text_sm(),
Size::Small => self.text_sm(),
_ => self,
}
.pl(padding.left)
.pr(padding.right)
.pt(padding.top)
.pb(padding.bottom)
}
}
pub trait AxisExt {
fn is_horizontal(self) -> bool;
fn is_vertical(self) -> bool;
}
impl AxisExt for Axis {
fn is_horizontal(self) -> bool {
self == Axis::Horizontal
}
fn is_vertical(self) -> bool {
self == Axis::Vertical
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Placement {
Top,
Bottom,
Left,
Right,
}
impl Display for Placement {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Placement::Top => write!(f, "Top"),
Placement::Bottom => write!(f, "Bottom"),
Placement::Left => write!(f, "Left"),
Placement::Right => write!(f, "Right"),
}
}
}
impl Placement {
pub fn is_horizontal(&self) -> bool {
match self {
Placement::Left | Placement::Right => true,
_ => false,
}
}
pub fn is_vertical(&self) -> bool {
match self {
Placement::Top | Placement::Bottom => true,
_ => false,
}
}
pub fn axis(&self) -> Axis {
match self {
Placement::Top | Placement::Bottom => Axis::Vertical,
Placement::Left | Placement::Right => Axis::Horizontal,
}
}
}
/// A enum for defining the side of the element.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Side {
Left,
Right,
}
impl Side {
pub(crate) fn is_left(&self) -> bool {
matches!(self, Self::Left)
}
}
/// A trait for defining element that can be collapsed.
pub trait Collapsible {
fn collapsed(self, collapsed: bool) -> Self;
fn is_collapsed(&self) -> bool;
}

295
crates/ui/src/svg_img.rs Normal file
View File

@@ -0,0 +1,295 @@
use std::{
hash::Hash,
ops::Deref,
sync::{Arc, LazyLock},
};
use gpui::{
px, size, AppContext, Asset, Bounds, Element, Hitbox, ImageCacheError, InteractiveElement,
Interactivity, IntoElement, IsZero, Pixels, RenderImage, SharedString, Size, StyleRefinement,
Styled, WindowContext,
};
use image::Frame;
use smallvec::SmallVec;
use image::ImageBuffer;
use crate::Assets;
const SCALE: f32 = 2.;
const FONT_PATH: &str = "fonts/NotoSans-Regular.ttf";
static OPTIONS: LazyLock<usvg::Options> = LazyLock::new(|| {
let mut options = usvg::Options::default();
if let Some(font_data) = Assets::get(FONT_PATH).map(|f| f.data) {
options.fontdb_mut().load_font_data(font_data.into());
}
options
});
#[derive(Debug, Clone, Hash)]
pub enum SvgSource {
/// A svg bytes
Data(Arc<[u8]>),
/// An asset path
Path(SharedString),
}
impl From<&[u8]> for SvgSource {
fn from(data: &[u8]) -> Self {
Self::Data(data.into())
}
}
impl From<Arc<[u8]>> for SvgSource {
fn from(data: Arc<[u8]>) -> Self {
Self::Data(data)
}
}
impl From<SharedString> for SvgSource {
fn from(path: SharedString) -> Self {
Self::Path(path)
}
}
impl From<&'static str> for SvgSource {
fn from(path: &'static str) -> Self {
Self::Path(path.into())
}
}
impl Clone for SvgImg {
fn clone(&self) -> Self {
Self {
interactivity: Interactivity::default(),
source: self.source.clone(),
size: self.size,
}
}
}
enum Image {}
#[derive(Debug, Clone)]
struct ImageSource {
source: SvgSource,
size: Size<Pixels>,
}
impl Hash for ImageSource {
/// Hash to to control the Asset cache
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.source.hash(state);
}
}
impl Asset for Image {
type Source = ImageSource;
type Output = Result<Arc<RenderImage>, ImageCacheError>;
fn load(
source: Self::Source,
cx: &mut AppContext,
) -> impl std::future::Future<Output = Self::Output> + Send + 'static {
let asset_source = cx.asset_source().clone();
async move {
let size = source.size;
if size.width.is_zero() || size.height.is_zero() {
return Err(usvg::Error::InvalidSize.into());
}
let size = Size {
width: (size.width * SCALE).ceil(),
height: (size.height * SCALE).ceil(),
};
let bytes = match source.source {
SvgSource::Data(data) => data,
SvgSource::Path(path) => {
if let Ok(Some(data)) = asset_source.load(&path) {
data.deref().to_vec().into()
} else {
Err(std::io::Error::other(format!(
"failed to load svg image from path: {}",
path
)))
.map_err(|e| ImageCacheError::Io(Arc::new(e)))?
}
}
};
let tree = usvg::Tree::from_data(&bytes, &OPTIONS)?;
let mut pixmap =
resvg::tiny_skia::Pixmap::new(size.width.0 as u32, size.height.0 as u32)
.ok_or(usvg::Error::InvalidSize)?;
let transform = resvg::tiny_skia::Transform::from_scale(SCALE, SCALE);
resvg::render(&tree, transform, &mut pixmap.as_mut());
let mut buffer = ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
.expect("invalid svg image buffer");
// Convert from RGBA with premultiplied alpha to BGRA with straight alpha.
for pixel in buffer.chunks_exact_mut(4) {
pixel.swap(0, 2);
if pixel[3] > 0 {
let a = pixel[3] as f32 / 255.;
pixel[0] = (pixel[0] as f32 / a) as u8;
pixel[1] = (pixel[1] as f32 / a) as u8;
pixel[2] = (pixel[2] as f32 / a) as u8;
}
}
Ok(Arc::new(RenderImage::new(SmallVec::from_elem(
Frame::new(buffer),
1,
))))
}
}
}
pub struct SvgImg {
interactivity: Interactivity,
source: Option<SvgSource>,
size: Size<Pixels>,
}
impl SvgImg {
/// Create a new svg image element.
///
/// The `src_width` and `src_height` are the original width and height of the svg image.
pub fn new() -> Self {
Self {
interactivity: Interactivity::default(),
source: None,
size: Size::default(),
}
}
/// Set the path of the svg image from the asset.
///
/// The `size` argument is the size of the original svg image.
#[must_use]
pub fn source(
mut self,
source: impl Into<SvgSource>,
width: impl Into<Pixels>,
height: impl Into<Pixels>,
) -> Self {
self.source = Some(source.into());
self.size = size(width.into(), height.into());
self
}
}
impl IntoElement for SvgImg {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for SvgImg {
type RequestLayoutState = ();
type PrepaintState = Option<Hitbox>;
fn id(&self) -> Option<gpui::ElementId> {
self.interactivity.element_id.clone()
}
fn request_layout(
&mut self,
global_id: Option<&gpui::GlobalElementId>,
cx: &mut WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let layout_id = self
.interactivity
.request_layout(global_id, cx, |style, cx| cx.request_layout(style, None));
(layout_id, ())
}
fn prepaint(
&mut self,
global_id: Option<&gpui::GlobalElementId>,
bounds: gpui::Bounds<gpui::Pixels>,
_: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) -> Self::PrepaintState {
self.interactivity
.prepaint(global_id, bounds, bounds.size, cx, |_, _, hitbox, _| hitbox)
}
fn paint(
&mut self,
global_id: Option<&gpui::GlobalElementId>,
bounds: gpui::Bounds<gpui::Pixels>,
_: &mut Self::RequestLayoutState,
hitbox: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
let source = self.source.clone();
self.interactivity
.paint(global_id, bounds, hitbox.as_ref(), cx, |_style, cx| {
let size = self.size;
let data = if let Some(source) = source {
match cx.use_asset::<Image>(&ImageSource { source, size }) {
Some(Ok(data)) => Some(data),
_ => None,
}
} else {
None
};
if let Some(data) = data {
// To calculate the ratio of the original image size to the container bounds size.
// Scale by shortest side (width or height) to get a fit image.
// And center the image in the container bounds.
let ratio = if bounds.size.width < bounds.size.height {
bounds.size.width / size.width
} else {
bounds.size.height / size.height
};
let ratio = ratio.min(1.0);
let new_size = gpui::Size {
width: size.width * ratio,
height: size.height * ratio,
};
let new_origin = gpui::Point {
x: bounds.origin.x + px(((bounds.size.width - new_size.width) / 2.).into()),
y: bounds.origin.y
+ px(((bounds.size.height - new_size.height) / 2.).into()),
};
let img_bounds = Bounds {
origin: new_origin.map(|origin| origin.floor()),
size: new_size.map(|size| size.ceil()),
};
match cx.paint_image(img_bounds, px(0.).into(), data, 0, false) {
Ok(_) => {}
Err(err) => eprintln!("failed to paint svg image: {:?}", err),
}
}
})
}
}
impl Styled for SvgImg {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.interactivity.base_style
}
}
impl InteractiveElement for SvgImg {
fn interactivity(&mut self) -> &mut Interactivity {
&mut self.interactivity
}
}

234
crates/ui/src/switch.rs Normal file
View File

@@ -0,0 +1,234 @@
use crate::{h_flex, theme::ActiveTheme, Disableable, Side, Sizable, Size};
use gpui::{
div, prelude::FluentBuilder as _, px, Animation, AnimationExt as _, AnyElement, Element,
ElementId, GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _,
SharedString, Styled as _, WindowContext,
};
use std::{cell::RefCell, rc::Rc, time::Duration};
pub struct Switch {
id: ElementId,
checked: bool,
disabled: bool,
label: Option<SharedString>,
label_side: Side,
on_click: Option<Rc<dyn Fn(&bool, &mut WindowContext)>>,
size: Size,
}
impl Switch {
pub fn new(id: impl Into<ElementId>) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
checked: false,
disabled: false,
label: None,
on_click: None,
label_side: Side::Right,
size: Size::Medium,
}
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&bool, &mut WindowContext) + 'static,
{
self.on_click = Some(Rc::new(handler));
self
}
pub fn label_side(mut self, label_side: Side) -> Self {
self.label_side = label_side;
self
}
}
impl Sizable for Switch {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Disableable for Switch {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl IntoElement for Switch {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
#[derive(Default)]
pub struct SwitchState {
prev_checked: Rc<RefCell<Option<bool>>>,
}
impl Element for Switch {
type RequestLayoutState = AnyElement;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn request_layout(
&mut self,
global_id: Option<&GlobalElementId>,
cx: &mut WindowContext,
) -> (LayoutId, Self::RequestLayoutState) {
cx.with_element_state::<SwitchState, _>(global_id.unwrap(), move |state, cx| {
let state = state.unwrap_or_default();
let theme = cx.theme();
let checked = self.checked;
let on_click = self.on_click.clone();
let (bg, toggle_bg) = match self.checked {
true => (theme.primary, theme.background),
false => (theme.input, theme.background),
};
let (bg, toggle_bg) = match self.disabled {
true => (bg.opacity(0.3), toggle_bg.opacity(0.8)),
false => (bg, toggle_bg),
};
let (bg_width, bg_height) = match self.size {
Size::XSmall | Size::Small => (px(28.), px(16.)),
_ => (px(36.), px(20.)),
};
let bar_width = match self.size {
Size::XSmall | Size::Small => px(12.),
_ => px(16.),
};
let inset = px(2.);
let mut element = h_flex()
.id(self.id.clone())
.items_center()
.gap_2()
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
.child(
// Switch Bar
div()
.id(self.id.clone())
.w(bg_width)
.h(bg_height)
.rounded(bg_height / 2.)
.flex()
.items_center()
.border(inset)
.border_color(theme.transparent)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.child(
// Switch Toggle
div()
.rounded_full()
.bg(toggle_bg)
.size(bar_width)
.map(|this| {
let prev_checked = state.prev_checked.clone();
if !self.disabled
&& prev_checked
.borrow()
.map_or(false, |prev| prev != checked)
{
let dur = Duration::from_secs_f64(0.15);
cx.spawn(|cx| async move {
cx.background_executor().timer(dur).await;
*prev_checked.borrow_mut() = Some(checked);
})
.detach();
this.with_animation(
ElementId::NamedInteger(
"move".into(),
checked as usize,
),
Animation::new(dur),
move |this, delta| {
let max_x = bg_width - bar_width - inset * 2;
let x = if checked {
max_x * delta
} else {
max_x - max_x * delta
};
this.left(x)
},
)
.into_any_element()
} else {
let max_x = bg_width - bar_width - inset * 2;
let x = if checked { max_x } else { px(0.) };
this.left(x).into_any_element()
}
}),
),
)
.when_some(self.label.clone(), |this, label| {
this.child(div().child(label).map(|this| match self.size {
Size::XSmall | Size::Small => this.text_sm(),
_ => this.text_base(),
}))
})
.when_some(
on_click
.as_ref()
.map(|c| c.clone())
.filter(|_| !self.disabled),
|this, on_click| {
let prev_checked = state.prev_checked.clone();
this.on_mouse_down(gpui::MouseButton::Left, move |_, cx| {
cx.stop_propagation();
*prev_checked.borrow_mut() = Some(checked);
on_click(&!checked, cx);
})
},
)
.into_any_element();
((element.request_layout(cx), element), state)
})
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
cx: &mut WindowContext,
) {
element.prepaint(cx);
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<gpui::Pixels>,
element: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
element.paint(cx)
}
}

5
crates/ui/src/tab.rs Normal file
View File

@@ -0,0 +1,5 @@
mod tab;
mod tab_bar;
pub use tab::*;
pub use tab_bar::*;

99
crates/ui/src/tab/tab.rs Normal file
View File

@@ -0,0 +1,99 @@
use crate::theme::ActiveTheme;
use crate::Selectable;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, AnyElement, Div, ElementId, InteractiveElement, IntoElement, ParentElement as _,
RenderOnce, Stateful, StatefulInteractiveElement, Styled, WindowContext,
};
#[derive(IntoElement)]
pub struct Tab {
id: ElementId,
base: Stateful<Div>,
label: AnyElement,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
disabled: bool,
selected: bool,
}
impl Tab {
pub fn new(id: impl Into<ElementId>, label: impl IntoElement) -> Self {
let id: ElementId = id.into();
Self {
id: id.clone(),
base: div().id(id).gap_1().py_1p5().px_3().h(px(30.)),
label: label.into_any_element(),
disabled: false,
selected: false,
prefix: None,
suffix: None,
}
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl Into<AnyElement>) -> Self {
self.prefix = Some(prefix.into());
self
}
/// Set the right side of the tab
pub fn suffix(mut self, suffix: impl Into<AnyElement>) -> Self {
self.suffix = Some(suffix.into());
self
}
}
impl Selectable for Tab {
fn element_id(&self) -> &ElementId {
&self.id
}
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
}
impl InteractiveElement for Tab {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.base.interactivity()
}
}
impl StatefulInteractiveElement for Tab {}
impl Styled for Tab {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Tab {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let (text_color, bg_color) = match (self.selected, self.disabled) {
(true, _) => (cx.theme().tab_active_foreground, cx.theme().tab_active),
(false, true) => (cx.theme().tab_foreground.opacity(0.5), cx.theme().tab),
(false, false) => (cx.theme().muted_foreground, cx.theme().tab),
};
self.base
.flex()
.items_center()
.flex_shrink_0()
.cursor_pointer()
.overflow_hidden()
.text_color(text_color)
.bg(bg_color)
.border_x_1()
.border_color(cx.theme().transparent)
.when(self.selected, |this| this.border_color(cx.theme().border))
.text_sm()
.when(self.disabled, |this| this)
.when_some(self.prefix, |this, prefix| {
this.child(prefix).text_color(text_color)
})
.child(div().text_ellipsis().child(self.label))
.when_some(self.suffix, |this, suffix| this.child(suffix))
}
}

View File

@@ -0,0 +1,95 @@
use crate::h_flex;
use crate::theme::ActiveTheme;
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, AnyElement, Div, ElementId, IntoElement, ParentElement, RenderOnce, ScrollHandle,
StatefulInteractiveElement as _, Styled, WindowContext,
};
use gpui::{px, InteractiveElement};
use smallvec::SmallVec;
#[derive(IntoElement)]
pub struct TabBar {
base: Div,
id: ElementId,
scroll_handle: ScrollHandle,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
children: SmallVec<[AnyElement; 2]>,
}
impl TabBar {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
base: div().px(px(-1.)),
id: id.into(),
children: SmallVec::new(),
scroll_handle: ScrollHandle::new(),
prefix: None,
suffix: None,
}
}
/// Track the scroll of the TabBar
pub fn track_scroll(mut self, scroll_handle: ScrollHandle) -> Self {
self.scroll_handle = scroll_handle;
self
}
/// Set the prefix element of the TabBar
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the suffix element of the TabBar
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
}
impl ParentElement for TabBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl Styled for TabBar {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for TabBar {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
self.base
.id(self.id)
.group("tab-bar")
.relative()
.flex()
.flex_none()
.items_center()
.bg(cx.theme().tab_bar)
.text_color(cx.theme().tab_foreground)
.child(
div()
.id("border-b")
.absolute()
.bottom_0()
.size_full()
.border_b_1()
.border_color(cx.theme().border),
)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.id("tabs")
.flex_grow()
.overflow_x_scroll()
.track_scroll(&self.scroll_handle)
.children(self.children),
)
.when_some(self.suffix, |this, suffix| this.child(suffix))
}
}

1226
crates/ui/src/table.rs Normal file

File diff suppressed because it is too large Load Diff

537
crates/ui/src/theme.rs Normal file
View File

@@ -0,0 +1,537 @@
use std::ops::{Deref, DerefMut};
use gpui::{
hsla, point, AppContext, BoxShadow, Global, Hsla, ModelContext, Pixels, SharedString,
ViewContext, WindowAppearance, WindowContext,
};
pub fn init(cx: &mut AppContext) {
Theme::sync_system_appearance(cx)
}
pub trait ActiveTheme {
fn theme(&self) -> &Theme;
}
impl ActiveTheme for AppContext {
fn theme(&self) -> &Theme {
Theme::global(self)
}
}
impl<V> ActiveTheme for ViewContext<'_, V> {
fn theme(&self) -> &Theme {
self.deref().theme()
}
}
impl<V> ActiveTheme for ModelContext<'_, V> {
fn theme(&self) -> &Theme {
self.deref().theme()
}
}
impl ActiveTheme for WindowContext<'_> {
fn theme(&self) -> &Theme {
self.deref().theme()
}
}
/// Make a [gpui::Hsla] color.
///
/// - h: 0..360.0
/// - s: 0.0..100.0
/// - l: 0.0..100.0
pub fn hsl(h: f32, s: f32, l: f32) -> Hsla {
hsla(h / 360., s / 100.0, l / 100.0, 1.0)
}
/// Make a BoxShadow like CSS
///
/// e.g:
///
/// If CSS is `box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);`
///
/// Then the equivalent in Rust is `box_shadow(0., 0., 10., 0., hsla(0., 0., 0., 0.1))`
pub fn box_shadow(
x: impl Into<Pixels>,
y: impl Into<Pixels>,
blur: impl Into<Pixels>,
spread: impl Into<Pixels>,
color: Hsla,
) -> BoxShadow {
BoxShadow {
offset: point(x.into(), y.into()),
blur_radius: blur.into(),
spread_radius: spread.into(),
color,
}
}
pub trait Colorize {
fn opacity(&self, opacity: f32) -> Hsla;
fn divide(&self, divisor: f32) -> Hsla;
fn invert(&self) -> Hsla;
fn invert_l(&self) -> Hsla;
fn lighten(&self, amount: f32) -> Hsla;
fn darken(&self, amount: f32) -> Hsla;
fn apply(&self, base_color: Hsla) -> Hsla;
}
impl Colorize for Hsla {
/// Returns a new color with the given opacity.
///
/// The opacity is a value between 0.0 and 1.0, where 0.0 is fully transparent and 1.0 is fully opaque.
fn opacity(&self, factor: f32) -> Hsla {
Hsla {
a: self.a * factor.clamp(0.0, 1.0),
..*self
}
}
/// Returns a new color with each channel divided by the given divisor.
///
/// The divisor in range of 0.0 .. 1.0
fn divide(&self, divisor: f32) -> Hsla {
Hsla {
a: divisor,
..*self
}
}
/// Return inverted color
fn invert(&self) -> Hsla {
Hsla {
h: (self.h + 1.8) % 3.6,
s: 1.0 - self.s,
l: 1.0 - self.l,
a: self.a,
}
}
/// Return inverted lightness
fn invert_l(&self) -> Hsla {
Hsla {
l: 1.0 - self.l,
..*self
}
}
/// Return a new color with the lightness increased by the given factor.
fn lighten(&self, factor: f32) -> Hsla {
let l = self.l + (1.0 - self.l) * factor.clamp(0.0, 1.0).min(1.0);
Hsla { l, ..*self }
}
/// Return a new color with the darkness increased by the given factor.
fn darken(&self, factor: f32) -> Hsla {
let l = self.l * (1.0 - factor.clamp(0.0, 1.0).min(1.0));
Hsla { l, ..*self }
}
/// Return a new color with the same lightness and alpha but different hue and saturation.
fn apply(&self, new_color: Hsla) -> Hsla {
Hsla {
h: new_color.h,
s: new_color.s,
l: self.l,
a: self.a,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ThemeColor {
pub accent: Hsla,
pub accent_foreground: Hsla,
pub accordion: Hsla,
pub accordion_active: Hsla,
pub accordion_hover: Hsla,
pub background: Hsla,
pub border: Hsla,
pub card: Hsla,
pub card_foreground: Hsla,
pub destructive: Hsla,
pub destructive_active: Hsla,
pub destructive_foreground: Hsla,
pub destructive_hover: Hsla,
pub drag_border: Hsla,
pub drop_target: Hsla,
pub foreground: Hsla,
pub input: Hsla,
pub link: Hsla,
pub link_active: Hsla,
pub link_hover: Hsla,
pub list: Hsla,
pub list_active: Hsla,
pub list_active_border: Hsla,
pub list_even: Hsla,
pub list_head: Hsla,
pub list_hover: Hsla,
pub muted: Hsla,
pub muted_foreground: Hsla,
pub panel: Hsla,
pub popover: Hsla,
pub popover_foreground: Hsla,
pub primary: Hsla,
pub primary_active: Hsla,
pub primary_foreground: Hsla,
pub primary_hover: Hsla,
pub progress_bar: Hsla,
pub ring: Hsla,
pub scrollbar: Hsla,
pub scrollbar_thumb: Hsla,
pub secondary: Hsla,
pub secondary_active: Hsla,
pub secondary_foreground: Hsla,
pub secondary_hover: Hsla,
pub selection: Hsla,
pub skeleton: Hsla,
pub slider_bar: Hsla,
pub slider_thumb: Hsla,
pub tab: Hsla,
pub tab_active: Hsla,
pub tab_active_foreground: Hsla,
pub tab_bar: Hsla,
pub tab_foreground: Hsla,
pub table: Hsla,
pub table_active: Hsla,
pub table_active_border: Hsla,
pub table_even: Hsla,
pub table_head: Hsla,
pub table_head_foreground: Hsla,
pub table_hover: Hsla,
pub table_row_border: Hsla,
pub title_bar: Hsla,
pub title_bar_border: Hsla,
pub sidebar: Hsla,
pub sidebar_accent: Hsla,
pub sidebar_accent_foreground: Hsla,
pub sidebar_border: Hsla,
pub sidebar_foreground: Hsla,
pub sidebar_primary: Hsla,
pub sidebar_primary_foreground: Hsla,
}
impl ThemeColor {
pub fn light() -> Self {
Self {
accent: hsl(240.0, 5.0, 96.0),
accent_foreground: hsl(240.0, 5.9, 10.0),
accordion: hsl(0.0, 0.0, 100.0),
accordion_active: hsl(240.0, 5.9, 90.0),
accordion_hover: hsl(240.0, 4.8, 95.9).opacity(0.7),
background: hsl(0.0, 0.0, 100.),
border: hsl(240.0, 5.9, 90.0),
card: hsl(0.0, 0.0, 100.0),
card_foreground: hsl(240.0, 10.0, 3.9),
destructive: hsl(0.0, 84.2, 60.2),
destructive_active: hsl(0.0, 84.2, 47.0),
destructive_foreground: hsl(0.0, 0.0, 98.0),
destructive_hover: hsl(0.0, 84.2, 65.0),
drag_border: crate::blue_500(),
drop_target: hsl(235.0, 30., 44.0).opacity(0.25),
foreground: hsl(240.0, 10., 3.9),
input: hsl(240.0, 5.9, 90.0),
link: hsl(221.0, 83.0, 53.0),
link_active: hsl(221.0, 83.0, 53.0).darken(0.2),
link_hover: hsl(221.0, 83.0, 53.0).lighten(0.2),
list: hsl(0.0, 0.0, 100.),
list_active: hsl(211.0, 97.0, 85.0).opacity(0.2),
list_active_border: hsl(211.0, 97.0, 85.0),
list_even: hsl(240.0, 5.0, 96.0),
list_head: hsl(0.0, 0.0, 100.),
list_hover: hsl(240.0, 4.8, 95.0),
muted: hsl(240.0, 4.8, 95.9),
muted_foreground: hsl(240.0, 3.8, 46.1),
panel: hsl(0.0, 0.0, 100.0),
popover: hsl(0.0, 0.0, 100.0),
popover_foreground: hsl(240.0, 10.0, 3.9),
primary: hsl(223.0, 5.9, 10.0),
primary_active: hsl(223.0, 1.9, 25.0),
primary_foreground: hsl(223.0, 0.0, 98.0),
primary_hover: hsl(223.0, 5.9, 15.0),
progress_bar: hsl(223.0, 5.9, 10.0),
ring: hsl(240.0, 5.9, 65.0),
scrollbar: hsl(0., 0., 97.).opacity(0.3),
scrollbar_thumb: hsl(0., 0., 69.),
secondary: hsl(240.0, 5.9, 96.9),
secondary_active: hsl(240.0, 5.9, 93.),
secondary_foreground: hsl(240.0, 59.0, 10.),
secondary_hover: hsl(240.0, 5.9, 98.),
selection: hsl(211.0, 97.0, 85.0),
skeleton: hsl(223.0, 5.9, 10.0).opacity(0.1),
slider_bar: hsl(223.0, 5.9, 10.0),
slider_thumb: hsl(0.0, 0.0, 100.0),
tab: gpui::transparent_black(),
tab_active: hsl(0.0, 0.0, 100.0),
tab_active_foreground: hsl(240.0, 10., 3.9),
tab_bar: hsl(240.0, 4.8, 95.9),
tab_foreground: hsl(240.0, 10., 3.9),
table: hsl(0.0, 0.0, 100.),
table_active: hsl(211.0, 97.0, 85.0).opacity(0.2),
table_active_border: hsl(211.0, 97.0, 85.0),
table_even: hsl(240.0, 5.0, 96.0),
table_head: hsl(0.0, 0.0, 100.),
table_head_foreground: hsl(240.0, 10., 3.9).opacity(0.7),
table_hover: hsl(240.0, 4.8, 95.0),
table_row_border: hsl(240.0, 7.7, 94.5),
title_bar: hsl(0.0, 0.0, 100.),
title_bar_border: hsl(240.0, 5.9, 90.0),
sidebar: hsl(0.0, 0.0, 98.0),
sidebar_accent: hsl(240.0, 4.8, 92.),
sidebar_accent_foreground: hsl(240.0, 5.9, 10.0),
sidebar_border: hsl(220.0, 13.0, 91.0),
sidebar_foreground: hsl(240.0, 5.3, 26.1),
sidebar_primary: hsl(240.0, 5.9, 10.0),
sidebar_primary_foreground: hsl(0.0, 0.0, 98.0),
}
}
pub fn dark() -> Self {
Self {
accent: hsl(240.0, 3.7, 15.9),
accent_foreground: hsl(0.0, 0.0, 78.0),
accordion: hsl(299.0, 2., 11.),
accordion_active: hsl(240.0, 3.7, 16.9),
accordion_hover: hsl(240.0, 3.7, 15.9).opacity(0.7),
background: hsl(0.0, 0.0, 8.0),
border: hsl(240.0, 3.7, 16.9),
card: hsl(0.0, 0.0, 8.0),
card_foreground: hsl(0.0, 0.0, 78.0),
destructive: hsl(0.0, 62.8, 30.6),
destructive_active: hsl(0.0, 62.8, 20.6),
destructive_foreground: hsl(0.0, 0.0, 78.0),
destructive_hover: hsl(0.0, 62.8, 35.6),
drag_border: crate::blue_500(),
drop_target: hsl(235.0, 30., 44.0).opacity(0.1),
foreground: hsl(0., 0., 78.),
input: hsl(240.0, 3.7, 15.9),
link: hsl(221.0, 83.0, 53.0),
link_active: hsl(221.0, 83.0, 53.0).darken(0.2),
link_hover: hsl(221.0, 83.0, 53.0).lighten(0.2),
list: hsl(0.0, 0.0, 8.0),
list_active: hsl(240.0, 3.7, 15.0).opacity(0.2),
list_active_border: hsl(240.0, 5.9, 35.5),
list_even: hsl(240.0, 3.7, 10.0),
list_head: hsl(0.0, 0.0, 8.0),
list_hover: hsl(240.0, 3.7, 15.9),
muted: hsl(240.0, 3.7, 15.9),
muted_foreground: hsl(240.0, 5.0, 64.9),
panel: hsl(299.0, 2., 11.),
popover: hsl(0.0, 0.0, 10.),
popover_foreground: hsl(0.0, 0.0, 78.0),
primary: hsl(223.0, 0.0, 98.0),
primary_active: hsl(223.0, 0.0, 80.0),
primary_foreground: hsl(223.0, 5.9, 10.0),
primary_hover: hsl(223.0, 0.0, 90.0),
progress_bar: hsl(223.0, 0.0, 98.0),
ring: hsl(240.0, 4.9, 83.9),
scrollbar: hsl(240., 1., 15.).opacity(0.3),
scrollbar_thumb: hsl(0., 0., 68.),
secondary: hsl(240.0, 0., 13.0),
secondary_active: hsl(240.0, 0., 10.),
secondary_foreground: hsl(0.0, 0.0, 78.0),
secondary_hover: hsl(240.0, 0., 15.),
selection: hsl(211.0, 97.0, 22.0),
skeleton: hsla(223.0, 0.0, 98.0, 0.1),
slider_bar: hsl(223.0, 0.0, 98.0),
slider_thumb: hsl(0.0, 0.0, 8.0),
tab: gpui::transparent_black(),
tab_active: hsl(0.0, 0.0, 8.0),
tab_active_foreground: hsl(0., 0., 78.),
tab_bar: hsl(299.0, 0., 5.5),
tab_foreground: hsl(0., 0., 78.),
table: hsl(0.0, 0.0, 8.0),
table_active: hsl(240.0, 3.7, 15.0).opacity(0.2),
table_active_border: hsl(240.0, 5.9, 35.5),
table_even: hsl(240.0, 3.7, 10.0),
table_head: hsl(0.0, 0.0, 8.0),
table_head_foreground: hsl(0., 0., 78.).opacity(0.7),
table_hover: hsl(240.0, 3.7, 15.9).opacity(0.5),
table_row_border: hsl(240.0, 3.7, 16.9).opacity(0.5),
title_bar: hsl(0., 0., 9.7),
title_bar_border: hsl(240.0, 3.7, 15.9),
sidebar: hsl(240.0, 0.0, 10.0),
sidebar_accent: hsl(240.0, 3.7, 15.9),
sidebar_accent_foreground: hsl(240.0, 4.8, 95.9),
sidebar_border: hsl(240.0, 3.7, 15.9),
sidebar_foreground: hsl(240.0, 4.8, 95.9),
sidebar_primary: hsl(0.0, 0.0, 98.0),
sidebar_primary_foreground: hsl(240.0, 5.9, 10.0),
}
}
}
#[derive(Debug, Clone)]
pub struct Theme {
colors: ThemeColor,
pub mode: ThemeMode,
pub font_family: SharedString,
pub font_size: f32,
pub radius: f32,
pub shadow: bool,
pub transparent: Hsla,
}
impl Deref for Theme {
type Target = ThemeColor;
fn deref(&self) -> &Self::Target {
&self.colors
}
}
impl DerefMut for Theme {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.colors
}
}
impl Global for Theme {}
impl Theme {
/// Returns the global theme reference
pub fn global(cx: &AppContext) -> &Theme {
cx.global::<Theme>()
}
/// Returns the global theme mutable reference
pub fn global_mut(cx: &mut AppContext) -> &mut Theme {
cx.global_mut::<Theme>()
}
/// Apply a mask color to the theme.
pub fn apply_color(&mut self, mask_color: Hsla) {
self.title_bar = self.title_bar.apply(mask_color);
self.title_bar_border = self.title_bar_border.apply(mask_color);
self.background = self.background.apply(mask_color);
self.foreground = self.foreground.apply(mask_color);
self.card = self.card.apply(mask_color);
self.card_foreground = self.card_foreground.apply(mask_color);
self.popover = self.popover.apply(mask_color);
self.popover_foreground = self.popover_foreground.apply(mask_color);
self.primary = self.primary.apply(mask_color);
self.primary_hover = self.primary_hover.apply(mask_color);
self.primary_active = self.primary_active.apply(mask_color);
self.primary_foreground = self.primary_foreground.apply(mask_color);
self.secondary = self.secondary.apply(mask_color);
self.secondary_hover = self.secondary_hover.apply(mask_color);
self.secondary_active = self.secondary_active.apply(mask_color);
self.secondary_foreground = self.secondary_foreground.apply(mask_color);
// self.destructive = self.destructive.apply(mask_color);
// self.destructive_hover = self.destructive_hover.apply(mask_color);
// self.destructive_active = self.destructive_active.apply(mask_color);
// self.destructive_foreground = self.destructive_foreground.apply(mask_color);
self.muted = self.muted.apply(mask_color);
self.muted_foreground = self.muted_foreground.apply(mask_color);
self.accent = self.accent.apply(mask_color);
self.accent_foreground = self.accent_foreground.apply(mask_color);
self.border = self.border.apply(mask_color);
self.input = self.input.apply(mask_color);
self.ring = self.ring.apply(mask_color);
// self.selection = self.selection.apply(mask_color);
self.scrollbar = self.scrollbar.apply(mask_color);
self.scrollbar_thumb = self.scrollbar_thumb.apply(mask_color);
self.panel = self.panel.apply(mask_color);
self.drag_border = self.drag_border.apply(mask_color);
self.drop_target = self.drop_target.apply(mask_color);
self.tab_bar = self.tab_bar.apply(mask_color);
self.tab = self.tab.apply(mask_color);
self.tab_active = self.tab_active.apply(mask_color);
self.tab_foreground = self.tab_foreground.apply(mask_color);
self.tab_active_foreground = self.tab_active_foreground.apply(mask_color);
self.progress_bar = self.progress_bar.apply(mask_color);
self.slider_bar = self.slider_bar.apply(mask_color);
self.slider_thumb = self.slider_thumb.apply(mask_color);
self.list = self.list.apply(mask_color);
self.list_even = self.list_even.apply(mask_color);
self.list_head = self.list_head.apply(mask_color);
self.list_active = self.list_active.apply(mask_color);
self.list_active_border = self.list_active_border.apply(mask_color);
self.list_hover = self.list_hover.apply(mask_color);
self.table = self.table.apply(mask_color);
self.table_even = self.table_even.apply(mask_color);
self.table_active = self.table_active.apply(mask_color);
self.table_active_border = self.table_active_border.apply(mask_color);
self.table_hover = self.table_hover.apply(mask_color);
self.table_row_border = self.table_row_border.apply(mask_color);
self.table_head = self.table_head.apply(mask_color);
self.table_head_foreground = self.table_head_foreground.apply(mask_color);
self.link = self.link.apply(mask_color);
self.link_hover = self.link_hover.apply(mask_color);
self.link_active = self.link_active.apply(mask_color);
self.skeleton = self.skeleton.apply(mask_color);
self.accordion = self.accordion.apply(mask_color);
self.accordion_hover = self.accordion_hover.apply(mask_color);
self.accordion_active = self.accordion_active.apply(mask_color);
self.title_bar = self.title_bar.apply(mask_color);
self.title_bar_border = self.title_bar_border.apply(mask_color);
self.sidebar = self.sidebar.apply(mask_color);
self.sidebar_accent = self.sidebar_accent.apply(mask_color);
self.sidebar_accent_foreground = self.sidebar_accent_foreground.apply(mask_color);
self.sidebar_border = self.sidebar_border.apply(mask_color);
self.sidebar_foreground = self.sidebar_foreground.apply(mask_color);
self.sidebar_primary = self.sidebar_primary.apply(mask_color);
self.sidebar_primary_foreground = self.sidebar_primary_foreground.apply(mask_color);
}
/// Sync the theme with the system appearance
pub fn sync_system_appearance(cx: &mut AppContext) {
match cx.window_appearance() {
WindowAppearance::Dark | WindowAppearance::VibrantDark => {
Self::change(ThemeMode::Dark, cx)
}
WindowAppearance::Light | WindowAppearance::VibrantLight => {
Self::change(ThemeMode::Light, cx)
}
}
}
pub fn change(mode: ThemeMode, cx: &mut AppContext) {
let colors = match mode {
ThemeMode::Light => ThemeColor::light(),
ThemeMode::Dark => ThemeColor::dark(),
};
let mut theme = Theme::from(colors);
theme.mode = mode;
cx.set_global(theme);
cx.refresh();
}
}
impl From<ThemeColor> for Theme {
fn from(colors: ThemeColor) -> Self {
Theme {
mode: ThemeMode::default(),
transparent: Hsla::transparent_black(),
font_size: 16.0,
font_family: if cfg!(target_os = "macos") {
".SystemUIFont".into()
} else if cfg!(target_os = "windows") {
"Segoe UI".into()
} else {
"FreeMono".into()
},
radius: 4.0,
shadow: true,
colors,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, PartialOrd, Eq)]
pub enum ThemeMode {
Light,
#[default]
Dark,
}
impl ThemeMode {
pub fn is_dark(&self) -> bool {
matches!(self, Self::Dark)
}
}

330
crates/ui/src/title_bar.rs Normal file
View File

@@ -0,0 +1,330 @@
use std::rc::Rc;
use crate::{h_flex, theme::ActiveTheme, Icon, IconName, InteractiveElementExt as _, Sizable as _};
use gpui::{
div, prelude::FluentBuilder as _, px, relative, AnyElement, ClickEvent, Div, Element, Hsla,
InteractiveElement as _, IntoElement, ParentElement, Pixels, RenderOnce, Stateful,
StatefulInteractiveElement as _, Style, Styled, WindowContext,
};
pub const TITLE_BAR_HEIGHT: Pixels = px(35.);
#[cfg(target_os = "macos")]
const TITLE_BAR_LEFT_PADDING: Pixels = px(80.);
#[cfg(not(target_os = "macos"))]
const TITLE_BAR_LEFT_PADDING: Pixels = px(12.);
/// TitleBar used to customize the appearance of the title bar.
///
/// We can put some elements inside the title bar.
#[derive(IntoElement)]
pub struct TitleBar {
base: Stateful<Div>,
children: Vec<AnyElement>,
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>,
}
impl TitleBar {
pub fn new() -> Self {
Self {
base: div().id("title-bar").pl(TITLE_BAR_LEFT_PADDING),
children: Vec::new(),
on_close_window: None,
}
}
/// Add custom for close window event, default is None, then click X button will call `cx.remove_window()`.
/// Linux only, this will do nothing on other platforms.
pub fn on_close_window(
mut self,
f: impl Fn(&ClickEvent, &mut WindowContext) + 'static,
) -> Self {
if cfg!(target_os = "linux") {
self.on_close_window = Some(Rc::new(Box::new(f)));
}
self
}
}
// The Windows control buttons have a fixed width of 35px.
//
// We don't need implementation the click event for the control buttons.
// If user clicked in the bounds, the window event will be triggered.
#[derive(IntoElement, Clone)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>,
},
}
impl ControlIcon {
fn minimize() -> Self {
Self::Minimize
}
fn restore() -> Self {
Self::Restore
}
fn maximize() -> Self {
Self::Maximize
}
fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>) -> Self {
Self::Close { on_close_window }
}
fn id(&self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close { .. } => "close",
}
}
fn icon(&self) -> IconName {
match self {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close { .. } => IconName::WindowClose,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close { .. })
}
fn fg(&self, cx: &WindowContext) -> Hsla {
if cx.theme().mode.is_dark() {
crate::white()
} else {
crate::black()
}
}
fn hover_fg(&self, cx: &WindowContext) -> Hsla {
if self.is_close() || cx.theme().mode.is_dark() {
crate::white()
} else {
crate::black()
}
}
fn hover_bg(&self, cx: &WindowContext) -> Hsla {
if self.is_close() {
if cx.theme().mode.is_dark() {
crate::red_800()
} else {
crate::red_600()
}
} else if cx.theme().mode.is_dark() {
crate::stone_700()
} else {
crate::stone_200()
}
}
}
impl RenderOnce for ControlIcon {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let fg = self.fg(cx);
let hover_fg = self.hover_fg(cx);
let hover_bg = self.hover_bg(cx);
let icon = self.clone();
let is_linux = cfg!(target_os = "linux");
let on_close_window = match &icon {
ControlIcon::Close { on_close_window } => on_close_window.clone(),
_ => None,
};
div()
.id(self.id())
.flex()
.cursor_pointer()
.w(TITLE_BAR_HEIGHT)
.h_full()
.justify_center()
.content_center()
.items_center()
.text_color(fg)
.when(is_linux, |this| {
this.on_click(move |_, cx| match icon {
Self::Minimize => cx.minimize_window(),
Self::Restore => cx.zoom_window(),
Self::Maximize => cx.zoom_window(),
Self::Close { .. } => {
if let Some(f) = on_close_window.clone() {
f(&ClickEvent::default(), cx);
} else {
cx.remove_window();
}
}
})
})
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
.active(|style| style.bg(hover_bg.opacity(0.7)))
.child(Icon::new(self.icon()).small())
}
}
#[derive(IntoElement)]
struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut WindowContext)>>>,
}
impl RenderOnce for WindowControls {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
if cfg!(target_os = "macos") {
return div().id("window-controls");
}
h_flex()
.id("window-controls")
.items_center()
.flex_shrink_0()
.h_full()
.child(
h_flex()
.justify_center()
.content_stretch()
.h_full()
.child(ControlIcon::minimize())
.child(if cx.is_maximized() {
ControlIcon::restore()
} else {
ControlIcon::maximize()
}),
)
.child(ControlIcon::close(self.on_close_window))
}
}
impl Styled for TitleBar {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for TitleBar {
fn render(self, cx: &mut WindowContext) -> impl IntoElement {
let is_linux = cfg!(target_os = "linux");
const HEIGHT: Pixels = px(34.);
div()
.flex_shrink_0()
.child(
self.base
.flex()
.flex_row()
.items_center()
.justify_between()
.h(HEIGHT)
.border_b_1()
.border_color(cx.theme().title_bar_border)
.bg(cx.theme().title_bar)
.when(cx.is_fullscreen(), |this| this.pl(px(12.)))
.on_double_click(|_, cx| cx.zoom_window())
.child(
h_flex()
.h_full()
.justify_between()
.flex_shrink_0()
.flex_1()
.children(self.children),
)
.child(WindowControls {
on_close_window: self.on_close_window,
}),
)
.when(is_linux, |this| {
this.child(
div()
.top_0()
.left_0()
.absolute()
.size_full()
.h_full()
.child(TitleBarElement {}),
)
})
}
}
/// A TitleBar Element that can be move the window.
pub struct TitleBarElement {}
impl IntoElement for TitleBarElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TitleBarElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
cx: &mut WindowContext,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.flex_grow = 1.0;
style.flex_shrink = 1.0;
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
let id = cx.request_layout(style, []);
(id, ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut WindowContext,
) -> Self::PrepaintState {
}
#[allow(unused_variables)]
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
bounds: gpui::Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
cx: &mut WindowContext,
) {
use gpui::{MouseButton, MouseMoveEvent, MouseUpEvent};
cx.on_mouse_event(move |ev: &MouseMoveEvent, _, cx: &mut WindowContext| {
if bounds.contains(&ev.position) && ev.pressed_button == Some(MouseButton::Left) {
cx.start_window_move();
}
});
cx.on_mouse_event(move |ev: &MouseUpEvent, _, cx: &mut WindowContext| {
if ev.button == MouseButton::Left {
cx.show_window_menu(ev.position);
}
});
}
}

38
crates/ui/src/tooltip.rs Normal file
View File

@@ -0,0 +1,38 @@
use gpui::{
div, px, AnyView, IntoElement, ParentElement, Render, SharedString, Styled, ViewContext,
VisualContext, WindowContext,
};
use crate::theme::ActiveTheme;
pub struct Tooltip {
text: SharedString,
}
impl Tooltip {
pub fn new(text: impl Into<SharedString>, cx: &mut WindowContext) -> AnyView {
cx.new_view(|_| Self { text: text.into() }).into()
}
}
impl Render for Tooltip {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div().child(
// Wrap in a child, to ensure the left margin is applied to the tooltip
div()
.font_family(".SystemUIFont")
.m_3()
.bg(cx.theme().popover)
.text_color(cx.theme().popover_foreground)
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(px(6.))
.py_0p5()
.px_2()
.text_sm()
.child(self.text.clone()),
)
}
}