migrate overlays and feedback
This commit is contained in:
@@ -42,7 +42,5 @@ mod window_ext;
|
|||||||
pub fn init(cx: &mut gpui::App) {
|
pub fn init(cx: &mut gpui::App) {
|
||||||
gpui_base::init(cx);
|
gpui_base::init(cx);
|
||||||
theme::sync_base(cx);
|
theme::sync_base(cx);
|
||||||
modal::init(cx);
|
|
||||||
popover::init(cx);
|
|
||||||
menu::init(cx);
|
menu::init(cx);
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-177
@@ -2,28 +2,19 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle,
|
Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Div, FocusHandle,
|
||||||
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
|
InteractiveElement as _, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
|
||||||
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
|
StyleRefinement, Styled, Window, div, hsla, point, px, size,
|
||||||
};
|
};
|
||||||
|
use gpui_base::Dialog;
|
||||||
use instant::Duration;
|
use instant::Duration;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::actions::{Cancel, Confirm};
|
|
||||||
use crate::animation::cubic_bezier;
|
use crate::animation::cubic_bezier;
|
||||||
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
|
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
|
||||||
use crate::scroll::ScrollableElement;
|
use crate::scroll::ScrollableElement;
|
||||||
use crate::{IconName, Root, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use crate::{IconName, Root, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
const CONTEXT: &str = "Modal";
|
|
||||||
|
|
||||||
pub fn init(cx: &mut App) {
|
|
||||||
cx.bind_keys([
|
|
||||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
|
||||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnClose = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
|
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 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 OnCancel = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static>;
|
||||||
@@ -275,6 +266,8 @@ impl Styled for Modal {
|
|||||||
impl RenderOnce for Modal {
|
impl RenderOnce for Modal {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
|
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
|
||||||
let layer_ix = self.layer_ix;
|
let layer_ix = self.layer_ix;
|
||||||
|
let is_topmost = layer_ix + 1 == Root::read(window, cx).active_modals.len();
|
||||||
|
let has_footer = self.footer.is_some();
|
||||||
let on_close = self.on_close.clone();
|
let on_close = self.on_close.clone();
|
||||||
let on_ok = self.on_ok.clone();
|
let on_ok = self.on_ok.clone();
|
||||||
let on_cancel = self.on_cancel.clone();
|
let on_cancel = self.on_cancel.clone();
|
||||||
@@ -345,19 +338,16 @@ impl RenderOnce for Modal {
|
|||||||
let radius = cx.theme().radius_lg;
|
let radius = cx.theme().radius_lg;
|
||||||
|
|
||||||
let view_size = window.viewport_size()
|
let view_size = window.viewport_size()
|
||||||
- gpui::size(
|
- size(
|
||||||
window_paddings.left + window_paddings.right,
|
window_paddings.left + window_paddings.right,
|
||||||
window_paddings.top + window_paddings.bottom,
|
window_paddings.top + window_paddings.bottom,
|
||||||
);
|
);
|
||||||
|
|
||||||
let bounds = Bounds {
|
|
||||||
origin: Point::default(),
|
|
||||||
size: view_size,
|
|
||||||
};
|
|
||||||
|
|
||||||
let offset_top = px(layer_ix as f32 * 16.);
|
let offset_top = px(layer_ix as f32 * 16.);
|
||||||
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top;
|
||||||
let x = bounds.center().x - self.width / 2.;
|
let x = view_size.width / 2. - self.width / 2.;
|
||||||
|
let card_top = window_paddings.top + y;
|
||||||
|
let card_left = window_paddings.left + x;
|
||||||
|
|
||||||
let mut padding_right = px(16.);
|
let mut padding_right = px(16.);
|
||||||
let mut padding_left = px(16.);
|
let mut padding_left = px(16.);
|
||||||
@@ -373,168 +363,138 @@ impl RenderOnce for Modal {
|
|||||||
let animation = Animation::new(Duration::from_secs_f64(0.25))
|
let animation = Animation::new(Duration::from_secs_f64(0.25))
|
||||||
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
|
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
|
||||||
|
|
||||||
anchored()
|
let backdrop = div()
|
||||||
.position(point(window_paddings.left, window_paddings.top))
|
.absolute()
|
||||||
.snap_to_window()
|
.top(window_paddings.top)
|
||||||
|
.left(window_paddings.left)
|
||||||
|
.w(view_size.width)
|
||||||
|
.h(view_size.height)
|
||||||
|
.when(self.overlay_visible, |this| {
|
||||||
|
this.occlude().bg(cx.theme().overlay)
|
||||||
|
})
|
||||||
|
.with_animation("fade-in", animation.clone(), move |this, delta| {
|
||||||
|
this.opacity(delta)
|
||||||
|
});
|
||||||
|
|
||||||
|
let card = v_flex()
|
||||||
|
.id(layer_ix)
|
||||||
|
.bg(cx.theme().background)
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border.alpha(0.4))
|
||||||
|
.rounded(radius)
|
||||||
|
.when(cx.theme().shadow, |this| this.shadow_xl())
|
||||||
|
.min_h_24()
|
||||||
|
.refine_style(&self.style)
|
||||||
|
// There style is high priority, can't be overridden.
|
||||||
|
.absolute()
|
||||||
|
.occlude()
|
||||||
|
.relative()
|
||||||
|
.left(card_left)
|
||||||
|
.top(card_top)
|
||||||
|
.w(self.width)
|
||||||
|
.when_some(self.max_width, |this, w| this.max_w(w))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id("modal")
|
.px_4()
|
||||||
.w(view_size.width)
|
.h_8()
|
||||||
.h(view_size.height)
|
.w_full()
|
||||||
.when(self.overlay_visible, |this| {
|
.flex()
|
||||||
this.occlude().bg(cx.theme().overlay)
|
.items_center()
|
||||||
})
|
.justify_center()
|
||||||
.when(self.overlay_closable, |this| {
|
.when_some(self.title, |this, title| {
|
||||||
// Only the last modal owns the `mouse down - close modal` event.
|
this.h_10().font_semibold().text_center().child(title)
|
||||||
if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() {
|
}),
|
||||||
return this;
|
)
|
||||||
}
|
.when(self.show_close, |this| {
|
||||||
|
let on_cancel = on_cancel.clone();
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
|
||||||
this.on_mouse_down(MouseButton::Left, {
|
this.child(
|
||||||
let on_cancel = on_cancel.clone();
|
Button::new("close")
|
||||||
let on_close = on_close.clone();
|
.icon(IconName::CloseCircleFill)
|
||||||
move |_, window, cx| {
|
.absolute()
|
||||||
on_cancel(&ClickEvent::default(), window, cx);
|
.top_1p5()
|
||||||
on_close(&ClickEvent::default(), window, cx);
|
.right_2()
|
||||||
window.close_modal(cx);
|
.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),
|
||||||
|
)
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
on_cancel(&ClickEvent::default(), window, cx);
|
||||||
|
on_close(&ClickEvent::default(), window, cx);
|
||||||
|
window.close_modal(cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.pt_px()
|
||||||
|
.w_full()
|
||||||
|
.h_auto()
|
||||||
|
.flex_1()
|
||||||
|
.overflow_hidden()
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.id(layer_ix)
|
.pr(padding_right)
|
||||||
.bg(cx.theme().background)
|
.pl(padding_left)
|
||||||
.border_1()
|
.size_full()
|
||||||
.border_color(cx.theme().border.alpha(0.4))
|
.overflow_y_scrollbar()
|
||||||
.rounded(radius)
|
.child(self.content),
|
||||||
.when(cx.theme().shadow, |this| this.shadow_xl())
|
),
|
||||||
.min_h_24()
|
|
||||||
.key_context(CONTEXT)
|
|
||||||
.track_focus(&self.focus_handle)
|
|
||||||
.refine_style(&self.style)
|
|
||||||
.when(self.keyboard, |this| {
|
|
||||||
this.on_action({
|
|
||||||
let on_cancel = on_cancel.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
move |_: &Cancel, window, cx| {
|
|
||||||
// FIXME:
|
|
||||||
//
|
|
||||||
// Here some Modal have no focus_handle, so it will not work will Escape key.
|
|
||||||
// But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work.
|
|
||||||
on_cancel(&ClickEvent::default(), window, cx);
|
|
||||||
on_close(&ClickEvent::default(), window, cx);
|
|
||||||
window.close_modal(cx);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_action({
|
|
||||||
let on_ok = on_ok.clone();
|
|
||||||
let on_close = on_close.clone();
|
|
||||||
let has_footer = self.footer.is_some();
|
|
||||||
move |_: &Confirm, window, cx| {
|
|
||||||
if let Some(on_ok) = &on_ok {
|
|
||||||
if on_ok(&ClickEvent::default(), window, cx) {
|
|
||||||
on_close(&ClickEvent::default(), window, cx);
|
|
||||||
window.close_modal(cx);
|
|
||||||
}
|
|
||||||
} else if has_footer {
|
|
||||||
window.close_modal(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// There style is high priority, can't be overridden.
|
|
||||||
.absolute()
|
|
||||||
.occlude()
|
|
||||||
.relative()
|
|
||||||
.left(x)
|
|
||||||
.top(y)
|
|
||||||
.w(self.width)
|
|
||||||
.when_some(self.max_width, |this, w| this.max_w(w))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.px_4()
|
|
||||||
.h_8()
|
|
||||||
.w_full()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_center()
|
|
||||||
.when_some(self.title, |this, title| {
|
|
||||||
this.h_10().font_semibold().text_center().child(title)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.when(self.show_close, |this| {
|
|
||||||
this.child(
|
|
||||||
Button::new("close")
|
|
||||||
.icon(IconName::CloseCircleFill)
|
|
||||||
.absolute()
|
|
||||||
.top_1p5()
|
|
||||||
.right_2()
|
|
||||||
.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),
|
|
||||||
)
|
|
||||||
.on_click(move |_, window, cx| {
|
|
||||||
on_cancel(&ClickEvent::default(), window, cx);
|
|
||||||
on_close(&ClickEvent::default(), window, cx);
|
|
||||||
window.close_modal(cx);
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.pt_px()
|
|
||||||
.w_full()
|
|
||||||
.h_auto()
|
|
||||||
.flex_1()
|
|
||||||
.overflow_hidden()
|
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.pr(padding_right)
|
|
||||||
.pl(padding_left)
|
|
||||||
.size_full()
|
|
||||||
.overflow_y_scrollbar()
|
|
||||||
.child(self.content),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.when_none(&self.footer, |this| this.child(div().pt(padding_left)))
|
|
||||||
.when_some(self.footer, |this, footer| {
|
|
||||||
this.child(
|
|
||||||
h_flex()
|
|
||||||
.gap_2()
|
|
||||||
.pt(padding_left)
|
|
||||||
.pr(padding_right)
|
|
||||||
.pb(padding_left)
|
|
||||||
.pl(padding_right)
|
|
||||||
.justify_end()
|
|
||||||
.children(footer(render_ok, render_cancel, window, cx)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.with_animation("slide-down", animation.clone(), move |this, delta| {
|
|
||||||
let y_offset = px(0.) + delta * px(30.);
|
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
this.top(y + y_offset).shadow(shadow)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.with_animation("fade-in", animation, move |this, delta| this.opacity(delta)),
|
|
||||||
)
|
)
|
||||||
|
.when_none(&self.footer, |this| this.child(div().pt(padding_left)))
|
||||||
|
.when_some(self.footer, |this, footer| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.pt(padding_left)
|
||||||
|
.pr(padding_right)
|
||||||
|
.pb(padding_left)
|
||||||
|
.pl(padding_right)
|
||||||
|
.justify_end()
|
||||||
|
.children(footer(render_ok, render_cancel, window, cx)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.with_animation("slide-down", animation, move |this, delta| {
|
||||||
|
let y_offset = px(0.) + delta * px(30.);
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.top(card_top + y_offset).shadow(shadow)
|
||||||
|
});
|
||||||
|
|
||||||
|
Dialog::new(cx)
|
||||||
|
.layer(layer_ix, is_topmost)
|
||||||
|
.focus_handle(self.focus_handle.clone())
|
||||||
|
.close_on_escape(self.keyboard)
|
||||||
|
.close_on_backdrop_press(self.overlay_closable)
|
||||||
|
.on_ok(move |event, window, cx| match &on_ok {
|
||||||
|
Some(on_ok) => on_ok(event, window, cx),
|
||||||
|
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_modal(cx);
|
||||||
|
})
|
||||||
|
.backdrop(backdrop)
|
||||||
|
.popup(card)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+176
-97
@@ -1,7 +1,7 @@
|
|||||||
use std::any::TypeId;
|
use std::any::TypeId;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use instant::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
@@ -10,12 +10,25 @@ use gpui::{
|
|||||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||||
Subscription, Window, div, px, relative,
|
Subscription, Window, div, px, relative,
|
||||||
};
|
};
|
||||||
|
use gpui_base::{
|
||||||
|
Toast as BaseToast, ToastManager, ToastMotion, ToastOptions, ToastStack, ToastStackState,
|
||||||
|
ToastTransitionStatus,
|
||||||
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
use crate::animation::cubic_bezier;
|
use crate::animation::cubic_bezier;
|
||||||
use crate::button::{Button, ButtonVariants as _};
|
use crate::button::{Button, ButtonVariants as _};
|
||||||
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
use crate::{Icon, IconName, Sizable as _, Size, StyledExt, h_flex, v_flex};
|
||||||
|
|
||||||
|
/// How often the notification lifecycle clock is sampled.
|
||||||
|
const ADVANCE_INTERVAL: Duration = Duration::from_millis(50);
|
||||||
|
|
||||||
|
/// How long a notification stays before it hides itself.
|
||||||
|
const AUTOHIDE_DURATION: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Request by a notification to be dismissed; the list owns the transition.
|
||||||
|
struct DismissRequest;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub enum NotificationKind {
|
pub enum NotificationKind {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -79,7 +92,7 @@ pub struct Notification {
|
|||||||
action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
|
action_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> Button>>,
|
||||||
content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
|
content_builder: Option<Rc<dyn Fn(&mut Self, &mut Window, &mut Context<Self>) -> AnyElement>>,
|
||||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
closing: bool,
|
transition_status: ToastTransitionStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for Notification {
|
impl From<String> for Notification {
|
||||||
@@ -133,7 +146,7 @@ impl Notification {
|
|||||||
action_builder: None,
|
action_builder: None,
|
||||||
content_builder: None,
|
content_builder: None,
|
||||||
on_click: None,
|
on_click: None,
|
||||||
closing: false,
|
transition_status: ToastTransitionStatus::Starting,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,29 +251,29 @@ impl Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Dismiss the notification.
|
/// Dismiss the notification.
|
||||||
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
pub fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if self.closing {
|
cx.emit(DismissRequest);
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
/// Begin the exit transition, driven by the notification list.
|
||||||
|
pub(crate) fn begin_close(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.transition_status != ToastTransitionStatus::Ending {
|
||||||
|
self.transition_status = ToastTransitionStatus::Ending;
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
self.closing = true;
|
}
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
// Dismiss the notification after 0.15s to show the animation.
|
/// Mark the enter transition as finished, driven by the notification list.
|
||||||
cx.spawn(async move |view, cx| {
|
pub(crate) fn complete_enter(&mut self, cx: &mut Context<Self>) {
|
||||||
cx.background_executor()
|
if self.transition_status == ToastTransitionStatus::Starting {
|
||||||
.timer(Duration::from_secs_f32(0.15))
|
self.transition_status = ToastTransitionStatus::Present;
|
||||||
.await;
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cx.update(|cx| {
|
/// Finish the exit transition, driven by the notification list.
|
||||||
if let Some(view) = view.upgrade() {
|
pub(crate) fn complete_close(&mut self, cx: &mut Context<Self>) {
|
||||||
view.update(cx, |view, cx| {
|
cx.emit(DismissEvent);
|
||||||
view.closing = false;
|
|
||||||
cx.emit(DismissEvent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the content of the notification.
|
/// Set the content of the notification.
|
||||||
@@ -280,6 +293,7 @@ impl Default for Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for Notification {}
|
impl EventEmitter<DismissEvent> for Notification {}
|
||||||
|
impl EventEmitter<DismissRequest> for Notification {}
|
||||||
|
|
||||||
impl FluentBuilder for Notification {}
|
impl FluentBuilder for Notification {}
|
||||||
|
|
||||||
@@ -319,17 +333,19 @@ impl Render for Notification {
|
|||||||
_ => cx.theme().text,
|
_ => cx.theme().text,
|
||||||
};
|
};
|
||||||
|
|
||||||
let closing = self.closing;
|
let transition_status = self.transition_status;
|
||||||
|
let closing = transition_status == ToastTransitionStatus::Ending;
|
||||||
let has_title = self.title.is_some();
|
let has_title = self.title.is_some();
|
||||||
let only_message = !has_title && content.is_none() && action.is_none();
|
let only_message = !has_title && content.is_none() && action.is_none();
|
||||||
let placement = cx.theme().notification.placement;
|
let placement = cx.theme().notification.placement;
|
||||||
|
|
||||||
h_flex()
|
BaseToast::new("notification")
|
||||||
.id("notification")
|
.transition_status(transition_status)
|
||||||
|
.h_flex()
|
||||||
.group("")
|
.group("")
|
||||||
.occlude()
|
.occlude()
|
||||||
.relative()
|
.relative()
|
||||||
.w_112()
|
.w_full()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
.bg(background)
|
.bg(background)
|
||||||
@@ -455,10 +471,13 @@ impl Render for Notification {
|
|||||||
/// A list of notifications.
|
/// A list of notifications.
|
||||||
pub struct NotificationList {
|
pub struct NotificationList {
|
||||||
/// Notifications that will be auto hidden.
|
/// Notifications that will be auto hidden.
|
||||||
pub(crate) notifications: VecDeque<Entity<Notification>>,
|
pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
|
||||||
|
|
||||||
/// Whether the notification list is expanded.
|
/// Measured geometry and interaction state of the visible stack.
|
||||||
expanded: bool,
|
stack_state: ToastStackState,
|
||||||
|
|
||||||
|
/// Whether the lifecycle clock is running. The loop clears it as it exits.
|
||||||
|
is_advancing: bool,
|
||||||
|
|
||||||
/// Subscriptions
|
/// Subscriptions
|
||||||
_subscriptions: HashMap<NotificationId, Subscription>,
|
_subscriptions: HashMap<NotificationId, Subscription>,
|
||||||
@@ -467,12 +486,64 @@ pub struct NotificationList {
|
|||||||
impl NotificationList {
|
impl NotificationList {
|
||||||
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
notifications: VecDeque::new(),
|
notifications: ToastManager::new(ToastMotion::default()),
|
||||||
expanded: false,
|
stack_state: ToastStackState::default(),
|
||||||
|
is_advancing: false,
|
||||||
_subscriptions: HashMap::new(),
|
_subscriptions: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn start_advancing(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if self.is_advancing {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.is_advancing = true;
|
||||||
|
cx.spawn_in(window, async move |view, cx| {
|
||||||
|
loop {
|
||||||
|
cx.background_executor().timer(ADVANCE_INTERVAL).await;
|
||||||
|
let running = view.update(cx, |view, cx| {
|
||||||
|
view.advance(cx);
|
||||||
|
view.is_advancing = !view.notifications.is_empty();
|
||||||
|
view.is_advancing
|
||||||
|
});
|
||||||
|
if !matches!(running, Ok(true)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let changes = self.notifications.advance(
|
||||||
|
cx.background_executor().now(),
|
||||||
|
self.stack_state.is_expanded(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for id in changes.presented {
|
||||||
|
if let Some(note) = self.notifications.get(&id) {
|
||||||
|
note.update(cx, |note, cx| note.complete_enter(cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in changes.ending {
|
||||||
|
if let Some(note) = self.notifications.get(&id) {
|
||||||
|
note.update(cx, |note, cx| note.begin_close(cx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (id, note) in changes.removed {
|
||||||
|
self._subscriptions.remove(&id);
|
||||||
|
note.update(cx, |note, cx| note.complete_close(cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
if changes.changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn push(
|
pub fn push(
|
||||||
&mut self,
|
&mut self,
|
||||||
notification: impl Into<Notification>,
|
notification: impl Into<Notification>,
|
||||||
@@ -483,102 +554,110 @@ impl NotificationList {
|
|||||||
let id = notification.id.clone();
|
let id = notification.id.clone();
|
||||||
let autohide = notification.autohide;
|
let autohide = notification.autohide;
|
||||||
|
|
||||||
// Remove the notification by id, for keep unique.
|
|
||||||
self.notifications.retain(|note| note.read(cx).id != id);
|
|
||||||
|
|
||||||
let notification = cx.new(|_| notification);
|
let notification = cx.new(|_| notification);
|
||||||
|
|
||||||
|
let dismiss_id = id.clone();
|
||||||
self._subscriptions.insert(
|
self._subscriptions.insert(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
cx.subscribe(¬ification, move |view, _, _: &DismissEvent, cx| {
|
cx.subscribe(¬ification, move |view, _, _: &DismissRequest, cx| {
|
||||||
view.notifications.retain(|note| id != note.read(cx).id);
|
if view
|
||||||
view._subscriptions.remove(&id);
|
.notifications
|
||||||
|
.dismiss(&dismiss_id, cx.background_executor().now())
|
||||||
|
&& let Some(note) = view.notifications.get(&dismiss_id)
|
||||||
|
{
|
||||||
|
note.update(cx, |note, cx| note.begin_close(cx));
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.notifications.push_back(notification.clone());
|
self.notifications.push(
|
||||||
|
id,
|
||||||
if autohide {
|
notification,
|
||||||
// Sleep for 5 seconds to autohide the notification
|
ToastOptions {
|
||||||
cx.spawn_in(window, async move |_this, cx| {
|
timeout: autohide.then_some(AUTOHIDE_DURATION),
|
||||||
cx.background_executor().timer(Duration::from_secs(5)).await;
|
},
|
||||||
|
cx.background_executor().now(),
|
||||||
if let Err(err) =
|
);
|
||||||
notification.update_in(cx, |note, window, cx| note.dismiss(window, cx))
|
|
||||||
{
|
|
||||||
log::error!("failed to auto hide notification: {:?}", err);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
self.start_advancing(window, cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn close(
|
pub(crate) fn close(
|
||||||
&mut self,
|
&mut self,
|
||||||
id: impl Into<NotificationId>,
|
id: impl Into<NotificationId>,
|
||||||
window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let id: NotificationId = id.into();
|
let id: NotificationId = id.into();
|
||||||
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
|
if self
|
||||||
n.update(cx, |note, cx| note.dismiss(window, cx))
|
.notifications
|
||||||
|
.dismiss(&id, cx.background_executor().now())
|
||||||
|
&& let Some(note) = self.notifications.get(&id)
|
||||||
|
{
|
||||||
|
note.update(cx, |note, cx| note.begin_close(cx));
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.notifications.clear();
|
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();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn notifications(&self) -> Vec<Entity<Notification>> {
|
pub fn notifications(&self) -> Vec<Entity<Notification>> {
|
||||||
self.notifications.iter().cloned().collect()
|
self.notifications
|
||||||
|
.iter()
|
||||||
|
.map(|(_, note, _)| note.clone())
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for NotificationList {
|
impl Render for NotificationList {
|
||||||
fn render(
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
&mut self,
|
|
||||||
window: &mut gpui::Window,
|
|
||||||
cx: &mut gpui::Context<Self>,
|
|
||||||
) -> impl IntoElement {
|
|
||||||
let size = window.viewport_size();
|
let size = window.viewport_size();
|
||||||
let items = self.notifications.iter().rev().take(10).rev().cloned();
|
let settings = &cx.theme().notification;
|
||||||
|
let (placement, margins, max_items) = (
|
||||||
|
settings.placement,
|
||||||
|
settings.margins.clone(),
|
||||||
|
settings.max_items,
|
||||||
|
);
|
||||||
|
|
||||||
let placement = cx.theme().notification.placement;
|
let items = self
|
||||||
let margins = &cx.theme().notification.margins;
|
.notifications
|
||||||
|
.visible(max_items)
|
||||||
|
.map(|(id, note, _)| (id.clone(), note.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
v_flex()
|
let stack = items
|
||||||
.id("notification-list")
|
.into_iter()
|
||||||
|
.fold(
|
||||||
|
ToastStack::new("notification-list", self.stack_state.clone()),
|
||||||
|
|stack, (id, note)| stack.item(format!("{id:?}"), note),
|
||||||
|
)
|
||||||
|
.placement(placement)
|
||||||
|
.v_flex()
|
||||||
|
.w_112()
|
||||||
.max_h(size.height)
|
.max_h(size.height)
|
||||||
.pt(margins.top)
|
.absolute()
|
||||||
.pb(margins.bottom)
|
.map(|this| match placement {
|
||||||
.gap_3()
|
Anchor::TopLeft => this.top(margins.top).left(margins.left),
|
||||||
.when(
|
Anchor::TopRight => this.top(margins.top).right(margins.right),
|
||||||
matches!(placement, Anchor::TopRight),
|
Anchor::TopCenter => this.top(margins.top).left_0().right_0().mx_auto(),
|
||||||
|this| this.pr(margins.right), // ignore left
|
Anchor::BottomLeft => this.bottom(margins.bottom).left(margins.left),
|
||||||
)
|
Anchor::BottomRight => this.bottom(margins.bottom).right(margins.right),
|
||||||
.when(
|
Anchor::BottomCenter => this.bottom(margins.bottom).left_0().right_0().mx_auto(),
|
||||||
matches!(placement, Anchor::TopLeft),
|
Anchor::LeftCenter => this.left(margins.left).top_0().bottom_0().my_auto(),
|
||||||
|this| this.pl(margins.left), // ignore right
|
Anchor::RightCenter => this.right(margins.right).top_0().bottom_0().my_auto(),
|
||||||
)
|
});
|
||||||
.when(
|
|
||||||
matches!(placement, Anchor::BottomLeft),
|
div().size_full().child(stack)
|
||||||
|this| this.flex_col_reverse().pl(margins.left), // ignore right
|
|
||||||
)
|
|
||||||
.when(
|
|
||||||
matches!(placement, Anchor::BottomRight),
|
|
||||||
|this| this.flex_col_reverse().pr(margins.right), // ignore left
|
|
||||||
)
|
|
||||||
.when(matches!(placement, Anchor::BottomCenter), |this| {
|
|
||||||
this.flex_col_reverse()
|
|
||||||
})
|
|
||||||
.on_hover(cx.listener(|view, hovered, _, cx| {
|
|
||||||
view.expanded = *hovered;
|
|
||||||
cx.notify()
|
|
||||||
}))
|
|
||||||
.children(items)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-230
@@ -2,20 +2,13 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
|
Anchor, AnyElement, App, Context, Div, ElementId, FocusHandle, InteractiveElement as _,
|
||||||
FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
|
IntoElement, MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
|
||||||
ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
|
|
||||||
Subscription, Window, anchored, deferred, div, px,
|
|
||||||
};
|
};
|
||||||
|
use gpui_base::Popover as BasePopover;
|
||||||
|
pub use gpui_base::PopoverState;
|
||||||
|
|
||||||
use crate::actions::Cancel;
|
use crate::{Selectable, StyledExt as _, v_flex};
|
||||||
use crate::{ElementExt, Selectable, StyledExt as _, v_flex};
|
|
||||||
|
|
||||||
const CONTEXT: &str = "Popover";
|
|
||||||
|
|
||||||
pub(crate) fn init(cx: &mut App) {
|
|
||||||
cx.bind_keys([KeyBinding::new("escape", Cancel, Some(CONTEXT))])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A popover element that can be triggered by a button or any other element.
|
/// A popover element that can be triggered by a button or any other element.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
@@ -173,28 +166,6 @@ impl Popover {
|
|||||||
self.tracked_focus_handle = Some(handle.clone());
|
self.tracked_focus_handle = Some(handle.clone());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn resolved_corner(anchor: Anchor, trigger_bounds: Bounds<Pixels>) -> Point<Pixels> {
|
|
||||||
match anchor {
|
|
||||||
Anchor::TopLeft => trigger_bounds.origin,
|
|
||||||
Anchor::TopCenter => trigger_bounds.top_center(),
|
|
||||||
Anchor::TopRight => trigger_bounds.top_right(),
|
|
||||||
Anchor::BottomLeft => Point {
|
|
||||||
x: trigger_bounds.origin.x,
|
|
||||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
|
||||||
},
|
|
||||||
Anchor::BottomCenter => Point {
|
|
||||||
x: trigger_bounds.top_center().x,
|
|
||||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
|
||||||
},
|
|
||||||
Anchor::BottomRight => Point {
|
|
||||||
x: trigger_bounds.top_right().x,
|
|
||||||
y: trigger_bounds.origin.y - trigger_bounds.size.height,
|
|
||||||
},
|
|
||||||
// Fallback for LeftCenter/RightCenter – adjust as needed.
|
|
||||||
_ => trigger_bounds.origin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParentElement for Popover {
|
impl ParentElement for Popover {
|
||||||
@@ -209,119 +180,7 @@ impl Styled for Popover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PopoverState {
|
|
||||||
focus_handle: FocusHandle,
|
|
||||||
pub(crate) tracked_focus_handle: Option<FocusHandle>,
|
|
||||||
trigger_bounds: Bounds<Pixels>,
|
|
||||||
open: bool,
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
|
||||||
|
|
||||||
_dismiss_subscription: Option<Subscription>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PopoverState {
|
|
||||||
pub fn new(default_open: bool, cx: &mut App) -> Self {
|
|
||||||
Self {
|
|
||||||
focus_handle: cx.focus_handle(),
|
|
||||||
tracked_focus_handle: None,
|
|
||||||
trigger_bounds: Bounds::default(),
|
|
||||||
open: default_open,
|
|
||||||
on_open_change: None,
|
|
||||||
_dismiss_subscription: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if the popover is open.
|
|
||||||
pub fn is_open(&self) -> bool {
|
|
||||||
self.open
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dismiss the popover if it is open.
|
|
||||||
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if self.open {
|
|
||||||
self.toggle_open(window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open the popover if it is closed.
|
|
||||||
pub fn show(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
if !self.open {
|
|
||||||
self.toggle_open(window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.open = !self.open;
|
|
||||||
if self.open {
|
|
||||||
let state = cx.entity();
|
|
||||||
let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone()
|
|
||||||
{
|
|
||||||
tracked_focus_handle
|
|
||||||
} else {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
};
|
|
||||||
focus_handle.focus(window, cx);
|
|
||||||
|
|
||||||
self._dismiss_subscription =
|
|
||||||
Some(
|
|
||||||
window.subscribe(&cx.entity(), cx, move |_, _: &DismissEvent, window, cx| {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.dismiss(window, cx);
|
|
||||||
});
|
|
||||||
window.refresh();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
self._dismiss_subscription = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(callback) = self.on_open_change.as_ref() {
|
|
||||||
callback(&self.open, window, cx);
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
|
||||||
self.dismiss(window, cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Focusable for PopoverState {
|
|
||||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
|
||||||
self.focus_handle.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Render for PopoverState {
|
|
||||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
div()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventEmitter<DismissEvent> for PopoverState {}
|
|
||||||
|
|
||||||
impl Popover {
|
impl Popover {
|
||||||
pub(crate) fn render_popover<E>(
|
|
||||||
anchor: Anchor,
|
|
||||||
trigger_bounds: Bounds<Pixels>,
|
|
||||||
content: E,
|
|
||||||
_: &mut Window,
|
|
||||||
_: &mut App,
|
|
||||||
) -> Deferred
|
|
||||||
where
|
|
||||||
E: IntoElement + 'static,
|
|
||||||
{
|
|
||||||
deferred(
|
|
||||||
anchored()
|
|
||||||
.snap_to_window_with_margin(px(8.))
|
|
||||||
.anchor(anchor)
|
|
||||||
.position(Self::resolved_corner(anchor, trigger_bounds))
|
|
||||||
.child(div().relative().child(content)),
|
|
||||||
)
|
|
||||||
.with_priority(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn render_popover_content(
|
pub(crate) fn render_popover_content(
|
||||||
anchor: Anchor,
|
anchor: Anchor,
|
||||||
appearance: bool,
|
appearance: bool,
|
||||||
@@ -342,91 +201,34 @@ impl Popover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for Popover {
|
impl RenderOnce for Popover {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||||
let force_open = self.open;
|
let anchor = self.anchor;
|
||||||
let default_open = self.default_open;
|
let appearance = self.appearance;
|
||||||
let tracked_focus_handle = self.tracked_focus_handle.clone();
|
let style = self.style;
|
||||||
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
|
let children = self.children;
|
||||||
PopoverState::new(default_open, cx)
|
let content = self.content;
|
||||||
});
|
|
||||||
|
|
||||||
state.update(cx, |state, _| {
|
BasePopover::new(self.id)
|
||||||
if let Some(tracked_focus_handle) = tracked_focus_handle {
|
.anchor(anchor)
|
||||||
state.tracked_focus_handle = Some(tracked_focus_handle);
|
.mouse_button(self.mouse_button)
|
||||||
}
|
.default_open(self.default_open)
|
||||||
state.on_open_change = self.on_open_change.clone();
|
.overlay_closable(self.overlay_closable)
|
||||||
if let Some(force_open) = force_open {
|
.content(move |state, window, cx| {
|
||||||
state.open = force_open;
|
Self::render_popover_content(anchor, appearance, window, cx)
|
||||||
}
|
.when_some(content, |this, content| {
|
||||||
});
|
this.child((content)(state, window, cx))
|
||||||
|
})
|
||||||
let open = state.read(cx).open;
|
.children(children)
|
||||||
let focus_handle = state.read(cx).focus_handle.clone();
|
.refine_style(&style)
|
||||||
let trigger_bounds = state.read(cx).trigger_bounds;
|
|
||||||
|
|
||||||
let Some(trigger) = self.trigger else {
|
|
||||||
return div().id("empty");
|
|
||||||
};
|
|
||||||
|
|
||||||
let parent_view_id = window.current_view();
|
|
||||||
|
|
||||||
let el = div()
|
|
||||||
.id(self.id)
|
|
||||||
.child((trigger)(open, window, cx))
|
|
||||||
.on_mouse_down(self.mouse_button, {
|
|
||||||
let state = state.clone();
|
|
||||||
move |_, window, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
// We force set open to false to toggle it correctly.
|
|
||||||
// Because if the mouse down out will toggle open first.
|
|
||||||
state.open = open;
|
|
||||||
state.toggle_open(window, cx);
|
|
||||||
});
|
|
||||||
cx.notify(parent_view_id);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.on_prepaint({
|
.when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
|
||||||
let state = state.clone();
|
.when_some(self.open, |this, open| this.open(open))
|
||||||
move |bounds, _, cx| {
|
.when_some(self.tracked_focus_handle, |this, handle| {
|
||||||
state.update(cx, |state, _| {
|
this.track_focus(&handle)
|
||||||
state.trigger_bounds = bounds;
|
})
|
||||||
})
|
.when_some(self.on_open_change, |this, callback| {
|
||||||
}
|
this.on_open_change(move |open, window, cx| callback(open, window, cx))
|
||||||
});
|
})
|
||||||
|
.into_any_element()
|
||||||
if !open {
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
let popover_content =
|
|
||||||
Self::render_popover_content(self.anchor, self.appearance, window, cx)
|
|
||||||
.track_focus(&focus_handle)
|
|
||||||
.key_context(CONTEXT)
|
|
||||||
.on_action(window.listener_for(&state, PopoverState::on_action_cancel))
|
|
||||||
.when_some(self.content, |this, content| {
|
|
||||||
this.child(state.update(cx, |state, cx| (content)(state, window, cx)))
|
|
||||||
})
|
|
||||||
.children(self.children)
|
|
||||||
.when(self.overlay_closable, |this| {
|
|
||||||
this.on_mouse_down_out({
|
|
||||||
let state = state.clone();
|
|
||||||
move |_, window, cx| {
|
|
||||||
state.update(cx, |state, cx| {
|
|
||||||
state.dismiss(window, cx);
|
|
||||||
});
|
|
||||||
cx.notify(parent_view_id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.refine_style(&self.style);
|
|
||||||
|
|
||||||
el.child(Self::render_popover(
|
|
||||||
self.anchor,
|
|
||||||
trigger_bounds,
|
|
||||||
popover_content,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,8 +93,7 @@ impl Root {
|
|||||||
Some(
|
Some(
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.top_0()
|
.inset_0()
|
||||||
.right_0()
|
|
||||||
.child(root.read(cx).notification.clone()),
|
.child(root.read(cx).notification.clone()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, App, AppContext, Context, Entity, IntoElement, ParentElement, Render,
|
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||||
SharedString, Styled, Window,
|
Window, div, relative,
|
||||||
};
|
};
|
||||||
|
use gpui_base::Tooltip as BaseTooltip;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
pub struct Tooltip {
|
pub struct Tooltip {
|
||||||
@@ -18,7 +19,7 @@ impl Tooltip {
|
|||||||
impl Render for Tooltip {
|
impl Render for Tooltip {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div().child(
|
div().child(
|
||||||
div()
|
BaseTooltip::new("tooltip")
|
||||||
.font_family(".SystemUIFont")
|
.font_family(".SystemUIFont")
|
||||||
.m_3()
|
.m_3()
|
||||||
.p_1p5()
|
.p_1p5()
|
||||||
|
|||||||
+95
-15
@@ -21,7 +21,11 @@ repository at `main`, and this workspace's `Cargo.lock` (zed at `4b47ceb`,
|
|||||||
- **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of
|
- **Phase 2: landed.** `input/` runs on base's editing engine. 6,715 lines of
|
||||||
engine and history are gone and 306 are written, taking `crates/ui/src/input`
|
engine and history are gone and 306 are written, taking `crates/ui/src/input`
|
||||||
from 6,573 lines to 321. Six call sites changed, all named in phase 2 below.
|
from 6,573 lines to 321. Six call sites changed, all named in phase 2 below.
|
||||||
- **Phases 3-5: not started.**
|
- **Phase 3: landed.** `tooltip`, `popover`, `modal`, and `notification` run on base's
|
||||||
|
overlay and feedback primitives. Those four modules are 1,434 lines where they
|
||||||
|
were 1,592, and nothing outside `crates/ui` changed. The behavioural differences
|
||||||
|
are named in phase 3 below; the largest is that base's toast stack replaces the
|
||||||
|
fork's notification list.
|
||||||
- One pre-existing, unrelated breakage was found; see
|
- One pre-existing, unrelated breakage was found; see
|
||||||
[A pre-existing wasm blocker](#a-pre-existing-wasm-blocker).
|
[A pre-existing wasm blocker](#a-pre-existing-wasm-blocker).
|
||||||
|
|
||||||
@@ -109,6 +113,9 @@ dragging, both of which are projected.
|
|||||||
|
|
||||||
## What each module becomes
|
## What each module becomes
|
||||||
|
|
||||||
|
LOC is the count before the work; a module whose phase has landed reads
|
||||||
|
`before → after`.
|
||||||
|
|
||||||
| `ui` module | LOC | Plan | `gpui-base` counterpart |
|
| `ui` module | LOC | Plan | `gpui-base` counterpart |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame |
|
| `input/` (input, clear_button) | 6,573 | Replace; keep `ui::input::{Input, InputEvent, InputState}` as the import path. 321 lines remain, and the engine paints itself through `InputEditorStyle` | `InputState`/`TextareaState` (`InputBaseState` in two modes) plus the `InputBase` frame |
|
||||||
@@ -116,10 +123,10 @@ dragging, both of which are projected.
|
|||||||
| `checkbox.rs` | 312 | Delete | `Checkbox` |
|
| `checkbox.rs` | 312 | Delete | `Checkbox` |
|
||||||
| `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` |
|
| `scroll/` (scrollbar, scrollable, scrollable_mask) | 1,332 | Replace; keep the `ScrollableElement` and `Scrollbar` names | `Scrollbar`, `ScrollableMask` |
|
||||||
| `resizable/` | 927 | Replace; base exports the same names (`h_resizable`, `v_resizable`, `resizable_panel`, `PANEL_MIN_SIZE`, `resize_handle`) | `Resizable` + `ResizeHandleRenderer` for the coop hairline |
|
| `resizable/` | 927 | Replace; base exports the same names (`h_resizable`, `v_resizable`, `resizable_panel`, `PANEL_MIN_SIZE`, `resize_handle`) | `Resizable` + `ResizeHandleRenderer` for the coop hairline |
|
||||||
| `modal.rs` | 540 | Port onto base parts; keep `Modal`, `ModalButtonProps`, and `window.open_modal` | `Dialog`, `AlertDialog` |
|
| `modal.rs` | 540 → 500 | Port onto base parts; `Modal`, `ModalButtonProps`, and `window.open_modal` unchanged. `Root` still owns the stack | `Dialog` — focus trap, Escape/Enter/backdrop dispatch, layer priority, deferred host |
|
||||||
| `notification.rs` | 584 | Port; keep `Notification`, `NotificationKind`, and `window.push_notification` | `Toast`, `ToastManager`, `ToastStack` |
|
| `notification.rs` | 584 → 663 | Port; `Notification`, `NotificationKind`, and `window.push_notification` unchanged | `ToastManager` (storage, ids, timers, exit), `ToastStack` (geometry, motion), `Toast` (`Role::Alert`) |
|
||||||
| `popover.rs` | 432 | Replace with a coop-styled wrapper | `Popover`, `Popup`, `Positioner` |
|
| `popover.rs` | 432 → 234 | Coop's builder over base's element; `PopoverState` is base's, re-exported | `Popover`, `Popup`, `Positioner` |
|
||||||
| `tooltip.rs` | 36 | Replace with a coop-styled wrapper | `Tooltip` |
|
| `tooltip.rs` | 36 → 37 | Coop's view rooted at base's element | `Tooltip` (`Role::Tooltip`) |
|
||||||
| `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` |
|
| `button.rs` | 626 | Skin: base behavior plus coop's existing variant tables | `Button`, `StateStyle` |
|
||||||
| `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` |
|
| `switch.rs` | 287 | Skin | `Switch`, `SwitchTrack`, `SwitchThumb` |
|
||||||
| `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` |
|
| `avatar.rs` | 141 | Skin | `Avatar`, `AvatarImage`, `AvatarFallback` |
|
||||||
@@ -310,18 +317,86 @@ Surfaces to re-verify by hand: the chat composer (auto-grow, Enter to send, IME)
|
|||||||
profile bio, the subject line, the settings dialog, the relay and messaging lists, the
|
profile bio, the subject line, the settings dialog, the relay and messaging lists, the
|
||||||
import/restore/backup dialogs, and the sidebar search field.
|
import/restore/backup dialogs, and the sidebar search field.
|
||||||
|
|
||||||
### Phase 3 — overlays and feedback
|
### Phase 3 — overlays and feedback — landed
|
||||||
|
|
||||||
`popover` becomes a wrapper over base `Popover`; `modal` composes base `Dialog` and
|
All four modules keep their names, builders, and call sites. The four files go from
|
||||||
`AlertDialog` while keeping the `Modal` API and `window.open_modal`; `notification`
|
1,592 lines to 1,434, and no file outside `crates/ui` changed.
|
||||||
moves onto `Toast`/`ToastManager` (base owns the stack, timers, and motion; coop owns
|
|
||||||
the visual and the placement from `theme.notification`); `tooltip` becomes a wrapper
|
| `ui` module | What stayed coop's | What is base's now |
|
||||||
over base `Tooltip`. `Root` and `window_ext` keep their public API and host the new
|
| --- | --- | --- |
|
||||||
layers. No call site changes.
|
| `tooltip` | the whole look, `Tooltip::new(text, window, cx)` and the `Render` view | the element and `Role::Tooltip` |
|
||||||
|
| `popover` | every builder, the content styling, the anchor | open lifecycle, dismissal, focus capture and restore, deferred registration, trigger measurement and anchor math |
|
||||||
|
| `modal` | `Modal`, `ModalButtonProps`, `Root`'s stack, `window.open_modal`, the card, buttons, shadows and animations | focus trap, Escape/Enter/backdrop dispatch with a cancel veto, layer priority, the deferred host, `Role::Dialog` |
|
||||||
|
| `notification` | `Notification`, `NotificationKind`, `window.push_notification`, the card and the placement from `theme.notification` | id-replacing storage, auto-hide and exit timers, stack geometry and motion, `Role::Alert` |
|
||||||
|
|
||||||
|
**`tooltip`.** The view and its `new` are unchanged; the styled box inside is
|
||||||
|
`gpui_base::Tooltip` instead of a bare `div`. That is what carries the role. Base's
|
||||||
|
window-level `TooltipOverlay` is deliberately not adopted — gpui's own `.tooltip()`
|
||||||
|
layer already provides the delay and the placement, and taking the overlay would mean
|
||||||
|
rewriting every `.tooltip(..)` call site onto `Popup` plus hover state.
|
||||||
|
|
||||||
|
**`popover`.** `PopoverState` is `gpui_base::PopoverState`, re-exported so
|
||||||
|
`ui::popover::PopoverState` still resolves, and the hand-rolled `anchored`/`deferred`
|
||||||
|
layer, `resolved_corner` and `render_popover` are gone — base's `Popup` measures the
|
||||||
|
trigger, resolves the anchor and snaps to the window edge. The rest of the file is the
|
||||||
|
fork's builder, unchanged, including `trigger_style`, which the fork already stored
|
||||||
|
without ever reading. Two bindings changed hands: `popover::init` (escape → coop's
|
||||||
|
`Cancel` in the `Popover` context) is deleted, because `gpui_base::init` binds
|
||||||
|
escape/enter/space in that same context and coop's lone escape binding would have
|
||||||
|
shadowed base's `Confirm` — the one that opens a popover from its trigger.
|
||||||
|
|
||||||
|
**`modal`.** `Modal` still assembles the card, title, close button, footer buttons,
|
||||||
|
the two shadows and the `fade-in`/`slide-down` animations; `Root` still owns the stack,
|
||||||
|
the focus restore, and the one-visible-overlay rule, now expressed as base's
|
||||||
|
`layer(index, topmost)`. What changed underneath:
|
||||||
|
|
||||||
|
- Escape, Enter and the backdrop now run through base's `Dialog` decisions, so
|
||||||
|
`on_cancel`/`on_ok` returning `false` vetoes all three. The fork honored the veto on
|
||||||
|
the buttons and the backdrop but ignored it on Escape.
|
||||||
|
- Enter on a modal that has a footer but no `on_ok` now calls `on_close` before closing;
|
||||||
|
the fork closed silently. No caller combines the two, and `on_close` defaults to a
|
||||||
|
no-op.
|
||||||
|
- Tab is trapped inside the modal, and the dialog surface carries `Role::Dialog`.
|
||||||
|
- `modal::init` (escape/enter in the `Modal` context) is deleted; base binds them in
|
||||||
|
its own `Dialog` context, which the `Dialog` host installs when `keyboard` is on.
|
||||||
|
- The dim does not move: coop's backdrop element keeps the `window_paddings` inset and
|
||||||
|
the `view_size` that the fork used. Its hit area does move — base's host covers the
|
||||||
|
whole viewport, so a click in the client-side-decoration shadow band now dismisses
|
||||||
|
the modal instead of starting a window resize.
|
||||||
|
|
||||||
|
`AlertDialog` turned out to be unnecessary. Coop's `alert()` and `confirm()` select a
|
||||||
|
button set, not an ARIA role, and they already opt out of backdrop dismissal, which is
|
||||||
|
the whole of what `AlertDialog` adds over `Dialog`.
|
||||||
|
|
||||||
|
**`notification`.** `Notification` keeps its builder and its card. `closing: bool`
|
||||||
|
becomes base's `ToastTransitionStatus`, `dismiss` now emits a `DismissRequest` the list
|
||||||
|
turns into a `ToastManager::dismiss`, and the exit delay is base's 200 ms rather than
|
||||||
|
the fork's fixed 150 ms. `NotificationList` holds
|
||||||
|
`ToastManager<NotificationId, Entity<Notification>>` plus one `ToastStackState`; its
|
||||||
|
`expanded` field and hover handler are gone, and a 50 ms lifecycle tick runs only while
|
||||||
|
something is mounted. The stack is base's:
|
||||||
|
|
||||||
|
- It collapses to three layers with a 14 px peek and a 5% width step per layer, expands
|
||||||
|
on hover or focus, and pauses auto-hide while expanded.
|
||||||
|
- The newest notification sits nearest the window edge; the fork's list grew downwards
|
||||||
|
with the oldest first.
|
||||||
|
- Motion is `ToastMotion::default()`, base's shadcn/Sonner figures. Coop contributes the
|
||||||
|
width the fork's card had, the placement and the margins from `theme.notification`.
|
||||||
|
|
||||||
|
That stack is the one visible change of the phase, and it is the one to judge by hand.
|
||||||
|
If it is not wanted, the smaller step is to keep the list's own `v_flex` and use only
|
||||||
|
`ToastManager` together with `Toast` — base separates the lifecycle from the geometry,
|
||||||
|
so nothing else has to come back.
|
||||||
|
|
||||||
|
Surfaces to re-verify by hand: the settings dialog (its Escape and Enter paths), the
|
||||||
|
import, restore and screening modals (a modal with a textarea, and one with
|
||||||
|
`keyboard(false)`), the dropdown menus that ride the popover, and every
|
||||||
|
`push_notification` site — sending an empty message, a failed upload with its retry
|
||||||
|
action, and the device-approval notification that never auto-hides.
|
||||||
|
|
||||||
### Phase 4 — leaf controls, scroll, and resizable (one module per pull request)
|
### Phase 4 — leaf controls, scroll, and resizable (one module per pull request)
|
||||||
|
|
||||||
Order: `tooltip`, `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the
|
Order: `avatar`, `switch`, `button`, `scroll/`, `resizable/`. `button` is the
|
||||||
largest skin: the `ButtonVariants` and `ButtonCustomVariant` tables, the `compact`,
|
largest skin: the `ButtonVariants` and `ButtonCustomVariant` tables, the `compact`,
|
||||||
`loading`, and `caret` builders, and the variant names stay as they are, with styling
|
`loading`, and `caret` builders, and the variant names stay as they are, with styling
|
||||||
supplied through base's semantic-state styles. `scroll/` keeps the `ScrollableElement`
|
supplied through base's semantic-state styles. `scroll/` keeps the `ScrollableElement`
|
||||||
@@ -345,7 +420,12 @@ menu positioning and dismissal on base `Popup`/`Positioner` is optional and late
|
|||||||
|
|
||||||
There is no UI test suite to lean on, so each phase gets the same treatment:
|
There is no UI test suite to lean on, so each phase gets the same treatment:
|
||||||
|
|
||||||
- `cargo check` and `cargo build` at the workspace root.
|
- `cargo check --workspace` and `cargo build` (default members build `desktop`).
|
||||||
|
`cargo build --workspace` cannot link the web crate's host dylib: `coop_web` is
|
||||||
|
`crate-type = ["cdylib", "rlib"]` and depends on `wasm-bindgen`, `web-sys`,
|
||||||
|
`console_log` and `tracing-wasm` unconditionally, so its dylib is a wasm artifact.
|
||||||
|
That is a property of the manifest rather than of any migrated crate —
|
||||||
|
`cargo check -p coop_web` passes, and the desktop binary links the same crates.
|
||||||
- `cargo check -p theme -p ui --target wasm32-unknown-unknown`. The web target cannot
|
- `cargo check -p theme -p ui --target wasm32-unknown-unknown`. The web target cannot
|
||||||
be checked end to end until the pre-existing blocker below is fixed, so the migrated
|
be checked end to end until the pre-existing blocker below is fixed, so the migrated
|
||||||
crates are checked directly.
|
crates are checked directly.
|
||||||
@@ -402,7 +482,7 @@ compile for `wasm32-unknown-unknown`".
|
|||||||
| 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed |
|
| 1 | Phase 0: `gpui` moves to the `gpui-pre` package, `gpui_tokio` vendored | root `Cargo.toml`, `Cargo.lock`, `web/Cargo.toml`, new `crates/gpui_tokio`; `crates/state` needed no edit | landed |
|
||||||
| 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed |
|
| 2 | Phase 1: base wiring, `sync_base`, deletions | `crates/theme` | landed |
|
||||||
| 3 | Phase 2: input, plus `history.rs` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed |
|
| 3 | Phase 2: input, plus `history.rs` and the `ropey`/`sum_tree`/`lsp-types`/`regex`/`unicode-segmentation`/`tree-sitter` pruning | `crates/workspace`, `crates/chat_ui` (six call sites); no manifest outside `crates/ui` | landed |
|
||||||
| 4 | Phase 3: popover, modal, notification, tooltip | none | not started |
|
| 4 | Phase 3: popover, modal, notification, tooltip | none | landed |
|
||||||
| 5–10 | Phase 4: one leaf module each | none | not started |
|
| 5–10 | Phase 4: one leaf module each | none | not started |
|
||||||
| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started |
|
| later | Phase 5: dock, as its own plan | `crates/workspace`, `crates/chat_ui` | not started |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user