feat: add new theme (#61)

Reviewed-on: #61
This commit was merged in pull request #61.
This commit is contained in:
2026-10-10 00:30:42 +00:00
parent 4ef764984b
commit 602a91b4f0
94 changed files with 3337 additions and 4456 deletions
-18
View File
@@ -1,18 +0,0 @@
/// 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;
3.0 * y1 * one_t2 * t + 3.0 * y2 * one_t * t2 + t3
}
}
+6 -25
View File
@@ -1,4 +1,3 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
@@ -24,7 +23,7 @@ const FALLBACK_SEED: &str = "coop";
const CIRCLE_SEGMENTS: usize = 32;
/// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
fn avatar_size(size: Size) -> AbsoluteLength {
match size {
Size::Large => px(64.).into(),
Size::Medium => px(32.).into(),
@@ -36,7 +35,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// A deterministic, offline pixel-art avatar derived from a seed.
#[derive(IntoElement)]
pub(crate) struct PixelAvatar {
struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
@@ -44,7 +43,7 @@ pub(crate) struct PixelAvatar {
impl PixelAvatar {
/// Creates a pixel avatar from `seed`.
pub fn new(seed: impl AsRef<str>) -> Self {
fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::Medium,
@@ -188,8 +187,7 @@ fn paint_polygons<'a>(
}
}
/// Approximates the circle of `radius` around `center` as a convex polygon,
/// wound so that its interior is on the left of every directed edge.
/// Approximates the circle of `radius` around `center` as a convex polygon wound left-interior.
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
let center_x = center.x.as_f32();
let center_y = center.y.as_f32();
@@ -354,11 +352,9 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
pub struct Avatar {
base: Div,
picture: Option<ImageSource>,
grayscale: bool,
seed: Option<String>,
style: StyleRefinement,
size: Size,
border_color: Option<Hsla>,
selected: bool,
}
@@ -377,11 +373,9 @@ impl Avatar {
Avatar {
base: div(),
picture,
grayscale: false,
seed: None,
style: StyleRefinement::default(),
size: Size::Medium,
border_color: None,
selected: false,
}
}
@@ -424,27 +418,17 @@ impl InteractiveElement for Avatar {
}
impl RenderOnce for Avatar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let border_width = if self.border_color.is_some() {
px(2.)
} else {
px(0.)
};
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let container_size = image_size + border_width * 2.;
let content = match self.picture {
Some(picture) => {
let seed = self.seed;
let grayscale = self.grayscale;
img(picture)
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.grayscale(grayscale)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
.into_any_element()
}
@@ -453,12 +437,9 @@ impl RenderOnce for Avatar {
div()
.flex_shrink_0()
.size(container_size)
.size(image_size)
.rounded_full()
.overflow_hidden()
.when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color)
})
.child(content)
}
}
+109 -112
View File
@@ -4,10 +4,13 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div, relative,
Styled, Window, div, px, relative,
};
use gpui_base::Button as BaseButton;
use theme::ActiveTheme;
use theme::{
ActiveTheme, TextStyle, Typeset as _, bubble_radius, button_radius, plate_active, plate_hover,
wash,
};
use crate::indicator::Indicator;
use crate::tooltip::Tooltip;
@@ -24,10 +27,10 @@ pub struct ButtonCustomVariant {
impl ButtonCustomVariant {
pub fn new(_window: &Window, cx: &App) -> Self {
Self {
color: cx.theme().element_background,
foreground: cx.theme().element_foreground,
hover: cx.theme().element_hover,
active: cx.theme().element_active,
color: cx.theme().solid,
foreground: cx.theme().on_solid,
hover: plate_hover(cx.theme().solid),
active: plate_active(cx.theme().solid),
}
}
@@ -70,47 +73,49 @@ pub enum ButtonVariant {
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 secondary style for the Button.
fn secondary(self) -> Self {
self.with_variant(ButtonVariant::Secondary)
}
/// With the danger style for the Button.
fn danger(self) -> Self {
self.with_variant(ButtonVariant::Danger)
}
/// With the warning style for the Button.
fn warning(self) -> Self {
self.with_variant(ButtonVariant::Warning)
}
/// With the ghost style for the Button.
fn ghost(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: false })
}
/// With the ghost style for the Button.
fn ghost_alt(self) -> Self {
self.with_variant(ButtonVariant::Ghost { alt: true })
}
/// With the transparent style for the Button.
fn transparent(self) -> Self {
self.with_variant(ButtonVariant::Transparent)
}
/// With the custom style for the Button.
fn custom(self, style: ButtonCustomVariant) -> Self {
self.with_variant(ButtonVariant::Custom(style))
}
}
/// Which side of the label the icon sits on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum IconPosition {
/// Left of the label.
#[default]
Start,
/// Right of the label.
End,
}
/// A Button element.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
@@ -127,13 +132,10 @@ pub struct Button {
rounded: bool,
compact: bool,
caret: bool,
indicator: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
tab_index: isize,
tab_stop: bool,
pub(crate) selected: bool,
selected: bool,
icon_position: IconPosition,
}
impl From<Button> for AnyElement {
@@ -151,22 +153,19 @@ impl Button {
variant: ButtonVariant::default(),
disabled: false,
selected: false,
indicator: false,
compact: false,
caret: false,
rounded: false,
size: Size::Medium,
tooltip: None,
on_click: None,
on_hover: None,
loading: false,
children: Vec::new(),
tab_index: 0,
tab_stop: true,
icon_position: IconPosition::Start,
}
}
/// Make the button rounded.
pub fn rounded(mut self) -> Self {
self.rounded = true;
self
@@ -178,19 +177,23 @@ impl Button {
self
}
/// Set the icon of the button, if the Button have no label, the button well in Icon Button mode.
/// Set the icon of the button, if the Button has no label, the button will be 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.
/// Set which side of the label the icon sits on (default [`IconPosition::Start`]).
pub fn icon_position(mut self, position: IconPosition) -> Self {
self.icon_position = position;
self
}
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
@@ -202,13 +205,11 @@ impl Button {
self
}
/// Set true to show the caret indicator.
pub fn caret(mut self) -> Self {
self.caret = true;
self
}
/// Add click handler.
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -217,9 +218,7 @@ impl Button {
self
}
/// Set the tab stop of the button, if true, the button will be focusable by tab key.
///
/// Default is true.
/// Set whether the button can be focused with the tab key (default true).
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
@@ -229,11 +228,6 @@ impl Button {
fn clickable(&self) -> bool {
!(self.disabled || self.loading) && self.on_click.is_some()
}
#[inline]
fn hoverable(&self) -> bool {
!(self.disabled || self.loading) && self.on_hover.is_some()
}
}
impl Disableable for Button {
@@ -288,24 +282,23 @@ impl InteractiveElement for Button {
impl RenderOnce for Button {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let style: ButtonVariant = self.variant;
let style = self.variant;
let clickable = self.clickable();
let hoverable = self.hoverable();
let normal_style = style.normal(cx);
let icon_size = match self.size {
Size::Size(v) => Size::Size(v * 0.75),
Size::Large => Size::Medium,
_ => self.size,
};
let icon_start = self.icon_position == IconPosition::Start;
self.base
.tab_index(self.tab_index)
.tab_index(0)
.tab_stop(self.tab_stop)
.disabled(self.disabled)
.when_some(self.on_click.clone(), |this, on_click| {
this.on_click(move |event, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
if !clickable {
cx.stop_propagation();
return;
@@ -322,9 +315,16 @@ impl RenderOnce for Button {
.cursor_default()
.overflow_hidden()
.map(|this| match self.rounded {
false => this.rounded(cx.theme().radius),
true => this.rounded_full(),
false => this.rounded(px(button_radius())),
true => this.rounded(px(bubble_radius())),
})
.when_some(
match style {
ButtonVariant::Secondary => Some(cx.theme().border),
_ => None,
},
|this, border_color| this.border_1().border_color(border_color),
)
.when(!self.compact, |this| {
if self.label.is_none() && self.children.is_empty() {
// Icon Button
@@ -337,7 +337,7 @@ impl RenderOnce for Button {
}
} else {
// Normal Button
match self.size {
let this = match self.size {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => {
if self.icon.is_some() {
@@ -367,12 +367,21 @@ impl RenderOnce for Button {
this.h_10().px_3()
}
}
};
if !icon_start && self.icon.is_some() {
match self.size {
Size::XSmall | Size::Small => this.pl_2p5().pr_2(),
Size::Medium | Size::Large => this.pl_3p5().pr_3(),
Size::Size(_) => this,
}
} else {
this
}
}
})
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
// Stop handle any click event when disabled.
// To avoid handle dropdown menu open when button is disabled.
// Stop clicks when disabled, so a parent (e.g. dropdown menu) doesn't handle them.
if self.disabled {
cx.stop_propagation();
return;
@@ -380,30 +389,35 @@ impl RenderOnce for Button {
// Avoid focus on mouse down.
window.prevent_default();
})
.when_some(self.on_hover.filter(|_| hoverable), |this, on_hover| {
this.on_hover(move |hovered, window, cx| {
(on_hover)(hovered, window, cx);
})
})
.child({
h_flex()
.id("label")
.justify_center()
.map(|this| match self.size {
Size::XSmall => this.text_xs().gap_1(),
Size::Small => this.text_sm().gap_1p5(),
_ => this.text_sm().gap_2(),
Size::XSmall => this.text_style(TextStyle::Caption).gap_1(),
Size::Small => this.text_style(TextStyle::Callout).gap_1p5(),
_ => this.text_style(TextStyle::Callout).gap(px(theme::SPACE)),
})
.when(!self.loading, |this| {
this.when_some(self.icon, |this, icon| {
.when(!self.loading && icon_start, |this| {
this.when_some(self.icon.clone(), |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading, |this| this.child(Indicator::new()))
.when(self.loading && icon_start, |this| {
this.child(Indicator::new())
})
.when_some(self.label, |this, label| {
this.child(div().flex_none().line_height(relative(1.)).child(label))
})
.children(self.children)
.when(!self.loading && !icon_start, |this| {
this.when_some(self.icon.clone(), |this, icon| {
this.child(icon.with_size(icon_size))
})
})
.when(self.loading && !icon_start, |this| {
this.child(Indicator::new())
})
.when(self.caret, |this| {
this.justify_between().gap_0p5().child(
Icon::new(IconName::ChevronDown)
@@ -413,17 +427,6 @@ impl RenderOnce for Button {
})
})
.text_color(normal_style.fg)
.when(self.indicator && !self.disabled, |this| {
this.child(
div()
.absolute()
.bottom_px()
.right_px()
.size_1()
.rounded_full()
.bg(gpui::green()),
)
})
.when(!self.disabled && !self.selected, |this| {
this.bg(normal_style.bg)
.hover(|this| {
@@ -470,15 +473,15 @@ impl ButtonVariant {
fn bg_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_background,
ButtonVariant::Secondary => cx.theme().secondary_background,
ButtonVariant::Danger => cx.theme().danger_background,
ButtonVariant::Warning => cx.theme().warning_background,
ButtonVariant::Primary => cx.theme().solid,
ButtonVariant::Secondary => cx.theme().surface_raised,
ButtonVariant::Danger => cx.theme().danger_strong,
ButtonVariant::Warning => cx.theme().warning_muted,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().ghost_element_background_alt
cx.theme().surface_raised
} else {
cx.theme().ghost_element_background
gpui::transparent_black()
}
}
ButtonVariant::Custom(colors) => colors.color,
@@ -488,11 +491,15 @@ impl ButtonVariant {
fn text_color(&self, cx: &App) -> Hsla {
match self {
ButtonVariant::Primary => cx.theme().element_foreground,
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Danger => cx.theme().danger_foreground,
ButtonVariant::Warning => cx.theme().warning_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Primary => cx.theme().on_solid,
ButtonVariant::Secondary => cx.theme().text,
// Bezel's destructive pairing: the red plate carries white ink in
// both appearances, not the appearance-flipping on_accent.
ButtonVariant::Danger => gpui::white(),
// warning_muted is a translucent amber wash, so the label is the
// amber token itself — dark amber in light, bright in dark.
ButtonVariant::Warning => cx.theme().warning,
ButtonVariant::Transparent => cx.theme().text_faint,
ButtonVariant::Ghost { alt } => {
if *alt {
cx.theme().text
@@ -506,19 +513,19 @@ impl ButtonVariant {
fn hovered(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_hover,
ButtonVariant::Secondary => cx.theme().secondary_hover,
ButtonVariant::Danger => cx.theme().danger_hover,
ButtonVariant::Warning => cx.theme().warning_hover,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_hover,
ButtonVariant::Primary => plate_hover(cx.theme().solid),
ButtonVariant::Secondary => cx.theme().surface_raised_hover,
ButtonVariant::Danger => plate_hover(cx.theme().danger_strong),
ButtonVariant::Warning => plate_hover(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_hover,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.hover,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Ghost { .. } => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
@@ -527,18 +534,18 @@ impl ButtonVariant {
fn active(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_active,
ButtonVariant::Secondary => cx.theme().secondary_active,
ButtonVariant::Danger => cx.theme().danger_active,
ButtonVariant::Warning => cx.theme().warning_active,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_active,
ButtonVariant::Primary => plate_active(cx.theme().solid),
ButtonVariant::Secondary => plate_active(cx.theme().surface_raised),
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
@@ -547,18 +554,18 @@ impl ButtonVariant {
fn selected(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Primary => cx.theme().element_selected,
ButtonVariant::Secondary => cx.theme().secondary_selected,
ButtonVariant::Danger => cx.theme().danger_selected,
ButtonVariant::Warning => cx.theme().warning_selected,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_selected,
ButtonVariant::Primary => cx.theme().element_active,
ButtonVariant::Secondary => cx.theme().element_active,
ButtonVariant::Danger => plate_active(cx.theme().danger_strong),
ButtonVariant::Warning => plate_active(cx.theme().warning_muted),
ButtonVariant::Ghost { .. } => cx.theme().element_active,
ButtonVariant::Transparent => gpui::transparent_black(),
ButtonVariant::Custom(colors) => colors.active,
};
let fg = match self {
ButtonVariant::Secondary => cx.theme().secondary_foreground,
ButtonVariant::Transparent => cx.theme().text_placeholder,
ButtonVariant::Secondary => cx.theme().text,
ButtonVariant::Transparent => cx.theme().text_faint,
_ => self.text_color(cx),
};
@@ -566,18 +573,8 @@ impl ButtonVariant {
}
fn disabled(&self, cx: &App) -> ButtonVariantStyle {
let bg = match self {
ButtonVariant::Danger => cx.theme().danger_disabled,
ButtonVariant::Warning => cx.theme().warning_disabled,
ButtonVariant::Ghost { .. } => cx.theme().ghost_element_disabled,
ButtonVariant::Secondary => cx.theme().secondary_disabled,
_ => cx.theme().element_disabled,
};
let fg = match self {
ButtonVariant::Primary => cx.theme().text_muted,
_ => cx.theme().text_muted,
};
let bg = wash(0.08);
let fg = cx.theme().text_muted;
ButtonVariantStyle { bg, fg }
}
+34 -90
View File
@@ -2,20 +2,17 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Div, FocusHandle,
IntoElement, ParentElement, Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window,
div, hsla, point, px,
AnimationExt as _, AnyElement, App, ClickEvent, Div, FocusHandle, IntoElement, ParentElement,
Pixels, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, px, transparent_black,
};
use gpui_base::{DialogBackdrop, DialogPopup, DialogTitle};
use instant::Duration;
use theme::ActiveTheme;
use theme::{ActiveTheme, SCRIM_ALPHA_DARK, scrim, surface_radius, surface_shadows};
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
use crate::motion::DIALOG_IN;
use crate::scroll::ScrollableElement;
use crate::{IconName, Root, StyledExt, WindowExtension, h_flex, v_flex};
type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
type OnOk = Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>>;
type OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
type RenderButtonFn = Box<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
@@ -70,7 +67,6 @@ pub struct Dialog {
width: Pixels,
max_width: Option<Pixels>,
on_close: OnClose,
on_ok: OnOk,
on_cancel: OnCancel,
@@ -80,10 +76,10 @@ pub struct Dialog {
show_close: bool,
button_props: DialogButtonProps,
/// This will be change when open the dialog, the focus handle is create when open the dialog.
pub focus_handle: FocusHandle,
pub layer_ix: usize,
pub overlay_visible: bool,
/// The focus handle created when the dialog is opened, owned by the `Root`.
pub(crate) focus_handle: FocusHandle,
pub(crate) layer_ix: usize,
pub(crate) overlay_visible: bool,
}
impl Dialog {
@@ -100,7 +96,6 @@ impl Dialog {
keyboard: true,
layer_ix: 0,
overlay_visible: false,
on_close: Rc::new(|_, _, _| {}),
on_ok: None,
on_cancel: Rc::new(|_, _, _| true),
button_props: DialogButtonProps::default(),
@@ -115,15 +110,8 @@ impl Dialog {
self
}
/// Set the footer of the dialog.
///
/// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`.
///
/// - First `RenderButtonFn` is the render function for the OK button.
/// - Second `RenderButtonFn` is the render function for the CANCEL button.
///
/// When you set the footer, the footer will be placed default footer buttons.
pub fn footer<E, F>(mut self, footer: F) -> Self
/// Sets a custom footer, replacing the default footer buttons.
fn footer<E, F>(mut self, footer: F) -> Self
where
E: IntoElement,
F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec<E> + 'static,
@@ -150,20 +138,7 @@ impl Dialog {
self
}
/// Sets the callback for when the dialog is closed.
///
/// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback.
pub fn on_close(
mut self,
on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_close = Rc::new(on_close);
self
}
/// Sets the callback for when the dialog is has been confirmed.
///
/// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
/// Sets the confirm callback, returning `false` keeps the dialog open.
pub fn on_ok(
mut self,
on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
@@ -172,9 +147,7 @@ impl Dialog {
self
}
/// Sets the callback for when the dialog is has been canceled.
///
/// The callback should return `true` to close the dialog, if return `false` the dialog will not be closed.
/// Sets the cancel callback, returning `false` keeps the dialog open.
pub fn on_cancel(
mut self,
on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static,
@@ -189,15 +162,13 @@ impl Dialog {
self
}
/// Sets the width of the dialog, defaults to 480px.
/// Sets the width of the dialog, defaults to 380px.
pub fn width(mut self, width: Pixels) -> Self {
self.width = width;
self
}
/// Set the overlay closable of the dialog, defaults to `true`.
///
/// When the overlay is clicked, the dialog will be closed.
/// Sets whether clicking the overlay closes the dialog, defaults to `true`.
pub fn overlay_closable(mut self, overlay_closable: bool) -> Self {
self.overlay_closable = overlay_closable;
self
@@ -209,7 +180,7 @@ impl Dialog {
self
}
pub fn has_overlay(&self) -> bool {
pub(crate) fn has_overlay(&self) -> bool {
self.overlay
}
}
@@ -232,13 +203,11 @@ impl RenderOnce for Dialog {
let is_topmost = layer_ix + 1 == Root::read(window, cx).active_dialogs.len();
let has_footer = self.footer.is_some();
let on_close = self.on_close.clone();
let on_ok = self.on_ok.clone();
let on_cancel = self.on_cancel.clone();
let render_ok: RenderButtonFn = Box::new({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
let ok_variant = self.button_props.ok_variant;
let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into());
@@ -249,7 +218,6 @@ impl RenderOnce for Dialog {
.font_semibold()
.on_click({
let on_ok = on_ok.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if let Some(on_ok) = &on_ok
@@ -258,7 +226,6 @@ impl RenderOnce for Dialog {
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_dialog(cx);
}
})
@@ -268,7 +235,6 @@ impl RenderOnce for Dialog {
let render_cancel: RenderButtonFn = Box::new({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
let cancel_variant = self.button_props.cancel_variant;
let cancel_text = self
.button_props
@@ -281,13 +247,11 @@ impl RenderOnce for Dialog {
.with_variant(cancel_variant)
.on_click({
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
if !on_cancel(&ClickEvent::default(), window, cx) {
return;
}
on_close(&ClickEvent::default(), window, cx);
window.close_dialog(cx);
}
})
@@ -306,25 +270,24 @@ impl RenderOnce for Dialog {
padding_right = pr.to_pixels(self.width.into(), window.rem_size());
}
let animation = Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
let backdrop = DialogBackdrop::new()
.absolute()
.inset_0()
.when(self.overlay_visible, |this| this.bg(cx.theme().overlay))
.with_animation("fade-in", animation.clone(), move |this, delta| {
.when(self.overlay_visible, |this| {
this.bg(scrim(SCRIM_ALPHA_DARK))
})
.with_animation("fade-in", DIALOG_IN.animation(), move |this, delta| {
this.opacity(delta)
});
let card = DialogPopup::new()
.flex()
.flex_col()
.bg(cx.theme().background)
.bg(cx.theme().surface_dialog)
.border_1()
.border_color(cx.theme().border.alpha(0.4))
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_xl())
.border_color(cx.theme().border)
.rounded(px(surface_radius()))
.when(cx.theme().shadow, |this| this.shadow(surface_shadows()))
.min_h_24()
.refine_style(&self.style)
.relative()
@@ -349,7 +312,6 @@ impl RenderOnce for Dialog {
})
.when(self.show_close, |this| {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
this.child(
div()
@@ -364,14 +326,13 @@ impl RenderOnce for Dialog {
.icon(IconName::CloseCircleFill)
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(cx.theme().icon_muted)
.color(cx.theme().ghost_element_background)
.hover(cx.theme().ghost_element_background)
.active(cx.theme().ghost_element_background),
.foreground(cx.theme().text_faint)
.color(transparent_black())
.hover(cx.theme().element_hover)
.active(cx.theme().element_active),
)
.on_click(move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_dialog(cx);
}),
),
@@ -407,26 +368,12 @@ impl RenderOnce for Dialog {
.children(footer(render_ok, render_cancel, window, cx)),
)
})
.with_animation("slide-down", animation, move |this, delta| {
// Settle at the target offset; only the entrance is animated.
let y_offset = px(30.) * (delta - 1.);
// This is equivalent to `shadow_xl` with an extra opacity.
let shadow = vec![
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(20.)),
blur_radius: px(25.),
spread_radius: px(-5.),
inset: false,
},
BoxShadow {
color: hsla(0., 0., 0., 0.1 * delta),
offset: point(px(0.), px(8.)),
blur_radius: px(10.),
spread_radius: px(-6.),
inset: false,
},
];
.with_animation("slide-down", DIALOG_IN.animation(), move |this, delta| {
let y_offset = px(2.) * (delta - 1.);
let mut shadow = surface_shadows();
for shadow in &mut shadow {
shadow.color.a *= delta;
}
this.top(y_offset).shadow(shadow)
});
@@ -442,9 +389,6 @@ impl RenderOnce for Dialog {
None => has_footer,
})
.on_cancel(move |event, window, cx| on_cancel(event, window, cx))
.on_close(move |event, window, cx| {
on_close(event, window, cx);
window.close_dialog(cx);
})
.on_close(move |_, window, cx| window.close_dialog(cx))
}
}
+17 -16
View File
@@ -17,7 +17,7 @@ use gpui_base::dock::{
PaneRef, PanelId, TabGroupContext, TabGroupRenderer,
};
use gpui_base::{HandleEdge, Placement, ResizeHandleContext};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _, button_radius, panel_radius};
use crate::button::{Button, ButtonVariants as _};
use crate::resizable::{resize_handle, resize_handle_appearance};
@@ -83,9 +83,11 @@ pub fn add_panel_to(
window: &mut Window,
cx: &mut App,
) {
let _ = dock.update(cx, |area, cx| {
if let Err(error) = dock.update(cx, |area, cx| {
add_panel(area, panel, placement, window, cx);
});
}) {
log::warn!("Failed to add panel to dock: {error}");
}
}
/// The panel in any region of `area` whose logical id is `key`.
@@ -346,12 +348,12 @@ impl Render for DragPreview {
.justify_center()
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_sm()
.rounded(px(button_radius()))
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
.bg(cx.theme().background)
.bg(cx.theme().bg)
.child(panel_title(&self.panel, cx))
}
}
@@ -446,7 +448,7 @@ impl TabGroupSkin {
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().drop_target))
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
@@ -465,7 +467,7 @@ impl TabGroupSkin {
let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.bg(cx.theme().bg)
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
@@ -537,7 +539,7 @@ impl TabGroupSkin {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().border)
.border_color(cx.theme().drop_line)
})
.on_drop({
let group = TabGroupContext::clone(group);
@@ -567,7 +569,7 @@ impl TabGroupSkin {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.bg(cx.theme().panel_background)
.bg(cx.theme().bg)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
@@ -670,8 +672,7 @@ impl TabGroupRenderer for TabGroupSkin {
window: &mut Window,
cx: &mut App,
) -> AnyElement {
// The left dock's only panel draws bare, so its content can own the
// window's top-left corner instead of a tab bar doing so.
// The plain left group draws bare so its content can own the window's top-left corner.
if self.is_plain_left_group(group, cx) {
return Empty.into_any_element();
}
@@ -717,7 +718,7 @@ impl TabGroupRenderer for TabGroupSkin {
.child(
div()
.size_full()
.bg(cx.theme().panel_background)
.bg(cx.theme().bg)
.overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
)
@@ -758,10 +759,10 @@ impl TabGroupRenderer for TabGroupSkin {
.top(top)
.w(width)
.h(height)
.rounded(cx.theme().radius_lg)
.rounded(px(panel_radius()))
.border_1()
.border_color(cx.theme().element_disabled)
.bg(cx.theme().drop_target_background)
.border_color(cx.theme().drop_line)
.bg(cx.theme().drop_target)
.into_any_element(),
)
}
+7 -56
View File
@@ -8,14 +8,9 @@ use gpui::{
use gpui_base::dock::{PanelId, PanelState};
use crate::button::Button;
use crate::menu::PopupMenu;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PanelEvent {
ZoomIn,
ZoomOut,
LayoutChanged,
}
pub enum PanelEvent {}
pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// The name of the panel used to serialize, deserialize and identify the panel.
@@ -31,44 +26,29 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
true
}
/// Return true if the panel is zoomable, default is `false`.
/// Whether the panel is zoomable, default is `true`.
fn zoomable(&self, _cx: &App) -> bool {
true
}
/// Return false to hide panel, true to show panel, default is `true`.
///
/// This method called in Panel render, we should make sure it is fast.
fn visible(&self, _cx: &App) -> bool {
true
}
/// Set active state of the panel.
///
/// This method will be called when the panel is active or inactive.
///
/// The last_active_panel and current_active_panel will be touched when the panel is active.
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
/// Set zoomed state of the panel.
///
/// This method will be called when the panel is zoomed or unzoomed.
///
/// Only current Panel will touch this method.
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
/// The addition popup menu of the panel, default is `None`.
fn popup_menu(&self, this: PopupMenu, _cx: &App) -> PopupMenu {
this
}
/// The addition toolbar buttons of the panel used to show in the right of the title bar, default is `None`.
/// Toolbar buttons shown at the right of the title bar.
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
pub trait PanelView: 'static + Send + Sync {
pub(crate) trait PanelView: 'static + Send + Sync {
fn panel_id(&self, cx: &App) -> SharedString;
fn title(&self, cx: &App) -> AnyElement;
fn closable(&self, cx: &App) -> bool;
@@ -76,8 +56,6 @@ pub trait PanelView: 'static + Send + Sync {
fn visible(&self, cx: &App) -> bool;
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu;
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button>;
fn view(&self) -> AnyView;
fn focus_handle(&self, cx: &App) -> FocusHandle;
}
@@ -115,14 +93,6 @@ impl<T: Panel> PanelView for Entity<T> {
})
}
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu {
self.read(cx).popup_menu(menu, cx)
}
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button> {
self.read(cx).toolbar_buttons(window, cx)
}
fn view(&self) -> AnyView {
self.clone().into()
}
@@ -132,24 +102,6 @@ impl<T: Panel> PanelView for Entity<T> {
}
}
impl From<&dyn PanelView> for AnyView {
fn from(handle: &dyn PanelView) -> Self {
handle.view()
}
}
impl<T: Panel> From<&dyn PanelView> for Entity<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()
}
}
#[derive(Clone)]
pub struct PanelHandle {
id: PanelId,
@@ -164,13 +116,12 @@ impl PanelHandle {
}
}
/// Recover the coop handle behind one of base's.
pub fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
/// Downcast a base handle back to the coop handle.
pub(crate) fn of(panel: &Arc<dyn gpui_base::dock::PanelView>) -> Option<&Self> {
panel.as_any().downcast_ref::<Self>()
}
/// The coop panel behind this handle.
pub fn panel(&self) -> &Arc<dyn PanelView> {
pub(crate) fn panel(&self) -> &Arc<dyn PanelView> {
&self.panel
}
}
+6 -20
View File
@@ -1,10 +1,10 @@
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
StyleRefinement, Styled, Window, div, relative,
StyleRefinement, Styled, Window, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{StyledExt as _, v_flex};
@@ -22,12 +22,6 @@ pub trait GroupBoxVariants: Sized {
#[must_use]
fn with_variant(self, variant: GroupBoxVariant) -> Self;
/// Set to use [`GroupBoxVariant::Normal`] to GroupBox.
#[must_use]
fn normal(self) -> Self {
self.with_variant(GroupBoxVariant::Normal)
}
/// Set to use [`GroupBoxVariant::Fill`] to GroupBox.
#[must_use]
fn fill(self) -> Self {
@@ -35,16 +29,13 @@ pub trait GroupBoxVariants: Sized {
}
}
/// GroupBox is a styled container element that with
/// an optional title to groups related content together.
/// A styled container with an optional title that groups related content together.
#[derive(IntoElement)]
pub struct GroupBox {
id: Option<ElementId>,
variant: GroupBoxVariant,
style: StyleRefinement,
title_style: StyleRefinement,
title: Option<AnyElement>,
content_style: StyleRefinement,
children: SmallVec<[AnyElement; 1]>,
}
@@ -55,8 +46,6 @@ impl GroupBox {
id: None,
variant: GroupBoxVariant::default(),
style: StyleRefinement::default(),
title_style: StyleRefinement::default(),
content_style: StyleRefinement::default(),
title: None,
children: SmallVec::new(),
}
@@ -106,7 +95,7 @@ impl RenderOnce for GroupBox {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (bg, has_paddings) = match self.variant {
GroupBoxVariant::Normal => (None, false),
GroupBoxVariant::Fill => (Some(cx.theme().surface_background), true),
GroupBoxVariant::Fill => (Some(cx.theme().surface), true),
};
v_flex()
@@ -119,9 +108,7 @@ impl RenderOnce for GroupBox {
this.child(
div()
.text_color(cx.theme().text_muted)
.line_height(relative(1.))
.refine_style(&self.title_style)
.text_sm()
.text_style(TextStyle::Callout)
.font_semibold()
.child(title),
)
@@ -132,8 +119,7 @@ impl RenderOnce for GroupBox {
.text_color(cx.theme().text)
.when(has_paddings, |this| this.p_2())
.gap_4()
.rounded(cx.theme().radius_lg)
.refine_style(&self.content_style)
.rounded(px(theme::panel_radius()))
.children(self.children),
)
}
+4 -46
View File
@@ -1,7 +1,7 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Context, Hsla, IntoElement, Radians, Render, RenderOnce, SharedString,
StyleRefinement, Styled, Svg, Transformation, Window, svg,
AnyElement, App, Context, Hsla, IntoElement, Render, RenderOnce, SharedString, StyleRefinement,
Styled, Svg, Window, svg,
};
use theme::ActiveTheme;
@@ -18,13 +18,11 @@ pub enum IconName {
ArrowLeft,
ArrowRight,
ArrowDownCircle,
Boom,
Book,
Ban,
ChevronDown,
CaretDown,
CaretRight,
CaretUp,
Check,
CheckCircle,
Close,
@@ -32,17 +30,14 @@ pub enum IconName {
CloseCircleFill,
Compass,
Copy,
Device,
Door,
Ellipsis,
Emoji,
Eye,
Folder,
Input,
Info,
Invite,
Inbox,
InboxFill,
Link,
Loader,
Lock,
@@ -54,23 +49,14 @@ pub enum IconName {
PlusChat,
PlusCircle,
Profile,
Reset,
Relay,
Reply,
Refresh,
Scan,
Search,
Settings,
Settings2,
Sun,
Ship,
Shield,
Group,
Hashtag,
History,
UserKey,
Upload,
Usb,
Edit,
PanelLeft,
PanelLeftOpen,
@@ -84,9 +70,6 @@ pub enum IconName {
WindowMaximize,
WindowMinimize,
WindowRestore,
Fistbump,
FistbumpFill,
Zoom,
}
impl IconName {
@@ -95,13 +78,11 @@ impl IconName {
Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowRight => "icons/arrow-right.svg",
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
Self::Boom => "icons/boom.svg",
Self::Book => "icons/book.svg",
Self::Ban => "icons/ban.svg",
Self::ChevronDown => "icons/chevron-down.svg",
Self::CaretDown => "icons/caret-down.svg",
Self::CaretRight => "icons/caret-right.svg",
Self::CaretUp => "icons/caret-up.svg",
Self::Check => "icons/check.svg",
Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg",
@@ -109,17 +90,14 @@ impl IconName {
Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Compass => "icons/compass.svg",
Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg",
Self::Folder => "icons/folder.svg",
Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg",
Self::Inbox => "icons/inbox.svg",
Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
@@ -131,23 +109,14 @@ impl IconName {
Self::PlusChat => "icons/plus-chat.svg",
Self::PlusCircle => "icons/plus-circle.svg",
Self::Profile => "icons/profile.svg",
Self::Reset => "icons/reset.svg",
Self::Relay => "icons/relay.svg",
Self::Reply => "icons/reply.svg",
Self::Refresh => "icons/refresh.svg",
Self::Scan => "icons/scan.svg",
Self::Search => "icons/search.svg",
Self::Settings => "icons/settings.svg",
Self::Settings2 => "icons/settings2.svg",
Self::Sun => "icons/sun.svg",
Self::Ship => "icons/ship.svg",
Self::Shield => "icons/shield.svg",
Self::UserKey => "icons/user-key.svg",
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::Hashtag => "icons/hashtag.svg",
Self::History => "icons/history.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",
@@ -161,9 +130,6 @@ impl IconName {
Self::WindowMaximize => "icons/window-maximize.svg",
Self::WindowMinimize => "icons/window-minimize.svg",
Self::WindowRestore => "icons/window-restore.svg",
Self::Fistbump => "icons/fistbump.svg",
Self::FistbumpFill => "icons/fistbump-fill.svg",
Self::Zoom => "icons/zoom.svg",
}
.into()
}
@@ -188,7 +154,6 @@ pub struct Icon {
path: SharedString,
text_color: Option<Hsla>,
size: Option<Size>,
rotation: Option<Radians>,
}
impl Default for Icon {
@@ -199,7 +164,6 @@ impl Default for Icon {
path: "".into(),
text_color: None,
size: None,
rotation: None,
}
}
}
@@ -208,7 +172,6 @@ impl Clone for Icon {
fn clone(&self) -> Self {
let mut this = Self::default().path(self.path.clone());
this.style = self.style.clone();
this.rotation = self.rotation;
this.size = self.size;
this.text_color = self.text_color;
this
@@ -224,9 +187,7 @@ impl Icon {
Self::default().path(name.path())
}
/// Set the icon path of the Assets bundle
///
/// For example: `icons/foo.svg`
/// Sets the icon path, relative to the assets bundle (for example `icons/foo.svg`).
pub fn path(mut self, path: impl Into<SharedString>) -> Self {
self.path = path.into();
self
@@ -291,7 +252,7 @@ impl From<Icon> for AnyElement {
impl Render for Icon {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon);
let text_color = self.text_color.unwrap_or_else(|| cx.theme().text_muted);
let text_size = window.text_style().font_size.to_pixels(window.rem_size());
let has_base_size = self.style.size.width.is_some() || self.style.size.height.is_some();
@@ -309,8 +270,5 @@ impl Render for Icon {
Size::Large => this.size_6(),
})
.path(self.path.clone())
.when_some(self.rotation, |this, rotation| {
this.with_transformation(Transformation::rotate(rotation))
})
}
}
+13 -14
View File
@@ -1,10 +1,11 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
Animation, AnimationExt as _, App, Hsla, IntoElement, ParentElement, RenderOnce, Styled as _,
Transformation, Window, div, ease_in_out, percentage,
App, Hsla, IntoElement, ParentElement, RenderOnce, Styled as _, Transformation, Window, div,
percentage,
};
use instant::Duration;
use crate::motion::{EASE_IN_OUT, MotionSpec, Painter, pulse_delta};
use crate::{Icon, IconName, Sizable, Size};
#[derive(IntoElement)]
@@ -15,6 +16,12 @@ pub struct Indicator {
color: Option<Hsla>,
}
impl Default for Indicator {
fn default() -> Self {
Self::new()
}
}
impl Indicator {
pub fn new() -> Self {
Self {
@@ -31,12 +38,6 @@ impl Indicator {
}
}
impl Default for Indicator {
fn default() -> Self {
Self::new()
}
}
impl Sizable for Indicator {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
@@ -45,16 +46,14 @@ impl Sizable for Indicator {
}
impl RenderOnce for Indicator {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let spec = MotionSpec::new(self.speed.as_millis().max(1) as u64, EASE_IN_OUT);
let phase = pulse_delta(&spec, Painter::from(window.current_view()), cx);
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))),
),
.transform(Transformation::rotate(percentage(phase))),
)
}
}
+1 -1
View File
@@ -11,5 +11,5 @@ pub(crate) fn clear_button(cx: &App) -> Button {
.ghost()
.xsmall()
.tab_stop(false)
.text_color(cx.theme().icon_muted)
.text_color(cx.theme().text_faint)
}
+16 -83
View File
@@ -1,17 +1,16 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement,
MouseButton, ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign,
Window, div, px,
App, DefiniteLength, Edges, Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Pixels, Rems, RenderOnce, StyleRefinement, Styled, TextAlign, Window, div,
px,
};
use gpui_base::InputBase;
use gpui_base::input::{InputBaseState, InputEditorStyle, InputMode, InputModeKind, TextareaMode};
use theme::ActiveTheme;
use theme::{ActiveTheme, button_radius};
use crate::button::{Button, ButtonVariants as _};
use crate::indicator::Indicator;
use crate::input::clear_button;
use crate::{IconName, Selectable, Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
use crate::{Sizable, Size, StyleSized, StyledExt, h_flex, v_flex};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn primary_paste_offset(
@@ -45,35 +44,27 @@ fn primary_paste_offset(
/// The background of an input frame, which reads muted while the input is disabled.
fn input_background(disabled: bool, cx: &App) -> Hsla {
if disabled {
cx.theme().surface_background
cx.theme().surface
} else {
cx.theme().elevated_surface_background
cx.theme().input_bg
}
}
/// The colors base paints input text with, read from the coop theme.
///
/// Base fills in any color left transparent from its own palette, and that
/// palette is only a projection of this one, so every color coop paints with is
/// named here rather than left to resolve.
fn input_editor_style(cx: &App) -> InputEditorStyle {
let theme = cx.theme();
InputEditorStyle {
foreground: theme.text,
muted_foreground: theme.text_muted,
background: theme.elevated_surface_background,
border: theme.border,
muted_foreground: theme.text_faint,
background: theme.input_bg,
border: theme.border_faint,
selection: theme.selection,
caret: theme.cursor,
caret: theme.caret,
..InputEditorStyle::default()
}
}
/// The input's own padding, resolved to pixels.
///
/// Base applies the multi-line padding itself so that the text, the gutter, and
/// the scrollbar share one inset, and the single-line frame carries its own.
/// Both come from the same size table, resolved through the window's rem size.
fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges<Pixels> {
let mut probe = div().input_px(size).input_py(size).refine_style(style);
let padding = probe.style().padding.clone();
@@ -94,23 +85,14 @@ fn input_paddings(size: Size, style: &StyleRefinement, window: &Window) -> Edges
}
/// A text input element bound to an [`InputState`] or a [`TextareaState`].
///
/// The editing kind lives on the state, so `Input::new` accepts either and
/// infers which one is rendered.
#[derive(IntoElement)]
pub struct Input<M: InputModeKind = InputMode> {
state: Entity<InputBaseState<M>>,
style: StyleRefinement,
size: Size,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
height: Option<DefiniteLength>,
appearance: bool,
cleanable: bool,
mask_toggle: bool,
disabled: bool,
tab_index: isize,
selected: bool,
}
/// A styled multi-line text input.
@@ -123,17 +105,6 @@ impl<M: InputModeKind> Sizable for Input<M> {
}
}
impl<M: InputModeKind> Selectable for Input<M> {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl<M: InputModeKind> Input<M> {
/// Create a new [`Input`] element bind to the given state.
pub fn new(state: &Entity<InputBaseState<M>>) -> Self {
@@ -141,28 +112,12 @@ impl<M: InputModeKind> Input<M> {
state: state.clone(),
size: Size::default(),
style: StyleRefinement::default(),
prefix: None,
suffix: None,
height: None,
appearance: true,
cleanable: false,
mask_toggle: false,
disabled: false,
tab_index: 0,
selected: false,
}
}
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set the appearance of the input field, if false the input field will no border, background.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
@@ -180,18 +135,6 @@ impl<M: InputModeKind> Input<M> {
self.disabled = disabled;
self
}
fn render_toggle_mask_button(state: &Entity<InputBaseState<M>>) -> impl IntoElement {
Button::new("toggle-mask")
.icon(IconName::Eye)
.xsmall()
.ghost()
.tab_stop(false)
.on_click({
let state = state.clone();
move |_, window, cx| state.update(cx, |state, cx| state.toggle_masked(window, cx))
})
}
}
impl<M: InputModeKind> Styled for Input<M> {
@@ -235,10 +178,8 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
let background = input_background(disabled, cx);
let show_clear_button =
self.cleanable && state.is_editable() && !loading && !text_is_empty && !multi_line;
let has_suffix = self.suffix.is_some() || loading || self.mask_toggle || show_clear_button;
let has_suffix = loading || show_clear_button;
let prefix = self.prefix;
let suffix = self.suffix;
let state_entity = self.state.clone();
InputBase::new(("input", self.state.entity_id()))
@@ -294,19 +235,15 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
},
)
.items_center()
.when(multi_line, |this| {
this.h_auto()
.when_some(self.height, |this, height| this.h(height))
})
.when(multi_line, |this| this.h_auto())
.when(self.appearance, |this| {
this.bg(background)
.when(self.disabled, |this| this.opacity(0.5))
.rounded(cx.theme().radius)
.rounded(px(button_radius()))
})
.tab_index(self.tab_index)
.tab_index(0)
.gap(gap_x)
.refine_style(&self.style)
.children(prefix)
.when(!multi_line, |this| this.child(state_entity.clone()))
.when(multi_line, |this| {
this.child(
@@ -322,9 +259,6 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
.gap(gap_x)
.items_center()
.when(loading, |this| this.child(Indicator::new()))
.when(self.mask_toggle, |this| {
this.child(Self::render_toggle_mask_button(&state_entity))
})
.when(show_clear_button, |this| {
this.child(clear_button(cx).on_click({
let state = state_entity.clone();
@@ -335,8 +269,7 @@ impl<M: InputModeKind> RenderOnce for Input<M> {
})
}
}))
})
.children(suffix),
}),
)
})
}
+11 -38
View File
@@ -1,29 +1,20 @@
use gpui::{
Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke, ParentElement as _,
RenderOnce, StyleRefinement, Styled, Window, div, relative,
Action, AsKeystroke, IntoElement, KeyContext, Keystroke, ParentElement as _, RenderOnce,
StyleRefinement, Styled, Window, div,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::StyledExt;
/// A key binding tag
#[derive(IntoElement, Clone, Debug)]
pub struct Kbd {
pub(crate) struct Kbd {
style: StyleRefinement,
stroke: Keystroke,
}
impl From<Keystroke> for Kbd {
fn from(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
}
}
}
impl Kbd {
pub fn new(stroke: Keystroke) -> Self {
fn new(stroke: Keystroke) -> Self {
Self {
style: StyleRefinement::default(),
stroke,
@@ -31,7 +22,7 @@ impl Kbd {
}
/// Return the first keybinding for the given action and context.
pub fn binding_for_action(
pub(crate) fn binding_for_action(
action: &dyn Action,
context: Option<&str>,
window: &Window,
@@ -50,25 +41,8 @@ impl Kbd {
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the first keybinding for the given action and focus handle.
pub fn binding_for_action_in(
action: &dyn Action,
focus_handle: &FocusHandle,
window: &Window,
) -> Option<Self> {
let binding = window.highest_precedence_binding_for_action_in(action, focus_handle)?;
binding
.keystrokes()
.first()
.map(|key| Self::new(key.as_keystroke().clone()))
}
/// Return the Platform specific keybinding string by KeyStroke
///
/// - macOS: https://support.apple.com/en-us/HT201236
/// - Windows: https://support.microsoft.com/en-us/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec
pub fn format(key: &Keystroke) -> String {
/// Return the platform specific keybinding string for a keystroke.
fn format(key: &Keystroke) -> String {
#[cfg(target_os = "macos")]
const DIVIDER: &str = "";
#[cfg(not(target_os = "macos"))]
@@ -197,16 +171,15 @@ impl RenderOnce for Kbd {
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
div()
.border_1()
.border_color(cx.theme().border)
.border_color(cx.theme().border_faint)
.text_color(cx.theme().text_muted)
.bg(cx.theme().surface_background)
.bg(cx.theme().surface_raised)
.py_0p5()
.px_1()
.min_w_5()
.text_center()
.rounded_sm()
.line_height(relative(1.))
.text_xs()
.text_style(TextStyle::Caption)
.whitespace_normal()
.flex_shrink_0()
.refine_style(&self.style)
+1 -1
View File
@@ -23,9 +23,9 @@ pub mod switch;
pub mod tab;
pub mod tooltip;
mod animation;
mod icon;
mod kbd;
pub mod motion;
mod popover;
mod resizable;
mod root;
+18 -14
View File
@@ -1,7 +1,8 @@
use std::ops::Range;
use gpui::{
AnyElement, App, ElementId, IntoElement, SharedString, StyleRefinement, Styled as _, Window,
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, StyleRefinement,
Styled as _, Window,
};
use gpui_base::{TextView, TextViewStyle};
use theme::ActiveTheme as _;
@@ -62,15 +63,28 @@ impl RenderedText {
/// Render the message as a GPUI Kit text view.
pub fn element(&self, id: ElementId, _window: &Window, cx: &App) -> AnyElement {
let theme = cx.theme();
let code_block = StyleRefinement::default().font_family(code_font_family());
let code_block = StyleRefinement::default().font_family(theme.font_mono.clone());
let muted_heading = theme.text_muted;
TextView::markdown(id, self.text.clone())
.style(
TextViewStyle::default()
.with_foreground(theme.text)
.with_muted_foreground(theme.text_muted)
.with_link(theme.text_accent)
.with_code_background(theme.elevated_surface_background)
// Deeply nested headings read as secondary text.
.with_heading(move |level| {
if level >= 4 {
StyleRefinement::default().text_color(muted_heading)
} else {
StyleRefinement::default()
}
})
.with_link(theme.accent)
.with_code_background(theme.code_wash)
.with_inline_code(HighlightStyle {
color: Some(theme.code_text),
..Default::default()
})
.with_border(theme.border)
.with_code_block(code_block)
.with_dark(theme.is_dark()),
@@ -101,13 +115,3 @@ fn is_web_url(url: &str) -> bool {
let url = url.to_ascii_lowercase();
url.starts_with("http://") || url.starts_with("https://")
}
fn code_font_family() -> &'static str {
if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"monospace"
}
}
+4 -30
View File
@@ -75,12 +75,6 @@ where
}
}
/// Set the anchor corner for the dropdown menu popover.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the style refinement for the dropdown menu trigger.
fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.style = style;
@@ -88,13 +82,10 @@ where
}
}
/// Opens a [`PopupMenu`] when its child is clicked with a mouse button
/// (right by default), keeping the child's own click handler intact.
/// Opens a [`PopupMenu`] when its child is right-clicked, keeping the child's own click handler.
#[derive(IntoElement)]
pub struct ContextMenu {
id: ElementId,
anchor: Anchor,
mouse_button: MouseButton,
child: AnyElement,
builder: Rc<MenuBuilder>,
}
@@ -107,24 +98,10 @@ impl ContextMenu {
) -> Self {
Self {
id: id.into(),
anchor: Anchor::TopLeft,
mouse_button: MouseButton::Right,
child: child.into_any_element(),
builder: Rc::new(builder),
}
}
/// Set the anchor corner of the menu, default is `Anchor::TopLeft`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
}
#[derive(Default)]
@@ -132,10 +109,7 @@ struct MenuState {
menu: Option<Entity<PopupMenu>>,
}
/// Builds the menu once and reuses it until it is dismissed.
///
/// The popover content closure runs on every render, so rebuilding the menu
/// entity each time would drop its focus and selection state.
/// Builds the menu once and reuses it until dismissed, preserving focus and selection state.
fn cached_menu(
menu_state: &Entity<MenuState>,
builder: Rc<MenuBuilder>,
@@ -196,8 +170,8 @@ impl RenderOnce for ContextMenu {
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
.appearance(false)
.overlay_closable(false)
.anchor(self.anchor)
.mouse_button(self.mouse_button)
.anchor(Anchor::TopLeft)
.mouse_button(MouseButton::Right)
.trigger_with(move |_open, _window, _cx| self.child)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
}
+12 -15
View File
@@ -5,7 +5,7 @@ use gpui::{
Styled, Window,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Disableable, StyledExt, h_flex};
@@ -39,18 +39,12 @@ impl MenuItemElement {
}
}
/// Set ListItem as the selected item style.
/// Set the selected style of the item.
pub(crate) fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Set the disabled state of the MenuItem.
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set a handler for when the MenuItem is clicked.
pub(crate) fn on_click(
mut self,
@@ -61,7 +55,10 @@ impl MenuItemElement {
}
/// Set a handler for when the mouse enters the MenuItem.
pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
pub(crate) fn on_hover(
mut self,
handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_hover = Some(Box::new(handler));
self
}
@@ -93,7 +90,7 @@ impl RenderOnce for MenuItemElement {
.group(&self.group_name)
.gap_x_1()
.p_1()
.text_sm()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.relative()
.items_center()
@@ -104,12 +101,12 @@ impl RenderOnce for MenuItemElement {
})
.when(!self.disabled, |this| {
this.group_hover(self.group_name, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
this.bg(cx.theme().element_hover)
.text_color(cx.theme().text)
})
.when(self.selected, |this| {
this.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
this.bg(cx.theme().element_active)
.text_color(cx.theme().text)
})
.when_some(self.on_click, |this, on_click| {
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
@@ -118,7 +115,7 @@ impl RenderOnce for MenuItemElement {
.on_click(on_click)
})
})
.when(self.disabled, |this| this.text_color(cx.theme().text_muted))
.when(self.disabled, |this| this.text_color(cx.theme().text_faint))
.children(self.children)
}
}
+78 -324
View File
@@ -2,22 +2,22 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, Anchor, AnyElement, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent,
Edges, Entity, EventEmitter, FocusHandle, Focusable, Half, InteractiveElement, IntoElement,
KeyBinding, MouseDownEvent, ParentElement, Pixels, Point, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, anchored, div, px, rems,
Action, Anchor, App, AppContext, Axis, Bounds, ClickEvent, Context, DismissEvent, Edges,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding,
MouseDownEvent, ParentElement, Pixels, Point, Render, SharedString, Styled, WeakEntity, Window,
anchored, div, px, rems,
};
use gpui_base::actions::{Cancel, Confirm, SelectDown, SelectLeft, SelectRight, SelectUp};
use theme::{ActiveTheme, Side};
use theme::{ActiveTheme, Side, TextStyle, Typeset as _};
use crate::kbd::Kbd;
use crate::menu::menu_item::MenuItemElement;
use crate::scroll::ScrollableElement;
use crate::{ElementExt, Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
use crate::{Disableable, ElementExt, Icon, IconName, Sizable as _, StyledExt, h_flex, v_flex};
const CONTEXT: &str = "PopupMenu";
const MAX_WIDTH: Pixels = px(500.);
pub fn init(cx: &mut App) {
pub(crate) fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
@@ -28,7 +28,7 @@ pub fn init(cx: &mut App) {
]);
}
/// An menu item in a popup menu.
/// A menu item in a popup menu.
pub enum PopupMenuItem {
/// A menu separator item.
Separator,
@@ -40,26 +40,11 @@ pub enum PopupMenuItem {
label: SharedString,
disabled: bool,
checked: bool,
is_link: bool,
action: Option<Box<dyn Action>>,
// For link item
#[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
},
/// A menu item with custom element render.
ElementItem {
icon: Option<Icon>,
disabled: bool,
checked: bool,
action: Option<Box<dyn Action>>,
#[allow(clippy::type_complexity)]
render: Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>,
#[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
},
/// A submenu item that opens another popup menu.
///
/// NOTE: This is only supported when the parent menu is not `scrollable`.
Submenu {
icon: Option<Icon>,
label: SharedString,
@@ -80,14 +65,13 @@ impl PopupMenuItem {
disabled: false,
checked: false,
action: None,
is_link: false,
handler: None,
}
}
/// Create a new submenu item that opens another popup menu.
#[inline]
pub fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
fn submenu(label: impl Into<SharedString>, menu: Entity<PopupMenu>) -> Self {
PopupMenuItem::Submenu {
icon: None,
label: label.into(),
@@ -98,27 +82,22 @@ impl PopupMenuItem {
/// Create a separator menu item.
#[inline]
pub fn separator() -> Self {
fn separator() -> Self {
PopupMenuItem::Separator
}
/// Creates a label menu item.
/// Create a label menu item.
#[inline]
pub fn label(label: impl Into<SharedString>) -> Self {
fn label(label: impl Into<SharedString>) -> Self {
PopupMenuItem::Label(label.into())
}
/// Set the icon for the menu item.
///
/// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
fn icon(mut self, icon: impl Into<Icon>) -> Self {
match &mut self {
PopupMenuItem::Item { icon: i, .. } => {
*i = Some(icon.into());
}
PopupMenuItem::ElementItem { icon: i, .. } => {
*i = Some(icon.into());
}
PopupMenuItem::Submenu { icon: i, .. } => {
*i = Some(icon.into());
}
@@ -128,32 +107,19 @@ impl PopupMenuItem {
}
/// Set the action for the menu item.
///
/// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
pub fn action(mut self, action: Box<dyn Action>) -> Self {
match &mut self {
PopupMenuItem::Item { action: a, .. } => {
*a = Some(action);
}
PopupMenuItem::ElementItem { action: a, .. } => {
*a = Some(action);
}
_ => {}
fn action(mut self, action: Box<dyn Action>) -> Self {
if let PopupMenuItem::Item { action: a, .. } = &mut self {
*a = Some(action);
}
self
}
/// Set the disabled state for the menu item.
///
/// Only works for [`PopupMenuItem::Item`], [`PopupMenuItem::ElementItem`] and [`PopupMenuItem::Submenu`].
pub fn disabled(mut self, disabled: bool) -> Self {
fn disabled(mut self, disabled: bool) -> Self {
match &mut self {
PopupMenuItem::Item { disabled: d, .. } => {
*d = disabled;
}
PopupMenuItem::ElementItem { disabled: d, .. } => {
*d = disabled;
}
PopupMenuItem::Submenu { disabled: d, .. } => {
*d = disabled;
}
@@ -162,57 +128,37 @@ impl PopupMenuItem {
self
}
/// Set checked state for the menu item.
///
/// NOTE: If `check_side` is [`Side::Left`], the icon will replace with a check icon.
pub fn checked(mut self, checked: bool) -> Self {
match &mut self {
PopupMenuItem::Item { checked: c, .. } => {
*c = checked;
}
PopupMenuItem::ElementItem { checked: c, .. } => {
*c = checked;
}
_ => {}
/// Set the checked state for the menu item.
fn checked(mut self, checked: bool) -> Self {
if let PopupMenuItem::Item { checked: c, .. } = &mut self {
*c = checked;
}
self
}
/// Add a click handler for the menu item.
///
/// Only works for [`PopupMenuItem::Item`] and [`PopupMenuItem::ElementItem`].
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
{
match &mut self {
PopupMenuItem::Item { handler: h, .. } => {
*h = Some(Rc::new(handler));
}
PopupMenuItem::ElementItem { handler: h, .. } => {
*h = Some(Rc::new(handler));
}
_ => {}
if let PopupMenuItem::Item { handler: h, .. } = &mut self {
*h = Some(Rc::new(handler));
}
self
}
#[inline]
fn is_clickable(&self) -> bool {
!matches!(self, PopupMenuItem::Separator)
&& matches!(
self,
PopupMenuItem::Item {
disabled: false,
..
} | PopupMenuItem::ElementItem {
disabled: false,
..
} | PopupMenuItem::Submenu {
disabled: false,
..
}
)
matches!(
self,
PopupMenuItem::Item {
disabled: false,
..
} | PopupMenuItem::Submenu {
disabled: false,
..
}
)
}
#[inline]
@@ -220,14 +166,9 @@ impl PopupMenuItem {
matches!(self, PopupMenuItem::Separator)
}
fn has_left_icon(&self, check_side: Side) -> bool {
fn has_left_icon(&self) -> bool {
match self {
PopupMenuItem::Item { icon, checked, .. } => {
icon.is_some() || (check_side.is_left() && *checked)
}
PopupMenuItem::ElementItem { icon, checked, .. } => {
icon.is_some() || (check_side.is_left() && *checked)
}
PopupMenuItem::Item { icon, checked, .. } => icon.is_some() || *checked,
PopupMenuItem::Submenu { icon, .. } => icon.is_some(),
_ => false,
}
@@ -235,66 +176,38 @@ impl PopupMenuItem {
#[inline]
fn is_checked(&self) -> bool {
match self {
PopupMenuItem::Item { checked, .. } => *checked,
PopupMenuItem::ElementItem { checked, .. } => *checked,
_ => false,
}
matches!(self, PopupMenuItem::Item { checked: true, .. })
}
}
pub struct PopupMenu {
pub(crate) focus_handle: FocusHandle,
pub(crate) menu_items: Vec<PopupMenuItem>,
/// The focus handle of Entity to handle actions.
pub(crate) action_context: Option<FocusHandle>,
focus_handle: FocusHandle,
menu_items: Vec<PopupMenuItem>,
axis: Axis,
selected_index: Option<usize>,
min_width: Option<Pixels>,
max_width: Option<Pixels>,
max_height: Option<Pixels>,
bounds: Bounds<Pixels>,
size: Size,
check_side: Side,
/// The parent menu of this menu, if this is a submenu
parent_menu: Option<WeakEntity<Self>>,
scrollable: bool,
external_link_icon: bool,
scroll_handle: ScrollHandle,
/// This will update on render
submenu_anchor: (Anchor, Pixels),
_subscriptions: Vec<Subscription>,
}
impl PopupMenu {
pub(crate) fn new(cx: &mut App) -> Self {
Self {
focus_handle: cx.focus_handle(),
action_context: None,
parent_menu: None,
menu_items: Vec::new(),
selected_index: None,
axis: Axis::Vertical,
min_width: None,
max_width: None,
max_height: None,
check_side: Side::Left,
bounds: Bounds::default(),
scrollable: false,
scroll_handle: ScrollHandle::default(),
external_link_icon: true,
size: Size::default(),
submenu_anchor: (Anchor::TopLeft, Pixels::ZERO),
_subscriptions: vec![],
}
}
pub fn build(
pub(crate) fn build(
window: &mut Window,
cx: &mut App,
f: impl FnOnce(Self, &mut Window, &mut Context<PopupMenu>) -> Self,
@@ -302,45 +215,15 @@ impl PopupMenu {
cx.new(|cx| f(Self::new(cx), window, cx))
}
/// Set min width of the popup menu, default is 120px
pub fn min_w(mut self, width: impl Into<Pixels>) -> Self {
self.min_width = Some(width.into());
self
}
/// Set max height of the popup menu, default is half of the window height
pub fn max_h(mut self, height: impl Into<Pixels>) -> Self {
self.max_height = Some(height.into());
self
}
/// Set the axis of children to horizontal.
pub fn horizontal(mut self) -> Self {
self.axis = Axis::Horizontal;
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, scrollable: bool) -> Self {
self.scrollable = scrollable;
self
}
/// Add Menu Item
pub fn menu(self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.menu_with_disabled(label, action, false)
}
/// Add Menu Item with disabled state
pub fn menu_with_disabled(
mut self,
label: impl Into<SharedString>,
action: Box<dyn Action>,
disabled: bool,
) -> Self {
self.add_menu_item(label, None, action, disabled, false);
pub fn menu(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
self.add_menu_item(label, None, action, false, false);
self
}
@@ -399,19 +282,7 @@ impl PopupMenu {
/// Add a Submenu
pub fn submenu(
self,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
self.submenu_with_icon(None, label, window, cx, f)
}
/// Add a Submenu item with icon
pub fn submenu_with_icon(
mut self,
icon: Option<Icon>,
label: impl Into<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
@@ -423,9 +294,7 @@ impl PopupMenu {
view.parent_menu = Some(parent_menu);
});
self.menu_items.push(
PopupMenuItem::submenu(label, submenu).when_some(icon, |this, icon| this.icon(icon)),
);
self.menu_items.push(PopupMenuItem::submenu(label, submenu));
self
}
@@ -436,13 +305,6 @@ impl PopupMenu {
self
}
/// Use small size, the menu item will have smaller height.
#[allow(dead_code)]
pub(crate) fn small(mut self) -> Self {
self.size = Size::Small;
self
}
fn add_menu_item(
&mut self,
label: impl Into<SharedString>,
@@ -461,7 +323,7 @@ impl PopupMenu {
self
}
pub(crate) fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
fn active_submenu(&self) -> Option<Entity<PopupMenu>> {
if let Some(ix) = self.selected_index
&& let Some(item) = self.menu_items.get(ix)
{
@@ -489,52 +351,24 @@ impl PopupMenu {
}
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.selected_index {
let item = self.menu_items.get(index);
match item {
Some(PopupMenuItem::Item {
handler, action, ..
}) => {
if let Some(handler) = handler {
handler(&ClickEvent::default(), window, cx);
} else if let Some(action) = action.as_ref() {
self.dispatch_confirm_action(action.as_ref(), window, cx);
}
self.dismiss(&Cancel, window, cx)
}
Some(PopupMenuItem::ElementItem {
handler, action, ..
}) => {
if let Some(handler) = handler {
handler(&ClickEvent::default(), window, cx);
} else if let Some(action) = action.as_ref() {
self.dispatch_confirm_action(action.as_ref(), window, cx);
}
self.dismiss(&Cancel, window, cx)
}
_ => {}
if let Some(index) = self.selected_index
&& let Some(PopupMenuItem::Item {
handler, action, ..
}) = self.menu_items.get(index)
{
if let Some(handler) = handler {
handler(&ClickEvent::default(), window, cx);
} else if let Some(action) = action.as_ref() {
window.dispatch_action(action.boxed_clone(), cx);
}
}
}
fn dispatch_confirm_action(
&self,
action: &dyn Action,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(context) = self.action_context.as_ref() {
context.focus(window, cx);
self.dismiss(&Cancel, window, cx)
}
window.dispatch_action(action.boxed_clone(), cx);
}
fn set_selected_index(&mut self, ix: usize, cx: &mut Context<Self>) {
if self.selected_index != Some(ix) {
self.selected_index = Some(ix);
self.scroll_handle.scroll_to_item(ix);
cx.notify();
}
}
@@ -675,6 +509,7 @@ impl PopupMenu {
}
}
#[expect(clippy::only_used_in_recursion)]
fn dismiss(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
if self.active_submenu().is_some() {
return;
@@ -682,20 +517,17 @@ impl PopupMenu {
cx.emit(DismissEvent);
// Focus back to the previous focused handle.
if let Some(action_context) = self.action_context.as_ref() {
window.focus(action_context, cx);
}
let Some(parent_menu) = self.parent_menu.clone() else {
return;
};
// Dismiss parent menu, when this menu is dismissed
_ = parent_menu.update(cx, |view, cx| {
if let Err(error) = parent_menu.update(cx, |view, cx| {
view.selected_index = None;
view.dismiss(&Cancel, window, cx);
});
}) {
log::warn!("Failed to dismiss parent menu: {error}");
}
}
fn handle_dismiss(
@@ -732,16 +564,7 @@ impl PopupMenu {
) -> Option<Kbd> {
let action = action?;
match self
.action_context
.as_ref()
.and_then(|handle| Kbd::binding_for_action_in(action.as_ref(), handle, window))
{
Some(kbd) => Some(kbd),
// Fallback to App level key binding
None => Kbd::binding_for_action(action.as_ref(), None, window),
}
.map(|this| {
Kbd::binding_for_action(action.as_ref(), None, window).map(|this| {
this.p_0()
.flex_nowrap()
.border_0()
@@ -754,7 +577,7 @@ impl PopupMenu {
checked: bool,
icon: Option<Icon>,
_: &mut Window,
_: &mut Context<Self>,
cx: &mut Context<Self>,
) -> Option<impl IntoElement> {
if !has_icon {
return None;
@@ -763,7 +586,7 @@ impl PopupMenu {
let icon = if let Some(icon) = icon {
icon.clone()
} else if checked {
Icon::new(IconName::Check)
Icon::new(IconName::Check).text_color(cx.theme().accent)
} else {
Icon::empty()
};
@@ -771,16 +594,10 @@ impl PopupMenu {
Some(icon)
}
#[inline]
fn max_width(&self) -> Pixels {
self.max_width.unwrap_or(px(500.))
}
/// Calculate the anchor corner and left offset for child submenu
fn update_submenu_menu_anchor(&mut self, window: &Window) {
let bounds = self.bounds;
let max_width = self.max_width();
let (anchor, left) = if max_width + bounds.origin.x > window.bounds().size.width {
let (anchor, left) = if MAX_WIDTH + bounds.origin.x > window.bounds().size.width {
(Anchor::TopRight, -px(16.))
} else {
(Anchor::TopLeft, bounds.size.width - px(8.))
@@ -803,12 +620,7 @@ impl PopupMenu {
cx: &mut Context<Self>,
) -> MenuItemElement {
let has_left_icon = options.has_left_icon;
let is_left_check = options.check_side.is_left() && item.is_checked();
let right_check_icon = if options.check_side.is_right() && item.is_checked() {
Some(Icon::new(IconName::Check).xsmall())
} else {
None
};
let is_checked = item.is_checked();
let selected = self.selected_index == Some(ix);
const EDGE_PADDING: Pixels = px(4.);
@@ -817,17 +629,14 @@ impl PopupMenu {
let is_submenu = matches!(item, PopupMenuItem::Submenu { .. });
let group_name = format!("{}:item-{}", cx.entity().entity_id(), ix);
let (item_height, radius) = match self.size {
Size::Small => (px(20.), options.radius.half()),
_ => (px(26.), options.radius),
};
let item_height = px(26.);
let this = MenuItemElement::new(ix, &group_name)
.relative()
.text_sm()
.text_style(TextStyle::Callout)
.py_0()
.px(INNER_PADDING)
.rounded(radius)
.rounded(options.radius)
.items_center()
.selected(selected)
.on_hover(cx.listener(move |this, hovered, _, cx| {
@@ -848,45 +657,25 @@ impl PopupMenu {
.my_0p5()
.mx_neg_1()
.border_b(px(2.))
.border_color(cx.theme().border)
.border_color(cx.theme().border_faint)
.disabled(true),
PopupMenuItem::Label(label) => this.disabled(true).cursor_default().child(
h_flex().cursor_default().items_center().gap_x_1().child(
div()
.flex_1()
.text_sm()
.text_style(TextStyle::Callout)
.font_semibold()
.text_color(cx.theme().text_muted)
.child(label.clone()),
),
),
PopupMenuItem::ElementItem {
render, disabled, ..
} => this
.when(!disabled, |this| {
this.on_click(
cx.listener(move |this, _, window, cx| this.on_click(ix, window, cx)),
)
})
.disabled(*disabled)
.child(
h_flex()
.flex_1()
.min_h(item_height)
.items_center()
.gap_x_2()
.child((render)(window, cx))
.children(right_check_icon.map(|icon| icon.ml_3())),
),
PopupMenuItem::Item {
icon,
label,
action,
disabled,
is_link,
..
} => {
let show_link_icon = *is_link && self.external_link_icon;
let action = action.as_ref().map(|action| action.boxed_clone());
let key = self.render_key_binding(action, window, cx);
@@ -897,10 +686,10 @@ impl PopupMenu {
})
.disabled(*disabled)
.h(item_height)
.gap_x_2()
.gap_x(px(theme::SPACE))
.children(Self::render_icon(
has_left_icon,
is_left_check,
is_checked,
icon.clone(),
window,
cx,
@@ -911,22 +700,7 @@ impl PopupMenu {
.gap_3()
.items_center()
.justify_between()
.when(!show_link_icon, |this| this.child(label.clone()))
.children(right_check_icon)
.when(show_link_icon, |this| {
this.child(
h_flex()
.w_full()
.justify_between()
.gap_2()
.child(label.clone())
.child(
Icon::new(IconName::Link)
.xsmall()
.text_color(cx.theme().text_muted),
),
)
})
.child(label.clone())
.children(key),
)
}
@@ -944,7 +718,7 @@ impl PopupMenu {
.min_h(item_height)
.size_full()
.items_center()
.gap_x_2()
.gap_x(px(theme::SPACE))
.children(Self::render_icon(
has_left_icon,
false,
@@ -955,7 +729,7 @@ impl PopupMenu {
.child(
h_flex()
.flex_1()
.gap_2()
.gap(px(theme::SPACE))
.items_center()
.justify_between()
.child(label.clone())
@@ -1002,7 +776,6 @@ impl Focusable for PopupMenu {
#[derive(Clone, Copy)]
struct RenderOptions {
has_left_icon: bool,
check_side: Side,
radius: Pixels,
}
@@ -1013,21 +786,12 @@ impl Render for PopupMenu {
let view = cx.entity().clone();
let items_count = self.menu_items.len();
let max_width = self.max_width();
let max_height = self.max_height.unwrap_or_else(|| {
let window_half_height = window.window_bounds().get_bounds().size.height * 0.5;
window_half_height.min(px(450.))
});
let has_left_icon = self
.menu_items
.iter()
.any(|item| item.has_left_icon(self.check_side));
let has_left_icon = self.menu_items.iter().any(|item| item.has_left_icon());
let options = RenderOptions {
has_left_icon,
check_side: self.check_side,
radius: cx.theme().radius.min(px(8.)),
// Items sit p_1 (4px) inside the surface plate; keep corners concentric.
radius: px(theme::inset_radius(theme::surface_radius(), 4.0)),
};
v_flex()
@@ -1051,17 +815,11 @@ impl Render for PopupMenu {
.p_1()
.gap_y_0p5()
.min_w(rems(8.))
.when_some(self.min_width, |this, min_width| this.min_w(min_width))
.max_w(max_width)
.max_w(MAX_WIDTH)
.map(|this| match self.axis {
Axis::Horizontal => this.flex().flex_row().items_center(),
Axis::Vertical => this.flex().flex_col(),
})
.when(self.scrollable, |this| {
this.max_h(max_height)
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
})
.children(
self.menu_items
.iter()
@@ -1072,9 +830,5 @@ impl Render for PopupMenu {
)
.on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)),
)
.when(self.scrollable, |this| {
// TODO: When the menu is limited by `overflow_y_scroll`, the sub-menu will cannot be displayed.
this.vertical_scrollbar(&self.scroll_handle)
})
}
}
+9 -21
View File
@@ -5,7 +5,7 @@ use gpui::{
div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{InteractiveElementExt as _, StyledExt as _, h_flex, v_flex};
@@ -190,9 +190,9 @@ impl RenderOnce for MessageRow {
.when(show_author, |this| {
this.child(
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().text_placeholder)
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
.text_color(cx.theme().text_dim)
.when_some(author, |this, author| {
this.child(div().font_semibold().child(author))
})
@@ -212,7 +212,7 @@ impl RenderOnce for MessageRow {
.when_some(on_double_click, |this, listener| {
this.on_double_click(listener)
})
.hover(|this| this.bg(cx.theme().surface_background))
.hover(|this| this.bg(cx.theme().element_hover))
.into_any_element()
}
}
@@ -225,7 +225,6 @@ pub struct WelcomeMessage {
icon: Option<AnyElement>,
title: Option<SharedString>,
message: Option<SharedString>,
children: SmallVec<[AnyElement; 2]>,
}
impl WelcomeMessage {
@@ -237,7 +236,6 @@ impl WelcomeMessage {
icon: None,
title: None,
message: None,
children: SmallVec::new(),
}
}
@@ -261,13 +259,6 @@ impl WelcomeMessage {
self.message = Some(message.into());
self
}
/// Append an element below the body text.
#[must_use]
pub fn child(mut self, child: impl IntoElement) -> Self {
self.children.push(child.into_any_element());
self
}
}
impl Styled for WelcomeMessage {
@@ -284,13 +275,12 @@ impl RenderOnce for WelcomeMessage {
icon,
title,
message,
children,
} = self;
v_flex()
.id(id)
.w_full()
.gap_2()
.gap(px(theme::SPACE))
.p_3()
.items_center()
.justify_center()
@@ -305,8 +295,7 @@ impl RenderOnce for WelcomeMessage {
.when_some(title, |this, title| {
this.child(
div()
.text_sm()
.font_semibold()
.text_style(TextStyle::Headline)
.text_color(cx.theme().text)
.child(title),
)
@@ -314,12 +303,11 @@ impl RenderOnce for WelcomeMessage {
.when_some(message, |this, message| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(message),
)
}),
)
.children(children)
}
}
+668
View File
@@ -0,0 +1,668 @@
//! The motion kit, ported from bezel (`crates/motion/src/lib.rs` and its
//! `app.rs`, MIT License, Copyright (c) 2026 bezel contributors). TranslateY
//! rides a relative `top` inset, which taffy applies after layout, so
//! siblings never move.
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use gpui::{
Animation, AnimationElement, AnimationExt as _, App, Context, ElementId, EntityId, Global,
Hsla, IntoElement, Rgba, SharedString, Styled, Window, px,
};
use instant::Instant;
/// Redraw rate for anything driven by [`pulse_delta`].
const PULSE_FPS: f32 = 30.0;
/// How long a view stays on the tick list after its last pulse paint.
const PULSE_LEASE: Duration = Duration::from_millis(300);
/// Redraw rate for hover fades; [`HOVER_FADE`] paints in nine steps at it.
const HOVER_FPS: f32 = 60.0;
/// Floor on how long the clock sleeps between wake-ups.
const MIN_SLEEP: Duration = Duration::from_millis(1);
/// One view's claim on the clock.
struct Lease {
/// The fastest rate anything on this view has claimed.
period: Duration,
/// When this view is next owed a redraw.
due: Instant,
/// A notify is out and the render it provoked has not renewed yet.
in_flight: bool,
/// When the claim lapses if nothing renews it.
until: Instant,
}
#[derive(Default)]
struct PulseClock {
epoch: Option<Instant>,
leases: HashMap<EntityId, Lease>,
running: bool,
}
impl Global for PulseClock {}
impl PulseClock {
fn next_wake(&self) -> Option<Instant> {
self.leases
.values()
.map(|lease| lease.due.min(lease.until))
.min()
}
}
/// A component's line back to the view that paints it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Painter(EntityId);
impl Painter {
/// The view this render belongs to; take it once where the state is built.
pub fn of<T: 'static>(cx: &Context<T>) -> Self {
Self(cx.entity_id())
}
/// Redraw this view once.
pub fn notify(self, cx: &mut App) {
cx.notify(self.0);
}
/// Claim `fps` redraws a second; renew from `render` so an unmounted
/// element drops off.
pub fn lease(self, fps: f32, until: Duration, cx: &mut App) {
lease(self.0, fps, until, cx);
}
/// Whether the clock is what asked for the render running now.
pub fn woken(self, cx: &App) -> bool {
cx.try_global::<PulseClock>()
.and_then(|clock| clock.leases.get(&self.0))
.is_some_and(|lease| lease.in_flight)
}
}
impl From<EntityId> for Painter {
fn from(entity_id: EntityId) -> Self {
Self(entity_id)
}
}
impl From<Painter> for EntityId {
fn from(painter: Painter) -> Self {
painter.0
}
}
fn lease(view: EntityId, fps: f32, until: Duration, cx: &mut App) {
if cx.pause_when_inactive() && cx.active_window().is_none() {
return;
}
let now = cx.background_executor().now();
let period = Duration::from_secs_f32(1.0 / fps.clamp(1.0, 240.0));
let clock = cx.default_global::<PulseClock>();
clock
.leases
.entry(view)
.and_modify(|lease| {
lease.period = lease.period.min(period);
lease.due = lease.due.min(now + period);
lease.in_flight = false;
lease.until = lease.until.max(now + until);
})
.or_insert(Lease {
period,
due: now + period,
in_flight: false,
until: now + until,
});
if clock.running {
return;
}
clock.running = true;
cx.spawn(async move |cx| {
loop {
let sleep = cx.update(|cx| {
let now = cx.background_executor().now();
let clock = cx.default_global::<PulseClock>();
clock
.next_wake()
.map(|wake| wake.saturating_duration_since(now).max(MIN_SLEEP))
});
let Some(sleep) = sleep else { break };
cx.background_executor().timer(sleep).await;
let parked = cx.update(|cx| {
let now = cx.background_executor().now();
tick_hover_fades();
let clock = cx.default_global::<PulseClock>();
clock.leases.retain(|_, lease| lease.until > now);
if clock.leases.is_empty() {
clock.running = false;
return true;
}
let mut owed = Vec::new();
for (view, lease) in clock.leases.iter_mut() {
if lease.due > now {
continue;
}
lease.due += lease.period;
if lease.due <= now {
lease.due = now + lease.period;
}
lease.in_flight = true;
owed.push(*view);
}
for view in owed {
cx.notify(view);
}
false
});
if parked {
break;
}
}
})
.detach();
}
/// Current phase `[0,1)` of a repeating spec; reduced motion returns a
/// static 0 and schedules nothing.
pub fn pulse_delta(spec: &MotionSpec, painter: Painter, cx: &mut App) -> f32 {
if cx.reduced_motion() {
return 0.0;
}
painter.lease(PULSE_FPS, PULSE_LEASE, cx);
let now = cx.background_executor().now();
let clock = cx.default_global::<PulseClock>();
let epoch = *clock.epoch.get_or_insert(now);
let period = spec.total().as_secs_f32();
((now - epoch).as_secs_f32() / period).fract()
}
/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing function, evaluated exactly.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CubicBezier {
pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
}
impl CubicBezier {
pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
Self { x1, y1, x2, y2 }
}
fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
let c = 3.0 * a;
let bb = 3.0 * (b - a) - c;
let aa = 1.0 - c - bb;
(aa, bb, c)
}
fn sample_x(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.x1, self.x2);
((a * t + b) * t + c) * t
}
fn sample_y(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.y1, self.y2);
((a * t + b) * t + c) * t
}
fn sample_x_derivative(&self, t: f32) -> f32 {
let (a, b, c) = Self::coefficients(self.x1, self.x2);
(3.0 * a * t + 2.0 * b) * t + c
}
fn solve_t_for_x(&self, x: f32) -> f32 {
let mut t = x;
for _ in 0..8 {
let err = self.sample_x(t) - x;
if err.abs() < 1e-6 {
return t;
}
let d = self.sample_x_derivative(t);
if d.abs() < 1e-6 {
break;
}
t -= err / d;
}
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
for _ in 0..32 {
let mid = (lo + hi) / 2.0;
if self.sample_x(mid) < x {
lo = mid
} else {
hi = mid
}
}
(lo + hi) / 2.0
}
/// Eased output for input progress `x ∈ [0,1]`, clamped hard — gpui's
/// animation element asserts its delta is in `[0,1]` and aborts.
pub fn eval(&self, x: f32) -> f32 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
}
/// This curve as a gpui easing closure.
pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
move |x| self.eval(x)
}
}
/// The signature entrance curve — CSS `cubic-bezier(0.16, 1, 0.3, 1)`.
pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
/// CSS `ease-out` — width/height transitions.
pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
/// CSS `ease` — quick fades, menu/dialog pops.
pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
/// Sidebar resort glide — CSS `cubic-bezier(0.22, 1, 0.36, 1)`.
pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
/// CSS `ease-in-out`.
pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
/// Tailwind's default transition curve — CSS `cubic-bezier(0.4, 0, 0.2, 1)`.
pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
/// One catalog entry: duration + optional delay + curve; gpui has no native
/// delay, so `delay_ms` holds progress at 0 until it elapses.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MotionSpec {
pub duration_ms: u64,
pub delay_ms: u64,
pub curve: CubicBezier,
}
impl MotionSpec {
pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
Self {
duration_ms,
delay_ms: 0,
curve,
}
}
pub const fn with_delay(mut self, delay_ms: u64) -> Self {
self.delay_ms = delay_ms;
self
}
/// Wall-clock span of the whole timeline (delay + duration).
pub fn total(&self) -> Duration {
Duration::from_millis(self.delay_ms + self.duration_ms)
}
/// Eased progress (0..1) for a raw timeline delta (0..1 across `total`).
pub fn progress(&self, raw_delta: f32) -> f32 {
let total = (self.delay_ms + self.duration_ms) as f32;
if total <= 0.0 || self.duration_ms == 0 {
return 1.0;
}
let t =
(raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
self.curve.eval(t.clamp(0.0, 1.0))
}
/// A oneshot gpui [`Animation`] for this spec.
pub fn animation(&self) -> Animation {
let spec = *self;
Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
}
}
/// Entrances: 0.5s expo-out fade + 4px rise.
pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
/// Quick fade: 0.15s.
pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
/// Popover-in: 0.14s.
pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
/// Popover-out: 0.1s — quicker than the entrance.
pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
/// Dialog-in: 0.18s.
pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
/// Boot-splash exit: 0.5s fade + 6px lift after a 0.15s hold.
pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
/// Sidebar / pane width+height transitions: 200ms ease-out.
pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
/// Tab drag-reorder sliding transforms: 150ms.
pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
/// Per-pane collapse: 180ms height.
pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
/// A gliding move the reader caused rather than one they are dragging.
pub const LAYOUT: MotionSpec = MotionSpec::new(200, EASE_OUT);
/// Chevron rotate: 200ms.
pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
/// Scroll-to-row glide: 500ms ease-in-out.
pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
/// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`].
pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
/// Toast slide/fade, coop's own: 250ms over [`EASE_TAILWIND`].
pub const NOTIFICATION_IN: MotionSpec = MotionSpec::new(250, EASE_TAILWIND);
/// Pulse loader period: 2.4s.
pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
/// Spinner wave period: 750ms.
pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
/// Standard entrance: opacity 0→1 + translateY 4→0 over [`FADE_IN`].
pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, FADE_IN.animation(), |el, t| {
el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
})
}
/// Quick opacity-only fade over [`FADE_QUICK`].
pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
}
/// Popover entrance: fade + translateY −2→0 over [`MENU_IN`].
pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, MENU_IN.animation(), |el, t| {
el.relative()
.opacity(0.3 + 0.7 * t)
.top(px(-2.0 * (1.0 - t)))
})
}
/// Popover exit over [`MENU_OUT`]; the caller supplies progress because
/// `with_animation` replays from 0 on remount.
pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, MENU_OUT.animation(), move |el, _| {
el.relative().opacity(1.0 - t).top(px(-2.0 * t))
})
}
/// Dialog entrance over [`DIALOG_IN`] (fade + 2px rise).
pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, DIALOG_IN.animation(), |el, t| {
el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
})
}
/// Boot-splash exit: hold 150ms, then fade out + lift 6px over 500ms.
pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
where
E: Styled + IntoElement + 'static,
{
element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
el.opacity(1.0 - t).top(px(-6.0 * t))
})
}
/// Linear interpolation.
pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
from + (to - from) * t
}
/// One element's hover fade progress over [`HOVER_FADE`].
#[derive(Debug, Clone, Copy)]
pub struct FadeEntry {
origin: f32,
target: f32,
started: Instant,
seen: u64,
}
impl FadeEntry {
fn value(&self, now: Instant, duration: Duration) -> f32 {
let elapsed = now.saturating_duration_since(self.started);
if duration.is_zero() || elapsed >= duration {
return self.target;
}
let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
}
fn settled(&self, now: Instant, duration: Duration) -> bool {
self.origin == self.target || now.saturating_duration_since(self.started) >= duration
}
}
/// Which fade: the view that paints it plus the element key — two views
/// using the same string would trade washes.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Fade {
pub painter: Painter,
pub key: SharedString,
}
impl Fade {
pub fn new(painter: Painter, key: impl Into<SharedString>) -> Self {
Self {
painter,
key: key.into(),
}
}
}
/// Per-fade progress store.
#[derive(Default)]
pub struct HoverFades {
pub entries: HashMap<Fade, FadeEntry>,
frame: u64,
}
impl HoverFades {
pub fn duration() -> Duration {
HOVER_FADE.total().mul_f32(speed_scale())
}
/// Pointer entered (`hovered`) or left the element behind `fade`.
pub fn set_at(&mut self, fade: &Fade, hovered: bool, reduced: bool, now: Instant) {
let target = if hovered { 1.0 } else { 0.0 };
let duration = Self::duration();
let current = self
.entries
.get(fade)
.map(|e| e.value(now, duration))
.unwrap_or(0.0);
if target == 0.0 && !self.entries.contains_key(fade) {
return;
}
let origin = if reduced { target } else { current };
let seen = self.frame;
self.entries.insert(
fade.clone(),
FadeEntry {
origin,
target,
started: now,
seen,
},
);
}
/// Hover progress (0..1) for `fade` at `now`; stamps liveness.
pub fn value_at(&mut self, fade: &Fade, now: Instant) -> f32 {
let frame = self.frame;
match self.entries.get_mut(fade) {
Some(entry) => {
entry.seen = frame;
entry.value(now, Self::duration())
}
None => 0.0,
}
}
/// Advance the frame counter and prune settled or unmounted entries.
pub fn tick_at(&mut self, now: Instant) -> bool {
self.frame += 1;
let frame = self.frame;
let duration = Self::duration();
let mut active = false;
self.entries.retain(|_, entry| {
if entry.seen + 1 < frame {
return false;
}
let settled = entry.settled(now, duration);
if !settled {
active = true;
}
!(settled && entry.target == 0.0)
});
active
}
}
thread_local! {
static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
}
/// Hover progress (0..1) for `fade` this frame.
pub fn hover_t(fade: &Fade) -> f32 {
HOVER_FADES.with(|fades| fades.borrow_mut().value_at(fade, Instant::now()))
}
/// Record a hover flip for `fade`; prefer [`hover_listener`].
pub fn set_hover(fade: &Fade, hovered: bool, reduced: bool) {
HOVER_FADES.with(|fades| {
fades
.borrow_mut()
.set_at(fade, hovered, reduced, Instant::now())
});
}
/// An `.on_hover` listener driving the fade.
pub fn hover_listener(fade: Fade) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
move |hovered, _window, cx| {
set_hover(&fade, *hovered, cx.reduced_motion());
fade.painter.lease(HOVER_FPS, HoverFades::duration(), cx);
}
}
/// Once-per-tick bookkeeping for the fade store, driven by the clock.
fn tick_hover_fades() {
HOVER_FADES.with(|fades| {
fades.borrow_mut().tick_at(Instant::now());
});
}
/// Blend two colors by `t` the way the browser transitions them: sRGB with
/// premultiplied alpha, so a wash fading in never passes through grey.
pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
let t = t.clamp(0.0, 1.0);
if t <= 0.0 {
return from;
}
if t >= 1.0 {
return to;
}
let (f, g) = (Rgba::from(from), Rgba::from(to));
let a = lerp(f.a, g.a, t);
if a <= f32::EPSILON {
return Hsla::from(Rgba { a: 0.0, ..g });
}
Hsla::from(Rgba {
r: lerp(f.r * f.a, g.r * g.a, t) / a,
g: lerp(f.g * f.a, g.g * g.a, t) / a,
b: lerp(f.b * f.a, g.b * g.a, t) / a,
a,
})
}
/// The standard hover blend: rest → hover color at this fade's progress.
pub fn hover_blend(fade: &Fade, rest: Hsla, hover: Hsla) -> Hsla {
mix(rest, hover, hover_t(fade))
}
/// Process-wide motion speed, as raw `f32` bits; an atomic mirror because the
/// timelines are read from free functions with no `cx` in scope.
static SPEED: AtomicU32 = AtomicU32::new(1.0f32.to_bits());
/// How far every timeline in the catalog is stretched. `1.0` is the designed
/// speed.
pub fn speed_scale() -> f32 {
f32::from_bits(SPEED.load(Ordering::Relaxed))
}
/// Stretch every catalog timeline by `scale`; clamped, and a non-finite
/// `scale` resets to `1.0`.
pub fn set_speed(scale: f32) {
let scale = if scale.is_finite() {
scale.clamp(0.01, 100.0)
} else {
1.0
};
SPEED.store(scale.to_bits(), Ordering::Relaxed);
}
/// Process-wide motion switches.
#[derive(Clone, Copy)]
struct MotionSettings {
reduced_motion: bool,
pause_when_inactive: bool,
}
impl Global for MotionSettings {}
impl MotionSettings {
/// Defaults: the OS flag rules reduce motion; the clock pauses when no
/// window is active.
fn get(cx: &App) -> Self {
cx.try_global::<MotionSettings>().copied().unwrap_or(Self {
reduced_motion: false,
pause_when_inactive: true,
})
}
}
/// App-level motion switches.
pub trait AppExt {
/// The OS reduce-motion flag ORed with the manual override.
fn reduced_motion(&self) -> bool;
/// Set the manual reduce-motion override.
fn set_reduced_motion(&mut self, reduced: bool);
/// Whether the leased clock claims frames while no window is active.
fn pause_when_inactive(&self) -> bool;
fn set_pause_when_inactive(&mut self, pause: bool);
}
impl AppExt for App {
fn reduced_motion(&self) -> bool {
self.reduce_motion() || MotionSettings::get(self).reduced_motion
}
fn set_reduced_motion(&mut self, reduced: bool) {
let mut settings = MotionSettings::get(self);
settings.reduced_motion = reduced;
self.set_global(settings);
}
fn pause_when_inactive(&self) -> bool {
MotionSettings::get(self).pause_when_inactive
}
fn set_pause_when_inactive(&mut self, pause: bool) {
let mut settings = MotionSettings::get(self);
settings.pause_when_inactive = pause;
self.set_global(settings);
}
}
+12 -9
View File
@@ -4,7 +4,7 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div,
Styled, Window, div, px,
};
use theme::ActiveTheme;
@@ -44,8 +44,7 @@ impl Nav {
}
}
/// Marks the row as clickable even when it has no `on_click` handler, so it
/// still shows the hover affordance. Used by rows whose click opens a menu.
/// Marks the row as clickable even without an `on_click` handler, keeping the hover affordance.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
@@ -104,15 +103,21 @@ impl Styled for Nav {
impl RenderOnce for Nav {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
let text_color = if self.selected {
cx.theme().text
} else {
cx.theme().text_muted
};
v_flex().h_10().w_full().items_center().child(
v_flex().h_10().w_full().justify_center().child(
h_flex()
.id(self.id)
.h_9()
.w_full()
.px_1p5()
.gap_1p5()
.rounded(cx.theme().radius_lg)
.rounded(px(theme::button_radius()))
.text_color(text_color)
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
@@ -126,10 +131,8 @@ impl RenderOnce for Nav {
)
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.when(self.selected, |this| {
this.bg(cx.theme().ghost_element_active)
})
.hover(|this| this.bg(cx.theme().element_hover))
.when(self.selected, |this| this.bg(cx.theme().element_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
+14 -20
View File
@@ -4,9 +4,9 @@ use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
Styled, Window, div,
Styled, Window, div, px,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Selectable, StyledExt, h_flex};
@@ -21,7 +21,6 @@ pub struct NavItem {
label: SharedString,
suffix: Option<AnyElement>,
selected: bool,
clickable: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
@@ -43,18 +42,10 @@ impl NavItem {
label: label.into(),
suffix: None,
selected: false,
clickable: false,
on_click: None,
}
}
/// Marks the item as clickable even when it has no `on_click` handler, so it
/// still shows the hover affordance. Used by items whose click opens a menu.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
@@ -94,7 +85,12 @@ impl Styled for NavItem {
impl RenderOnce for NavItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some() || self.clickable;
let clickable = self.on_click.is_some();
let text_color = if self.selected {
cx.theme().text
} else {
cx.theme().text_muted
};
h_flex()
.id(self.id)
@@ -102,16 +98,16 @@ impl RenderOnce for NavItem {
.px_2()
.py_1()
.w_full()
.gap_2()
.rounded(cx.theme().radius)
.text_color(cx.theme().text)
.gap(px(theme::SPACE))
.rounded(px(theme::button_radius()))
.text_color(text_color)
.child(self.icon)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.text_style(TextStyle::Callout)
.child(self.label),
)
.when_some(self.suffix, |this, suffix| {
@@ -119,10 +115,8 @@ impl RenderOnce for NavItem {
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.when(self.selected, |this| {
this.bg(cx.theme().ghost_element_active)
})
.hover(|this| this.bg(cx.theme().element_hover))
.when(self.selected, |this| this.bg(cx.theme().element_active))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
+40 -78
View File
@@ -5,19 +5,19 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
Anchor, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context,
DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
Subscription, Window, div, px, relative,
Anchor, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, DismissEvent,
ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _,
Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Subscription,
Window, div, px, relative,
};
use gpui_base::{
Toast as BaseToast, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState,
ToastTransitionStatus,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _};
use crate::motion::NOTIFICATION_IN;
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
/// How often the notification lifecycle clock is sampled.
@@ -40,20 +40,13 @@ pub enum NotificationKind {
impl NotificationKind {
fn icon(&self, cx: &App) -> Icon {
match self {
Self::Info => Icon::new(IconName::Info)
.with_size(Size::Medium)
.text_color(cx.theme().icon),
Self::Success => Icon::new(IconName::CheckCircle)
.with_size(Size::Medium)
.text_color(cx.theme().icon_accent),
Self::Warning => Icon::new(IconName::Warning)
.with_size(Size::Medium)
.text_color(cx.theme().text_warning),
Self::Error => Icon::new(IconName::CloseCircle)
.with_size(Size::Medium)
.text_color(cx.theme().danger_foreground),
}
let (name, color) = match self {
Self::Info => (IconName::Info, cx.theme().text_muted),
Self::Success => (IconName::CheckCircle, cx.theme().success),
Self::Warning => (IconName::Warning, cx.theme().warning),
Self::Error => (IconName::CloseCircle, gpui::white()),
};
Icon::new(name).with_size(Size::Medium).text_color(color)
}
}
@@ -78,10 +71,7 @@ impl From<(TypeId, ElementId)> for NotificationId {
#[allow(clippy::type_complexity)]
/// 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.
/// Pushing a notification with the same id replaces the previous one.
id: NotificationId,
style: StyleRefinement,
kind: Option<NotificationKind>,
@@ -128,9 +118,7 @@ impl From<(NotificationKind, SharedString)> for Notification {
struct DefaultIdType;
impl Notification {
/// Create a new notification.
///
/// The default id is a random UUID.
/// Creates a notification with a random UUID id.
pub fn new() -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into();
let id = (TypeId::of::<DefaultIdType>(), id.into());
@@ -177,12 +165,7 @@ impl Notification {
.with_kind(NotificationKind::Error)
}
/// Set the type for unique identification of the notification.
///
/// ```rs
/// struct MyNotificationKind;
/// let notification = Notification::new("Hello").id::<MyNotificationKind>();
/// ```
/// Sets the type used to uniquely identify the notification.
pub fn id<T: Sized + 'static>(mut self) -> Self {
self.id = TypeId::of::<T>().into();
self
@@ -194,15 +177,13 @@ impl Notification {
self
}
/// Set the title of the notification, default is None.
///
/// If title is None, the notification will not have a title.
/// Sets the title of the notification, default is None.
pub fn title(mut self, title: impl Into<SharedString>) -> Self {
self.title = Some(title.into());
self
}
/// Set the type of the notification, default is NotificationType::Info.
/// Sets the kind of the notification, default is `NotificationKind::Info`.
pub fn with_kind(mut self, kind: NotificationKind) -> Self {
self.kind = Some(kind);
self
@@ -214,9 +195,7 @@ impl Notification {
self
}
/// Set the action button of the notification.
///
/// When an action is set, the notification will not autohide.
/// Sets an action button; the notification will not autohide when set.
pub fn action<F>(mut self, action: F) -> Self
where
F: Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button + 'static,
@@ -226,8 +205,8 @@ impl Notification {
self
}
/// Dismiss the notification.
pub fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
/// Emits a request for the notification list to dismiss this notification.
pub(crate) fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissRequest);
}
@@ -300,12 +279,14 @@ impl Render for Notification {
};
let background = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_background,
_ => cx.theme().surface_background,
Some(NotificationKind::Error) => cx.theme().danger_strong,
_ => cx.theme().surface_overlay,
};
let text_color = match self.kind {
Some(NotificationKind::Error) => cx.theme().danger_foreground,
// White on the danger plate in both appearances, per bezel's
// destructive pairing.
Some(NotificationKind::Error) => gpui::white(),
_ => cx.theme().text,
};
@@ -326,10 +307,12 @@ impl Render for Notification {
.border_color(cx.theme().border)
.bg(background)
.text_color(text_color)
.rounded(cx.theme().radius_lg)
.when(cx.theme().shadow, |this| this.shadow_md())
.rounded(px(theme::surface_radius()))
.when(cx.theme().shadow, |this| {
this.shadow(theme::surface_shadows())
})
.p_2()
.gap_2()
.gap(px(theme::SPACE))
.justify_start()
.items_start()
.when(only_message, |this| this.items_center())
@@ -343,12 +326,12 @@ impl Render for Notification {
.gap_1()
.overflow_hidden()
.when_some(self.title.clone(), |this, title| {
this.child(h_flex().h_5().text_sm().font_semibold().child(title))
this.child(h_flex().h_5().text_style(TextStyle::Headline).child(title))
})
.when_some(self.message.clone(), |this, message| {
this.child(
div()
.text_sm()
.text_style(TextStyle::Callout)
.when(has_title, |this| this.text_color(cx.theme().text_muted))
.line_height(relative(1.3))
.child(message),
@@ -356,7 +339,7 @@ impl Render for Notification {
})
.when_some(content, |this, content| this.child(content))
.when_some(action, |this, action| {
this.gap_2().child(
this.gap(px(theme::SPACE)).child(
h_flex()
.mt_2()
.w_full()
@@ -396,15 +379,11 @@ impl Render for Notification {
}))
.with_animation(
ElementId::NamedInteger("slide-down".into(), closing as u64),
Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
NOTIFICATION_IN.animation(),
move |this, delta| {
if closing {
let opacity = 1. - delta;
let that = this
.shadow_none()
.opacity(opacity)
.when(opacity < 0.85, |this| this.shadow_none());
let that = this.shadow_none().opacity(opacity);
match placement {
Anchor::TopRight | Anchor::BottomRight => {
let x_offset = px(0.) + delta * px(45.);
@@ -446,8 +425,7 @@ impl Render for Notification {
/// A list of notifications.
pub(crate) struct NotificationList {
/// Notifications that will be auto hidden.
pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
notifications: ToastManager<NotificationId, Entity<Notification>>,
/// Measured geometry and interaction state of the visible stack.
stack_state: ToastStackState,
@@ -455,12 +433,11 @@ pub(crate) struct NotificationList {
/// Whether the lifecycle clock is running. The loop clears it as it exits.
is_advancing: bool,
/// Subscriptions
_subscriptions: HashMap<NotificationId, Subscription>,
}
impl NotificationList {
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
pub(crate) fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
Self {
notifications: ToastManager::new(ToastMotion::default()),
stack_state: ToastStackState::default(),
@@ -469,10 +446,7 @@ impl NotificationList {
}
}
/// Tick the toast lifecycle until the last notification is unmounted.
///
/// The stack expansion is sampled here because it reaches the list through
/// no event, and an idle window should arm no timer.
/// Ticks the toast lifecycle, including stack expansion which arrives through no event.
fn start_advancing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.is_advancing {
return;
@@ -520,7 +494,7 @@ impl NotificationList {
}
}
pub fn push(
pub(crate) fn push(
&mut self,
notification: impl Into<Notification>,
window: &mut Window,
@@ -575,18 +549,6 @@ impl NotificationList {
}
cx.notify();
}
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
for id in self
.notifications
.dismiss_all(cx.background_executor().now())
{
if let Some(note) = self.notifications.get(&id) {
note.update(cx, |note, cx| note.begin_close(cx));
}
}
cx.notify();
}
}
impl Render for NotificationList {
+34 -52
View File
@@ -6,13 +6,13 @@ use gpui::{
MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
};
use gpui_base::Popover as BasePopover;
pub use gpui_base::PopoverState;
pub(crate) use gpui_base::PopoverState;
use crate::{Selectable, StyledExt as _, v_flex};
/// A popover element that can be triggered by a button or any other element.
#[derive(IntoElement)]
pub struct Popover {
pub(crate) struct Popover {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
@@ -26,8 +26,7 @@ pub struct Popover {
>,
>,
children: Vec<AnyElement>,
/// Style for trigger element.
/// This is used for hotfix the trigger element style to support w_full.
/// Style refinement applied to the trigger element.
trigger_style: Option<StyleRefinement>,
mouse_button: MouseButton,
appearance: bool,
@@ -35,8 +34,8 @@ pub struct Popover {
}
impl Popover {
/// Create a new Popover with `view` mode.
pub fn new(id: impl Into<ElementId>) -> Self {
/// Creates a new Popover with the given id.
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
@@ -51,23 +50,20 @@ impl Popover {
}
}
/// Set the anchor corner of the popover, default is `Corner::TopLeft`.
///
/// This method is kept for backward compatibility with `Corner` type.
/// Internally, it converts `Corner` to `Anchor`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
/// Sets the anchor corner of the popover, default is `Anchor::TopLeft`.
pub(crate) fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button to trigger the popover, default is `MouseButton::Left`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
/// Sets the mouse button that triggers the popover, default is `MouseButton::Left`.
pub(crate) fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
/// Set the trigger element of the popover.
pub fn trigger<T>(mut self, trigger: T) -> Self
/// Sets the trigger element, marked as selected while the popover is open.
pub(crate) fn trigger<T>(mut self, trigger: T) -> Self
where
T: Selectable + IntoElement + 'static,
{
@@ -78,12 +74,8 @@ impl Popover {
self
}
/// Set the trigger from a builder, for elements that have no selected state.
///
/// [`Self::trigger`] marks the trigger as selected while the popover is
/// open, so it cannot be used with elements whose selection carries a
/// different meaning, such as a row that indicates the current room.
pub fn trigger_with<F>(mut self, trigger: F) -> Self
/// Sets the trigger from a builder, for elements that have no selected state.
pub(crate) fn trigger_with<F>(mut self, trigger: F) -> Self
where
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
{
@@ -91,23 +83,20 @@ impl Popover {
self
}
/// Set the style for the trigger element.
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
/// Sets the style for the trigger element.
pub(crate) fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.trigger_style = Some(style);
self
}
/// Set whether clicking outside the popover will dismiss it, default is `true`.
pub fn overlay_closable(mut self, closable: bool) -> Self {
/// Sets whether clicking outside the popover dismisses it, default is `true`.
pub(crate) fn overlay_closable(mut self, closable: bool) -> Self {
self.overlay_closable = closable;
self
}
/// Set the content builder for content of the Popover.
///
/// This callback will called every time on render the popover.
/// So, you should avoid creating new elements or entities in the content closure.
pub fn content<F, E>(mut self, content: F) -> Self
/// Sets the content builder, called on every render of the popover.
pub(crate) fn content<F, E>(mut self, content: F) -> Self
where
E: IntoElement,
F: Fn(&mut PopoverState, &mut Window, &mut Context<PopoverState>) -> E + 'static,
@@ -118,32 +107,13 @@ impl Popover {
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 appearance(mut self, appearance: bool) -> Self {
/// Sets whether the popover renders with default styling, default is `true`.
pub(crate) fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
}
impl ParentElement for Popover {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for Popover {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl Popover {
pub(crate) fn render_popover_content(
fn render_popover_content(
anchor: Anchor,
appearance: bool,
_: &mut Window,
@@ -162,6 +132,18 @@ impl Popover {
}
}
impl ParentElement for Popover {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl Styled for Popover {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Popover {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let anchor = self.anchor;
+3 -3
View File
@@ -12,15 +12,15 @@ pub(crate) fn resize_handle_appearance() -> ResizeHandleRenderer {
Rc::new(
|context: &ResizeHandleContext, _: &mut Window, cx: &mut App| {
let color = if context.is_active() {
cx.theme().border_selected
cx.theme().border_strong
} else {
cx.theme().border
cx.theme().element_hover
};
let axis = context.axis();
Some(
div()
.bg(cx.theme().border_variant)
.bg(cx.theme().border_faint)
.group_hover("handle", move |this| this.bg(color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE))
+9 -27
View File
@@ -4,14 +4,14 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyView, App, AppContext, Bounds, Context, CursorStyle, Decorations, ElementId, Entity,
FocusHandle, HitboxBehavior, Hsla, InteractiveElement, IntoElement, MouseButton,
ParentElement as _, Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle,
Window, canvas, div, point, px, size,
FocusHandle, HitboxBehavior, InteractiveElement, IntoElement, MouseButton, ParentElement as _,
Pixels, Point, Render, ResizeEdge, Size, Styled, Tiling, WeakFocusHandle, Window, canvas, div,
point, px, size,
};
use gpui_base::TextSelectionLayer;
use theme::{
ActiveTheme, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
CLIENT_SIDE_DECORATION_SHADOW,
CLIENT_SIDE_DECORATION_SHADOW, base_text_size, surface_shadows,
};
use crate::dialog::Dialog;
@@ -230,19 +230,12 @@ impl Root {
});
cx.notify();
}
/// Clear all notifications from the notification layer.
pub fn clear_notifications(&mut self, window: &mut Window, cx: &mut Context<'_, Root>) {
self.notification
.update(cx, |view, cx| view.clear(window, cx));
cx.notify();
}
}
impl Render for Root {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let rem_size = cx.theme().font_size;
let font_family = cx.theme().font_family.clone();
let rem_size = px(base_text_size());
let font_family = cx.theme().font_sans.clone();
let decorations = window.window_decorations();
// Set the base font size
@@ -361,19 +354,8 @@ impl Render for Root {
.when(!tiling.right, |div| {
div.border_r(CLIENT_SIDE_DECORATION_BORDER)
})
.when(!tiling.is_tiled(), |div| {
div.shadow(vec![gpui::BoxShadow {
color: Hsla {
h: 0.,
s: 0.,
l: 0.,
a: 0.4,
},
blur_radius: CLIENT_SIDE_DECORATION_SHADOW / 2.,
spread_radius: px(0.),
offset: point(px(0.0), px(0.0)),
inset: false,
}])
.when(!tiling.is_tiled() && cx.theme().shadow, |div| {
div.shadow(surface_shadows())
}),
})
.on_mouse_move(|_e, _, cx| {
@@ -381,7 +363,7 @@ impl Render for Root {
})
.size_full()
.font_family(font_family)
.bg(cx.theme().surface_background)
.bg(cx.theme().bg)
.text_color(cx.theme().text)
.child(TextSelectionLayer)
.child(self.view.clone()),
+1 -1
View File
@@ -1,4 +1,4 @@
mod scrollable;
pub use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
pub use gpui_base::Scrollbar;
pub use scrollable::*;
+8 -126
View File
@@ -3,35 +3,14 @@ use std::panic::Location;
use gpui::prelude::FluentBuilder;
use gpui::{
App, Div, Element, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
ScrollHandle, Stateful, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, px,
ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use gpui_base::{Scrollbar, ScrollbarAxis, ScrollbarHandle};
use theme::ActiveTheme as _;
use gpui_base::{Scrollbar, ScrollbarAxis};
use crate::StyledExt as _;
/// A trait for elements that can be made scrollable with scrollbars.
pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Element {
/// Adds a scrollbar to the element.
#[track_caller]
fn scrollbar<H: ScrollbarHandle + Clone>(
self,
scroll_handle: &H,
axis: impl Into<ScrollbarAxis>,
) -> Self {
self.child(ScrollbarLayer {
id: "scrollbar_layer".into(),
axis: axis.into(),
scroll_handle: scroll_handle.clone(),
})
}
/// Adds a vertical scrollbar to the element.
#[track_caller]
fn vertical_scrollbar<H: ScrollbarHandle + Clone>(self, scroll_handle: &H) -> Self {
self.scrollbar(scroll_handle, ScrollbarAxis::Vertical)
}
/// Almost equivalent to [`StatefulInteractiveElement::overflow_y_scroll`], but adds Vertical scrollbar.
#[track_caller]
fn overflow_y_scrollbar(self) -> Scrollable<Self> {
@@ -39,72 +18,6 @@ pub trait ScrollableElement: InteractiveElement + Styled + ParentElement + Eleme
}
}
/// The scrollbar the application's theme describes: a 10px rail holding a 6px
/// rounded thumb that grows to 8px under the pointer.
fn scrollbar<H: ScrollbarHandle + Clone>(
scroll_handle: &H,
axis: ScrollbarAxis,
cx: &App,
) -> Scrollbar {
let theme = cx.theme();
let (thumb_width, thumb_radius) = if theme.scrollbar_mode.is_scrolling() {
(px(6.), px(3.))
} else {
(px(8.), px(4.))
};
Scrollbar::new(scroll_handle).axis(axis).styles(|styles| {
styles
.track(|track| {
track
.bg(theme.scrollbar_track_background)
.border_color(theme.scrollbar_thumb_border)
.width(px(10.))
})
.track_hover(|track| track.bg(theme.scrollbar_thumb_background).width(px(10.)))
.thumb(|thumb| {
thumb
.bg(theme.scrollbar_thumb_background)
.width(thumb_width)
.inset(px(1.))
.radius(thumb_radius)
.min_length(px(48.))
})
.thumb_hover(|thumb| {
thumb
.bg(theme.scrollbar_thumb_hover_background)
.width(px(8.))
.inset(px(1.))
.radius(px(4.))
})
.thumb_active(|thumb| {
thumb
.bg(theme.scrollbar_thumb_hover_background)
.width(px(8.))
.inset(px(1.))
.radius(px(4.))
})
})
}
/// A scrollbar child that resolves the theme while rendering, which the
/// [`ScrollableElement`] helpers cannot do at the call site.
#[derive(IntoElement)]
struct ScrollbarLayer<H: ScrollbarHandle + Clone> {
id: ElementId,
axis: ScrollbarAxis,
scroll_handle: H,
}
impl<H> RenderOnce for ScrollbarLayer<H>
where
H: ScrollbarHandle + Clone + 'static,
{
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
scrollbar(&self.scroll_handle, self.axis, cx).id(self.id)
}
}
/// A scrollable element wrapper that adds scrollbars to an interactive element.
#[derive(IntoElement)]
pub struct Scrollable<E: InteractiveElement + Styled + ParentElement + Element> {
@@ -128,15 +41,6 @@ where
}
}
impl<E> Styled for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
{
fn style(&mut self) -> &mut StyleRefinement {
self.element.style()
}
}
impl<E> ParentElement for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element,
@@ -146,18 +50,6 @@ where
}
}
impl InteractiveElement for Scrollable<Div> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl InteractiveElement for Scrollable<Stateful<Div>> {
fn interactivity(&mut self) -> &mut gpui::Interactivity {
self.element.interactivity()
}
}
impl<E> RenderOnce for Scrollable<E>
where
E: InteractiveElement + Styled + ParentElement + Element + 'static,
@@ -167,8 +59,6 @@ where
.use_keyed_state(self.id.clone(), cx, |_, _| ScrollHandle::default())
.read(cx)
.clone();
// Inherit the size from the element style.
let style = StyleRefinement {
size: self.element.style().size.clone(),
..Default::default()
@@ -190,22 +80,14 @@ where
ScrollbarAxis::Horizontal => this.flex_row().overflow_x_scroll(),
ScrollbarAxis::Both => this.overflow_scroll(),
})
.child(
self.element
// Refine element size to `flex_1`.
.size_auto()
.flex_1(),
),
.child(self.element.size_auto().flex_1()),
)
.child(
Scrollbar::new(&scroll_handle)
.axis(self.axis)
.id("scrollbar"),
)
.child(scrollbar(&scroll_handle, self.axis, cx).id("scrollbar"))
}
}
impl ScrollableElement for Div {}
impl<E> ScrollableElement for Stateful<E>
where
E: ParentElement + Styled + Element,
Self: InteractiveElement,
{
}
+15 -17
View File
@@ -1,7 +1,6 @@
use gpui::{App, Div, Pixels, Refineable, StyleRefinement, Styled, div, px};
pub use gpui_base::component_traits::{Disableable, Selectable};
use serde::{Deserialize, Serialize};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius, surface_shadows};
/// Returns a `Div` as horizontal flex layout.
pub fn h_flex() -> Div {
@@ -15,7 +14,7 @@ pub fn v_flex() -> Div {
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_1().w_full().h_px().bg(cx.theme().border_variant)
div().my_1().w_full().h_px().bg(cx.theme().border_faint)
}
macro_rules! font_weight {
@@ -54,18 +53,20 @@ pub trait StyledExt: Styled + Sized {
/// Set as Popover style
#[inline]
fn popover_style(self, cx: &mut App) -> Self {
self.bg(cx.theme().background)
let shadows = cx.theme().shadow.then(surface_shadows).unwrap_or_default();
self.bg(cx.theme().surface_overlay)
.border_1()
.border_color(cx.theme().border)
.shadow_md()
.rounded(cx.theme().radius_lg)
.shadow(shadows)
.rounded(px(surface_radius()))
}
}
impl<E: Styled> StyledExt for E {}
/// A size for elements.
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)]
#[derive(Clone, Default, Copy, PartialEq, Eq, Debug)]
pub enum Size {
Size(Pixels),
XSmall,
@@ -83,10 +84,7 @@ impl From<Pixels> for Size {
/// 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.)`
/// Set the element's [`Size`], or a custom pixel size (`px(30.)`).
fn with_size(self, size: impl Into<Size>) -> Self;
/// Set to Size::XSmall
@@ -130,18 +128,18 @@ impl<T: Styled> StyleSized<T> for T {
match size {
Size::XSmall => self.py_0p5(),
Size::Medium => self.py_2(),
Size::Large => self.py_5(),
Size::Large => self.py_1p5(),
_ => self.py_1(),
}
}
fn input_h(self, size: Size) -> Self {
match size {
Size::XSmall => self.h_6().text_xs(),
Size::Small => self.h_8().text_sm(),
Size::Medium => self.h_9().text_base(),
Size::Large => self.h_12().text_lg(),
Size::Size(size) => self.h(px(24.)).text_size(size),
Size::XSmall => self.h_6().text_style(TextStyle::Caption),
Size::Small => self.h_7().text_style(TextStyle::Callout),
Size::Medium => self.h_8().text_style(TextStyle::Body),
Size::Large => self.h_10().text_style(TextStyle::Title3),
Size::Size(size) => self.h(size).text_size(size * 0.5),
}
}
}
+25 -51
View File
@@ -4,12 +4,10 @@ use std::time::Duration;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ElementId, IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window,
div, px, white,
div, px, transparent_black,
};
use gpui_base::{Spring, Switch as BaseSwitch, SwitchThumb, SwitchTrack, spring};
use theme::{ActiveTheme, Side};
use crate::{Disableable, Sizable, Size};
use theme::{ActiveTheme, TextStyle, Typeset as _};
type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
@@ -17,12 +15,9 @@ type OnClick = Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>;
pub struct Switch {
id: ElementId,
checked: bool,
disabled: bool,
label: Option<SharedString>,
description: Option<SharedString>,
label_side: Side,
on_click: OnClick,
size: Size,
}
impl Switch {
@@ -32,12 +27,9 @@ impl Switch {
Self {
id: id.clone(),
checked: false,
disabled: false,
label: None,
description: None,
on_click: None,
label_side: Side::Left,
size: Size::Medium,
}
}
@@ -65,45 +57,27 @@ impl Switch {
}
}
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 RenderOnce for Switch {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let checked = self.checked;
let on_click = self.on_click.clone();
let (bg, toggle_bg) = match checked {
true => (cx.theme().element_background, white()),
false => (cx.theme().elevated_surface_background, white()),
let bg = if checked {
cx.theme().accent_strong
} else {
cx.theme().input_bg
};
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 toggle_bg = cx.theme().on_solid;
// The border is structural (it insets the thumb); it only paints when off.
let track_border = if checked {
transparent_black()
} else {
cx.theme().border
};
let bg_width = px(36.);
let bg_height = px(20.);
let bar_width = px(16.);
let inset = px(2.);
let thumb_left = spring(
@@ -124,14 +98,13 @@ impl RenderOnce for Switch {
div().child(
BaseSwitch::new(self.id.clone())
.checked(checked)
.disabled(self.disabled)
.when_some(accessibility_label, |this, label| {
this.accessibility_label(label)
})
.when_some(on_click, |this, on_click| {
this.on_change(move |next, _event, window, cx| on_click(&next, window, cx))
})
.when(self.label_side.is_left(), |this| this.flex_row_reverse())
.flex_row_reverse()
.child(
div()
.w_full()
@@ -140,14 +113,16 @@ impl RenderOnce for Switch {
.items_center()
.gap_4()
.when_some(label, |this, label| {
// Label
this.child(div().text_sm().text_color(cx.theme().text).child(label))
this.child(
div()
.text_style(TextStyle::Callout)
.text_color(cx.theme().text)
.child(label),
)
})
.child(
// Switch Bar
SwitchTrack::new((self.id.clone(), "track"))
.checked(checked)
.disabled(self.disabled)
.flex_shrink_0()
.w(bg_width)
.h(bg_height)
@@ -155,11 +130,10 @@ impl RenderOnce for Switch {
.flex()
.items_center()
.border(inset)
.border_color(cx.theme().border_transparent)
.border_color(track_border)
.bg(bg)
.when(!self.disabled, |this| this.cursor_pointer())
.cursor_pointer()
.child(
// Switch Toggle
SwitchThumb::new(checked)
.rounded_full()
.when(cx.theme().shadow, |this| this.shadow_sm())
@@ -173,7 +147,7 @@ impl RenderOnce for Switch {
this.child(
div()
.pr_3()
.text_xs()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(description),
)
+27 -80
View File
@@ -5,9 +5,9 @@ use gpui::{
AnyElement, App, ClickEvent, Div, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _};
use crate::{Icon, IconName, Selectable, h_flex};
use crate::{Selectable, h_flex};
pub mod tab_bar;
@@ -17,61 +17,27 @@ pub mod tab_bar;
pub struct Tab {
ix: usize,
base: Div,
pub(super) label: Option<SharedString>,
icon: Option<Icon>,
prefix: Option<AnyElement>,
pub(super) tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
label: Option<SharedString>,
children: Vec<AnyElement>,
pub(super) disabled: bool,
pub(super) selected: bool,
pub(super) segmented: bool,
tab_bar_prefix: Option<bool>,
suffix: Option<AnyElement>,
disabled: bool,
selected: bool,
segmented: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl From<&'static str> for Tab {
fn from(label: &'static str) -> Self {
Self::new().label(label)
}
}
impl From<String> for Tab {
fn from(label: String) -> Self {
Self::new().label(label)
}
}
impl From<SharedString> for Tab {
fn from(label: SharedString) -> Self {
Self::new().label(label)
}
}
impl From<Icon> for Tab {
fn from(icon: Icon) -> Self {
Self::default().icon(icon)
}
}
impl From<IconName> for Tab {
fn from(icon_name: IconName) -> Self {
Self::default().icon(Icon::new(icon_name))
}
}
impl Default for Tab {
fn default() -> Self {
Self {
ix: 0,
base: div(),
label: None,
icon: None,
tab_bar_prefix: None,
children: Vec::new(),
disabled: false,
selected: false,
segmented: false,
prefix: None,
suffix: None,
on_click: None,
}
@@ -90,32 +56,20 @@ impl Tab {
self
}
/// Set icon for the tab.
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set the left side of the tab
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
/// Set the right side of the tab
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Set disabled state to the tab, default false.
pub fn disabled(mut self, disabled: bool) -> Self {
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Set the click handler for the tab.
pub fn on_click(
pub(crate) fn on_click(
mut self,
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
@@ -179,8 +133,6 @@ impl RenderOnce for Tab {
ix,
base,
label,
icon,
prefix,
suffix,
children,
disabled,
@@ -193,9 +145,9 @@ impl RenderOnce for Tab {
let foreground = if disabled {
cx.theme().text_muted
} else if selected {
cx.theme().tab_active_foreground
cx.theme().text
} else {
cx.theme().tab_foreground
cx.theme().text_muted
};
let content = h_flex()
@@ -206,15 +158,11 @@ impl RenderOnce for Tab {
.overflow_hidden()
.when(segmented, |this| this.justify_center().px_1())
.when(!segmented, |this| this.justify_start())
.map(|this| match icon {
Some(icon) => this.w(px(38.)).child(icon.size_4()),
None => this
.map(|this| match label {
Some(label) => this.child(label),
None => this,
})
.children(children),
});
.map(|this| match label {
Some(label) => this.child(label),
None => this,
})
.children(children);
base.id(ix)
.group("tab")
@@ -222,16 +170,16 @@ impl RenderOnce for Tab {
.items_center()
.text_color(foreground)
.when(segmented, |this| {
this.text_xs()
this.text_style(TextStyle::Caption)
.flex_1()
.h_6()
.rounded(cx.theme().radius)
.rounded(px(theme::button_radius()))
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_active_background)
this.bg(cx.theme().bg)
.when(cx.theme().shadow, |this| this.shadow_sm())
})
.when(!selected && !disabled, |this| {
this.hover(|this| this.bg(cx.theme().tab_hover_background))
this.hover(|this| this.bg(cx.theme().element_hover))
})
})
.when(!segmented, |this| {
@@ -240,20 +188,19 @@ impl RenderOnce for Tab {
.h_7()
.gap_1()
.px_1p5()
.text_sm()
.rounded(cx.theme().radius)
.text_style(TextStyle::Callout)
.rounded(px(theme::button_radius()))
.overflow_hidden()
.when(selected && !disabled, |this| {
this.bg(cx.theme().tab_background)
this.bg(cx.theme().element_active)
})
.when(!selected && !disabled, |this| {
this.hover(|this| {
this.bg(cx.theme().tab_hover_background)
.text_color(cx.theme().tab_active_foreground)
this.bg(cx.theme().element_hover)
.text_color(cx.theme().text)
})
})
})
.when_some(prefix, |this, prefix| this.child(prefix))
.child(content)
.when_some(suffix, |this, suffix| {
this.child(
+8 -10
View File
@@ -52,25 +52,25 @@ impl TabBar {
}
/// Track the scroll of the TabBar.
pub fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
pub(crate) fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self {
self.scroll_handle = Some(scroll_handle.clone());
self
}
/// Set the prefix element of the TabBar
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
pub(crate) 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 {
pub(crate) fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Add children of the TabBar.
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
pub(crate) fn children(mut self, children: impl IntoIterator<Item = impl Into<Tab>>) -> Self {
self.children.extend(children.into_iter().map(Into::into));
self
}
@@ -88,14 +88,12 @@ impl TabBar {
}
/// Set the last empty space element of the TabBar.
pub fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
pub(crate) fn last_empty_space(mut self, last_empty_space: impl IntoElement) -> Self {
self.last_empty_space = last_empty_space.into_any_element();
self
}
/// Set the on_click callback of the TabBar, the first parameter is the index of the clicked tab.
///
/// When this is set, the children's on_click will be ignored.
/// Set the on_click callback of the TabBar; when set, the children's on_click is ignored.
pub fn on_click<F>(mut self, on_click: F) -> Self
where
F: Fn(&usize, &mut Window, &mut App) + 'static,
@@ -124,9 +122,9 @@ impl RenderOnce for TabBar {
.min_w_0()
.refine_style(&self.style)
.when(segmented, |this| {
this.bg(cx.theme().tab_background)
this.bg(cx.theme().surface)
.p_0p5()
.rounded(cx.theme().radius)
.rounded(px(theme::button_radius()))
})
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
+24 -53
View File
@@ -1,31 +1,23 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ClickEvent, Context, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Pixels, Render, RenderOnce, Stateful, StatefulInteractiveElement as _,
Styled, Window, WindowControlArea, div, px,
App, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels,
RenderOnce, Stateful, StatefulInteractiveElement as _, Styled, Window, WindowControlArea, div,
px,
};
use theme::ActiveTheme;
use theme::{ActiveTheme, plate_active};
use crate::{Icon, IconName, Sizable as _, h_flex};
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
const TITLE_BAR_HEIGHT: Pixels = px(34.);
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
// 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.
// Windows control buttons rely on the native window event, so no click handlers are needed.
#[derive(IntoElement, Clone)]
#[allow(clippy::type_complexity)]
enum ControlIcon {
Minimize,
Restore,
Maximize,
Close {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
},
Close,
}
impl ControlIcon {
@@ -41,9 +33,8 @@ impl ControlIcon {
Self::Maximize
}
#[allow(clippy::type_complexity)]
fn close(on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>) -> Self {
Self::Close { on_close_window }
fn close() -> Self {
Self::Close
}
fn id(&self) -> &'static str {
@@ -51,7 +42,7 @@ impl ControlIcon {
Self::Minimize => "minimize",
Self::Restore => "restore",
Self::Maximize => "maximize",
Self::Close { .. } => "close",
Self::Close => "close",
}
}
@@ -60,7 +51,7 @@ impl ControlIcon {
Self::Minimize => IconName::WindowMinimize,
Self::Restore => IconName::WindowRestore,
Self::Maximize => IconName::WindowMaximize,
Self::Close { .. } => IconName::WindowClose,
Self::Close => IconName::WindowClose,
}
}
@@ -68,18 +59,20 @@ impl ControlIcon {
match self {
Self::Minimize => WindowControlArea::Min,
Self::Restore | Self::Maximize => WindowControlArea::Max,
Self::Close { .. } => WindowControlArea::Close,
Self::Close => WindowControlArea::Close,
}
}
fn is_close(&self) -> bool {
matches!(self, Self::Close { .. })
matches!(self, Self::Close)
}
#[inline]
fn hover_fg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_foreground
// White on the danger plate in both appearances, per bezel's
// destructive pairing.
gpui::white()
} else {
cx.theme().text
}
@@ -88,18 +81,18 @@ impl ControlIcon {
#[inline]
fn hover_bg(&self, cx: &App) -> Hsla {
if self.is_close() {
cx.theme().danger_background
cx.theme().danger_strong
} else {
cx.theme().ghost_element_hover
cx.theme().element_hover
}
}
#[inline]
fn active_bg(&self, cx: &mut App) -> Hsla {
if self.is_close() {
cx.theme().danger_active
plate_active(cx.theme().danger_strong)
} else {
cx.theme().ghost_element_active
cx.theme().element_active
}
}
}
@@ -114,11 +107,6 @@ impl RenderOnce for ControlIcon {
let hover_bg = self.hover_bg(cx);
let active_bg = self.active_bg(cx);
let on_close_window = match &self {
ControlIcon::Close { on_close_window } => on_close_window.clone(),
_ => None,
};
div()
.id(self.id())
.flex()
@@ -144,13 +132,7 @@ impl RenderOnce for ControlIcon {
match icon {
Self::Minimize => window.minimize_window(),
Self::Restore | Self::Maximize => window.zoom_window(),
Self::Close { .. } => {
if let Some(f) = on_close_window.clone() {
f(&ClickEvent::default(), window, cx);
} else {
window.remove_window();
}
}
Self::Close => window.remove_window(),
}
})
})
@@ -159,15 +141,10 @@ impl RenderOnce for ControlIcon {
}
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub(crate) struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
pub(crate) struct WindowControls;
pub(crate) fn window_controls() -> WindowControls {
WindowControls {
on_close_window: None,
}
WindowControls
}
pub fn title_bar_drag_handlers(
@@ -232,16 +209,10 @@ impl RenderOnce for WindowControls {
} else {
ControlIcon::maximize()
})
.child(ControlIcon::close(self.on_close_window))
.child(ControlIcon::close())
}
}
struct TitleBarState {
should_move: bool,
}
impl Render for TitleBarState {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
}
}
+14 -17
View File
@@ -1,10 +1,10 @@
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, relative,
Window, px,
};
use gpui_base::Tooltip as BaseTooltip;
use theme::ActiveTheme;
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
pub struct Tooltip {
text: SharedString,
@@ -18,20 +18,17 @@ impl Tooltip {
impl Render for Tooltip {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(
BaseTooltip::new("tooltip")
.font_family(".SystemUIFont")
.m_3()
.p_1p5()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().surface_background)
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.text_xs()
.text_color(cx.theme().text)
.line_height(relative(1.25))
.child(self.text.clone()),
)
BaseTooltip::new("tooltip")
.font_family(cx.theme().font_sans.clone())
.m_3()
.p_1p5()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().surface_overlay)
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(px(control_radius()))
.text_style(TextStyle::Caption)
.text_color(cx.theme().text)
.child(self.text.clone())
}
}
-10
View File
@@ -31,9 +31,6 @@ pub trait WindowExtension: Sized {
key: impl Into<ElementId>,
cx: &mut App,
);
/// Clear all notifications
fn clear_notifications(&mut self, cx: &mut App);
}
impl WindowExtension for Window {
@@ -90,11 +87,4 @@ impl WindowExtension for Window {
root.clear_notification_by_id::<T>(key, window, cx);
})
}
#[inline]
fn clear_notifications(&mut self, cx: &mut App) {
Root::update(self, cx, move |root, window, cx| {
root.clear_notifications(window, cx);
})
}
}