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) { 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);
} }
+43 -83
View File
@@ -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,35 +363,20 @@ 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)
.child( .left(window_paddings.left)
div()
.id("modal")
.w(view_size.width) .w(view_size.width)
.h(view_size.height) .h(view_size.height)
.when(self.overlay_visible, |this| { .when(self.overlay_visible, |this| {
this.occlude().bg(cx.theme().overlay) this.occlude().bg(cx.theme().overlay)
}) })
.when(self.overlay_closable, |this| { .with_animation("fade-in", animation.clone(), move |this, delta| {
// Only the last modal owns the `mouse down - close modal` event. this.opacity(delta)
if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() { });
return this;
}
this.on_mouse_down(MouseButton::Left, { let card = v_flex()
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);
}
})
})
.child(
v_flex()
.id(layer_ix) .id(layer_ix)
.bg(cx.theme().background) .bg(cx.theme().background)
.border_1() .border_1()
@@ -409,45 +384,13 @@ impl RenderOnce for Modal {
.rounded(radius) .rounded(radius)
.when(cx.theme().shadow, |this| this.shadow_xl()) .when(cx.theme().shadow, |this| this.shadow_xl())
.min_h_24() .min_h_24()
.key_context(CONTEXT)
.track_focus(&self.focus_handle)
.refine_style(&self.style) .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. // There style is high priority, can't be overridden.
.absolute() .absolute()
.occlude() .occlude()
.relative() .relative()
.left(x) .left(card_left)
.top(y) .top(card_top)
.w(self.width) .w(self.width)
.when_some(self.max_width, |this, w| this.max_w(w)) .when_some(self.max_width, |this, w| this.max_w(w))
.child( .child(
@@ -463,6 +406,9 @@ impl RenderOnce for Modal {
}), }),
) )
.when(self.show_close, |this| { .when(self.show_close, |this| {
let on_cancel = on_cancel.clone();
let on_close = on_close.clone();
this.child( this.child(
Button::new("close") Button::new("close")
.icon(IconName::CloseCircleFill) .icon(IconName::CloseCircleFill)
@@ -512,7 +458,7 @@ impl RenderOnce for Modal {
.children(footer(render_ok, render_cancel, window, cx)), .children(footer(render_ok, render_cancel, window, cx)),
) )
}) })
.with_animation("slide-down", animation.clone(), move |this, delta| { .with_animation("slide-down", animation, move |this, delta| {
let y_offset = px(0.) + delta * px(30.); let y_offset = px(0.) + delta * px(30.);
// This is equivalent to `shadow_xl` with an extra opacity. // This is equivalent to `shadow_xl` with an extra opacity.
let shadow = vec![ let shadow = vec![
@@ -531,10 +477,24 @@ impl RenderOnce for Modal {
inset: false, inset: false,
}, },
]; ];
this.top(y + y_offset).shadow(shadow) this.top(card_top + y_offset).shadow(shadow)
}), });
)
.with_animation("fade-in", animation, move |this, delta| this.opacity(delta)), 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::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;
} }
self.closing = true;
/// 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(); 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;
cx.update(|cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |view, cx| {
view.closing = false;
cx.emit(DismissEvent);
});
} }
}) }
})
.detach(); /// 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();
}
}
/// 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. /// 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(&notification, move |view, _, _: &DismissEvent, cx| { cx.subscribe(&notification, 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)
} }
} }
+29 -227
View File
@@ -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;
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);
}
}) })
.on_prepaint({ .children(children)
let state = state.clone(); .refine_style(&style)
move |bounds, _, cx| {
state.update(cx, |state, _| {
state.trigger_bounds = bounds;
}) })
} .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| {
if !open { this.track_focus(&handle)
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_some(self.on_open_change, |this, callback| {
.when(self.overlay_closable, |this| { this.on_open_change(move |open, window, cx| callback(open, window, cx))
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);
}
}) })
}) .into_any_element()
.refine_style(&self.style);
el.child(Self::render_popover(
self.anchor,
trigger_bounds,
popover_content,
window,
cx,
))
} }
} }
+1 -2
View File
@@ -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()),
) )
} }
+4 -3
View File
@@ -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
View File
@@ -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 |
| 510 | Phase 4: one leaf module each | none | not started | | 510 | 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 |