migrate overlays and feedback

This commit is contained in:
2026-09-17 16:39:46 +07:00
parent 75a25a9a9d
commit df23067c01
7 changed files with 445 additions and 526 deletions
-2
View File
@@ -42,7 +42,5 @@ mod window_ext;
pub fn init(cx: &mut gpui::App) {
gpui_base::init(cx);
theme::sync_base(cx);
modal::init(cx);
popover::init(cx);
menu::init(cx);
}
+137 -177
View File
@@ -2,28 +2,19 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, ClickEvent, Div, FocusHandle,
InteractiveElement, IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point,
RenderOnce, SharedString, StyleRefinement, Styled, Window, anchored, div, hsla, point, px,
Animation, AnimationExt as _, AnyElement, App, BoxShadow, ClickEvent, Div, FocusHandle,
InteractiveElement as _, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
StyleRefinement, Styled, Window, div, hsla, point, px, size,
};
use gpui_base::Dialog;
use instant::Duration;
use theme::ActiveTheme;
use crate::actions::{Cancel, Confirm};
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _};
use crate::scroll::ScrollableElement;
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 OnOk = Option<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 {
fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement {
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_ok = self.on_ok.clone();
let on_cancel = self.on_cancel.clone();
@@ -345,19 +338,16 @@ impl RenderOnce for Modal {
let radius = cx.theme().radius_lg;
let view_size = window.viewport_size()
- gpui::size(
- size(
window_paddings.left + window_paddings.right,
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 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_left = px(16.);
@@ -373,168 +363,138 @@ impl RenderOnce for Modal {
let animation = Animation::new(Duration::from_secs_f64(0.25))
.with_easing(cubic_bezier(0.32, 0.72, 0., 1.));
anchored()
.position(point(window_paddings.left, window_paddings.top))
.snap_to_window()
let backdrop = div()
.absolute()
.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(
div()
.id("modal")
.w(view_size.width)
.h(view_size.height)
.when(self.overlay_visible, |this| {
this.occlude().bg(cx.theme().overlay)
})
.when(self.overlay_closable, |this| {
// Only the last modal owns the `mouse down - close modal` event.
if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() {
return this;
}
.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| {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
this.on_mouse_down(MouseButton::Left, {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
move |_, window, cx| {
on_cancel(&ClickEvent::default(), window, cx);
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}
})
})
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()
.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()
.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)),
.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, 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
View File
@@ -1,7 +1,7 @@
use std::any::TypeId;
use std::collections::{HashMap, VecDeque};
use std::collections::HashMap;
use std::rc::Rc;
use instant::Duration;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -10,12 +10,25 @@ use gpui::{
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 crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _};
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)]
pub enum NotificationKind {
#[default]
@@ -79,7 +92,7 @@ pub struct Notification {
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>>,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
closing: bool,
transition_status: ToastTransitionStatus,
}
impl From<String> for Notification {
@@ -133,7 +146,7 @@ impl Notification {
action_builder: None,
content_builder: None,
on_click: None,
closing: false,
transition_status: ToastTransitionStatus::Starting,
}
}
@@ -238,29 +251,29 @@ impl Notification {
}
/// Dismiss the notification.
pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context<Self>) {
if self.closing {
return;
pub fn dismiss(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissRequest);
}
/// 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.
cx.spawn(async move |view, cx| {
cx.background_executor()
.timer(Duration::from_secs_f32(0.15))
.await;
/// Mark the enter transition as finished, driven by the notification list.
pub(crate) fn complete_enter(&mut self, cx: &mut Context<Self>) {
if self.transition_status == ToastTransitionStatus::Starting {
self.transition_status = ToastTransitionStatus::Present;
cx.notify();
}
}
cx.update(|cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| {
view.closing = false;
cx.emit(DismissEvent);
});
}
})
})
.detach();
/// Finish the exit transition, driven by the notification list.
pub(crate) fn complete_close(&mut self, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
/// Set the content of the notification.
@@ -280,6 +293,7 @@ impl Default for Notification {
}
impl EventEmitter<DismissEvent> for Notification {}
impl EventEmitter<DismissRequest> for Notification {}
impl FluentBuilder for Notification {}
@@ -319,17 +333,19 @@ impl Render for Notification {
_ => 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 only_message = !has_title && content.is_none() && action.is_none();
let placement = cx.theme().notification.placement;
h_flex()
.id("notification")
BaseToast::new("notification")
.transition_status(transition_status)
.h_flex()
.group("")
.occlude()
.relative()
.w_112()
.w_full()
.border_1()
.border_color(cx.theme().border)
.bg(background)
@@ -455,10 +471,13 @@ impl Render for Notification {
/// A list of notifications.
pub struct NotificationList {
/// 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.
expanded: bool,
/// Measured geometry and interaction state of the visible stack.
stack_state: ToastStackState,
/// Whether the lifecycle clock is running. The loop clears it as it exits.
is_advancing: bool,
/// Subscriptions
_subscriptions: HashMap<NotificationId, Subscription>,
@@ -467,12 +486,64 @@ pub struct NotificationList {
impl NotificationList {
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
Self {
notifications: VecDeque::new(),
expanded: false,
notifications: ToastManager::new(ToastMotion::default()),
stack_state: ToastStackState::default(),
is_advancing: false,
_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(
&mut self,
notification: impl Into<Notification>,
@@ -483,102 +554,110 @@ impl NotificationList {
let id = notification.id.clone();
let autohide = notification.autohide;
// Remove the notification by id, for keep unique.
self.notifications.retain(|note| note.read(cx).id != id);
let notification = cx.new(|_| notification);
let dismiss_id = id.clone();
self._subscriptions.insert(
id.clone(),
cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
view.notifications.retain(|note| id != note.read(cx).id);
view._subscriptions.remove(&id);
cx.subscribe(&notification, move |view, _, _: &DismissRequest, cx| {
if view
.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());
if autohide {
// Sleep for 5 seconds to autohide the notification
cx.spawn_in(window, async move |_this, cx| {
cx.background_executor().timer(Duration::from_secs(5)).await;
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.notifications.push(
id,
notification,
ToastOptions {
timeout: autohide.then_some(AUTOHIDE_DURATION),
},
cx.background_executor().now(),
);
self.start_advancing(window, cx);
cx.notify();
}
pub(crate) fn close(
&mut self,
id: impl Into<NotificationId>,
window: &mut Window,
_window: &mut Window,
cx: &mut Context<Self>,
) {
let id: NotificationId = id.into();
if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) {
n.update(cx, |note, cx| note.dismiss(window, cx))
if self
.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();
}
pub fn clear(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.notifications.clear();
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();
}
pub fn notifications(&self) -> Vec<Entity<Notification>> {
self.notifications.iter().cloned().collect()
self.notifications
.iter()
.map(|(_, note, _)| note.clone())
.collect()
}
}
impl Render for NotificationList {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl IntoElement {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
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 margins = &cx.theme().notification.margins;
let items = self
.notifications
.visible(max_items)
.map(|(id, note, _)| (id.clone(), note.clone()))
.collect::<Vec<_>>();
v_flex()
.id("notification-list")
let stack = items
.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)
.pt(margins.top)
.pb(margins.bottom)
.gap_3()
.when(
matches!(placement, Anchor::TopRight),
|this| this.pr(margins.right), // ignore left
)
.when(
matches!(placement, Anchor::TopLeft),
|this| this.pl(margins.left), // ignore right
)
.when(
matches!(placement, Anchor::BottomLeft),
|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)
.absolute()
.map(|this| match placement {
Anchor::TopLeft => this.top(margins.top).left(margins.left),
Anchor::TopRight => this.top(margins.top).right(margins.right),
Anchor::TopCenter => this.top(margins.top).left_0().right_0().mx_auto(),
Anchor::BottomLeft => this.bottom(margins.bottom).left(margins.left),
Anchor::BottomRight => this.bottom(margins.bottom).right(margins.right),
Anchor::BottomCenter => this.bottom(margins.bottom).left_0().right_0().mx_auto(),
Anchor::LeftCenter => this.left(margins.left).top_0().bottom_0().my_auto(),
Anchor::RightCenter => this.right(margins.right).top_0().bottom_0().my_auto(),
});
div().size_full().child(stack)
}
}
+32 -230
View File
@@ -2,20 +2,13 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, App, Bounds, Context, Deferred, DismissEvent, Div, ElementId, EventEmitter,
FocusHandle, Focusable, InteractiveElement as _, IntoElement, KeyBinding, MouseButton,
ParentElement, Pixels, Point, Render, RenderOnce, Stateful, StyleRefinement, Styled,
Subscription, Window, anchored, deferred, div, px,
Anchor, AnyElement, App, Context, Div, ElementId, FocusHandle, InteractiveElement as _,
IntoElement, MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
};
use gpui_base::Popover as BasePopover;
pub use gpui_base::PopoverState;
use crate::actions::Cancel;
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))])
}
use crate::{Selectable, StyledExt as _, v_flex};
/// A popover element that can be triggered by a button or any other element.
#[derive(IntoElement)]
@@ -173,28 +166,6 @@ impl Popover {
self.tracked_focus_handle = Some(handle.clone());
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 {
@@ -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 {
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(
anchor: Anchor,
appearance: bool,
@@ -342,91 +201,34 @@ impl Popover {
}
impl RenderOnce for Popover {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let force_open = self.open;
let default_open = self.default_open;
let tracked_focus_handle = self.tracked_focus_handle.clone();
let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| {
PopoverState::new(default_open, cx)
});
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let anchor = self.anchor;
let appearance = self.appearance;
let style = self.style;
let children = self.children;
let content = self.content;
state.update(cx, |state, _| {
if let Some(tracked_focus_handle) = tracked_focus_handle {
state.tracked_focus_handle = Some(tracked_focus_handle);
}
state.on_open_change = self.on_open_change.clone();
if let Some(force_open) = force_open {
state.open = force_open;
}
});
let open = state.read(cx).open;
let focus_handle = state.read(cx).focus_handle.clone();
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);
}
BasePopover::new(self.id)
.anchor(anchor)
.mouse_button(self.mouse_button)
.default_open(self.default_open)
.overlay_closable(self.overlay_closable)
.content(move |state, window, cx| {
Self::render_popover_content(anchor, appearance, window, cx)
.when_some(content, |this, content| {
this.child((content)(state, window, cx))
})
.children(children)
.refine_style(&style)
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, _| {
state.trigger_bounds = bounds;
})
}
});
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,
))
.when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
.when_some(self.open, |this, open| this.open(open))
.when_some(self.tracked_focus_handle, |this, handle| {
this.track_focus(&handle)
})
.when_some(self.on_open_change, |this, callback| {
this.on_open_change(move |open, window, cx| callback(open, window, cx))
})
.into_any_element()
}
}
+1 -2
View File
@@ -93,8 +93,7 @@ impl Root {
Some(
div()
.absolute()
.top_0()
.right_0()
.inset_0()
.child(root.read(cx).notification.clone()),
)
}
+4 -3
View File
@@ -1,8 +1,9 @@
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, App, AppContext, Context, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Window,
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div, relative,
};
use gpui_base::Tooltip as BaseTooltip;
use theme::ActiveTheme;
pub struct Tooltip {
@@ -18,7 +19,7 @@ impl Tooltip {
impl Render for Tooltip {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().child(
div()
BaseTooltip::new("tooltip")
.font_family(".SystemUIFont")
.m_3()
.p_1p5()