split toast into alert and toast
This commit is contained in:
@@ -32,6 +32,7 @@ full = [
|
|||||||
"tooltip",
|
"tooltip",
|
||||||
"menu",
|
"menu",
|
||||||
"dialog",
|
"dialog",
|
||||||
|
"alert",
|
||||||
"toast",
|
"toast",
|
||||||
]
|
]
|
||||||
button = []
|
button = []
|
||||||
@@ -49,4 +50,5 @@ scroll_area = []
|
|||||||
tooltip = []
|
tooltip = []
|
||||||
menu = ["scroll_area"]
|
menu = ["scroll_area"]
|
||||||
dialog = []
|
dialog = []
|
||||||
|
alert = []
|
||||||
toast = []
|
toast = []
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
Animation, AnimationExt as _, App, Context, ElementId, Entity, InteractiveElement as _,
|
||||||
|
IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
|
||||||
|
Styled as _, Window, deferred, div, ease_out_quint, px,
|
||||||
|
};
|
||||||
|
use gpui_native_icons::{IconRole, icon};
|
||||||
|
use gpui_native_theme::{ActiveTheme as _, ThemeVariant};
|
||||||
|
|
||||||
|
use crate::common::DismissHandler;
|
||||||
|
use crate::common::v_flex;
|
||||||
|
|
||||||
|
/// How alarming an [`Alert`] is; picks the icon and the accent color.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum AlertSeverity {
|
||||||
|
/// The default; uses the info role.
|
||||||
|
#[default]
|
||||||
|
Info,
|
||||||
|
/// Uses the success role.
|
||||||
|
Success,
|
||||||
|
/// Uses the warning role.
|
||||||
|
Warning,
|
||||||
|
/// Uses the destructive role.
|
||||||
|
Danger,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A static callout banner.
|
||||||
|
#[derive(Clone, IntoElement)]
|
||||||
|
pub struct Alert {
|
||||||
|
id: ElementId,
|
||||||
|
title: Option<SharedString>,
|
||||||
|
message: Option<SharedString>,
|
||||||
|
severity: AlertSeverity,
|
||||||
|
icon: Option<IconRole>,
|
||||||
|
show_icon: bool,
|
||||||
|
on_dismiss: Option<DismissHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Alert {
|
||||||
|
pub fn new<I>(id: I) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
title: None,
|
||||||
|
message: None,
|
||||||
|
severity: AlertSeverity::Info,
|
||||||
|
icon: None,
|
||||||
|
show_icon: true,
|
||||||
|
on_dismiss: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the primary line.
|
||||||
|
pub fn title<L>(mut self, title: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.title = Some(title.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a secondary line under the title.
|
||||||
|
pub fn message<L>(mut self, message: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.message = Some(message.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets how alarming the alert is.
|
||||||
|
pub fn severity(mut self, severity: AlertSeverity) -> Self {
|
||||||
|
self.severity = severity;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the severity-derived icon with this glyph.
|
||||||
|
pub fn icon(mut self, role: IconRole) -> Self {
|
||||||
|
self.icon = Some(role);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides the icon entirely.
|
||||||
|
pub fn no_icon(mut self) -> Self {
|
||||||
|
self.show_icon = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs when the user dismisses the alert.
|
||||||
|
pub fn on_dismiss<F>(mut self, handler: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&mut App) + 'static,
|
||||||
|
{
|
||||||
|
self.on_dismiss = Some(Rc::new(handler));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rekey(mut self, id: usize) -> Self {
|
||||||
|
self.id = ElementId::from(id);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Alert {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
|
||||||
|
let ramp = match self.severity {
|
||||||
|
AlertSeverity::Info => theme.ramps.info,
|
||||||
|
AlertSeverity::Success => theme.ramps.success,
|
||||||
|
AlertSeverity::Warning => theme.ramps.warning,
|
||||||
|
AlertSeverity::Danger => theme.ramps.destructive,
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon_role = self.icon.unwrap_or(match self.severity {
|
||||||
|
AlertSeverity::Info => IconRole::Info,
|
||||||
|
AlertSeverity::Success => IconRole::Check,
|
||||||
|
AlertSeverity::Warning | AlertSeverity::Danger => IconRole::Warning,
|
||||||
|
});
|
||||||
|
|
||||||
|
let foreground = theme.colors.foreground;
|
||||||
|
let muted_foreground = theme.colors.muted_foreground;
|
||||||
|
let muted = theme.colors.muted;
|
||||||
|
let surface = theme.colors.surface;
|
||||||
|
let border = theme.colors.border;
|
||||||
|
|
||||||
|
let mut elevation = theme.elevations.popover;
|
||||||
|
if theme.variant == ThemeVariant::Light {
|
||||||
|
elevation.color = elevation.color.alpha(0.12);
|
||||||
|
}
|
||||||
|
let shadows = elevation.shadows();
|
||||||
|
|
||||||
|
let text_size = theme.sizes.small.text_size;
|
||||||
|
let icon_size = theme.sizes.small.icon_size;
|
||||||
|
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.gap_2()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.rounded(theme.radius.overlay)
|
||||||
|
.bg(surface)
|
||||||
|
.border_1()
|
||||||
|
.border_color(border)
|
||||||
|
.shadow(shadows)
|
||||||
|
.when(self.show_icon, |alert| {
|
||||||
|
alert.child(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.h(window.line_height())
|
||||||
|
.child(icon(icon_role).size(icon_size).color(ramp.base)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.gap_0p5()
|
||||||
|
.when_some(self.title.clone(), |alert, title| {
|
||||||
|
alert.child(
|
||||||
|
div()
|
||||||
|
.text_size(text_size)
|
||||||
|
.text_color(foreground)
|
||||||
|
.when(self.message.is_some(), |title| {
|
||||||
|
title.font_weight(gpui::FontWeight::SEMIBOLD)
|
||||||
|
})
|
||||||
|
.child(title),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when_some(self.message.clone(), |alert, message| {
|
||||||
|
// With no title, the message is the primary line.
|
||||||
|
let color = if self.title.is_some() {
|
||||||
|
muted_foreground
|
||||||
|
} else {
|
||||||
|
foreground
|
||||||
|
};
|
||||||
|
alert.child(div().text_size(text_size).text_color(color).child(message))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when_some(self.on_dismiss, |alert, on_dismiss| {
|
||||||
|
alert.child(
|
||||||
|
div()
|
||||||
|
.id((self.id, "alert-close"))
|
||||||
|
.flex_none()
|
||||||
|
.cursor_pointer()
|
||||||
|
.p_0p5()
|
||||||
|
.rounded(px(4.))
|
||||||
|
.hover(|style| style.bg(muted))
|
||||||
|
.child(
|
||||||
|
icon(IconRole::Close)
|
||||||
|
.size(theme.sizes.small.icon_size * 0.75)
|
||||||
|
.color(foreground.alpha(0.55)),
|
||||||
|
)
|
||||||
|
.on_click(move |_, _window, cx| on_dismiss(cx)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pushed alert, with its identity in the stack.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AlertEntry {
|
||||||
|
pub id: usize,
|
||||||
|
pub alert: Alert,
|
||||||
|
on_dismiss: Option<DismissHandler>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stack of pushed alerts.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Alerts {
|
||||||
|
entries: Vec<AlertEntry>,
|
||||||
|
next_id: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Alerts {
|
||||||
|
/// Pushes an alert onto the stack.
|
||||||
|
pub fn push(&mut self, mut alert: Alert, cx: &mut Context<Self>) -> usize {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
|
||||||
|
let on_dismiss = alert.on_dismiss.take();
|
||||||
|
let stack = cx.entity().downgrade();
|
||||||
|
|
||||||
|
alert.on_dismiss = Some(Rc::new(move |cx: &mut App| {
|
||||||
|
stack.update(cx, |alerts, cx| alerts.dismiss(id, cx)).ok();
|
||||||
|
}));
|
||||||
|
|
||||||
|
self.entries.push(AlertEntry {
|
||||||
|
id,
|
||||||
|
alert: alert.rekey(id),
|
||||||
|
on_dismiss,
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses one alert by id, running its dismiss handler.
|
||||||
|
pub fn dismiss(&mut self, id: usize, cx: &mut Context<Self>) {
|
||||||
|
if let Some(position) = self.entries.iter().position(|entry| entry.id == id) {
|
||||||
|
let entry = self.entries.remove(position);
|
||||||
|
cx.notify();
|
||||||
|
if let Some(on_dismiss) = &entry.on_dismiss {
|
||||||
|
(on_dismiss.clone())(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses every alert.
|
||||||
|
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if !self.entries.is_empty() {
|
||||||
|
self.entries.clear();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stacked alerts, oldest first.
|
||||||
|
pub fn entries(&self) -> &[AlertEntry] {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many alerts are stacked.
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The floating stack of pushed alerts, topmost right.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct AlertsView {
|
||||||
|
alerts: Entity<Alerts>,
|
||||||
|
id: ElementId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlertsView {
|
||||||
|
/// Binds the view to an alert stack.
|
||||||
|
pub fn new<I>(id: I, alerts: Entity<Alerts>) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
alerts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for AlertsView {
|
||||||
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let enter_duration = theme.motion.overlay;
|
||||||
|
|
||||||
|
let entries: Vec<AlertEntry> = self.alerts.read(cx).entries().to_vec();
|
||||||
|
|
||||||
|
deferred(
|
||||||
|
div()
|
||||||
|
.id(self.id)
|
||||||
|
.absolute()
|
||||||
|
.top_4()
|
||||||
|
.right_4()
|
||||||
|
.w_80()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_end()
|
||||||
|
.gap_2()
|
||||||
|
.children(entries.iter().rev().map(|entry| {
|
||||||
|
let id = entry.id;
|
||||||
|
let ease_slide = ease_out_quint();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.relative()
|
||||||
|
.w_full()
|
||||||
|
.child(entry.alert.clone())
|
||||||
|
.with_animation(
|
||||||
|
("alert-enter", id),
|
||||||
|
Animation::new(enter_duration),
|
||||||
|
move |layer, delta| layer.left(px(24. * (1. - ease_slide(delta)))),
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
pub use gpui_native_theme::Size;
|
pub use gpui_native_theme::Size;
|
||||||
pub use gpui_native_utils::{h_flex, v_flex};
|
pub use gpui_native_utils::{h_flex, v_flex};
|
||||||
|
|
||||||
|
/// A handler told to clean up after a dismissed surface.
|
||||||
|
pub type DismissHandler = std::rc::Rc<dyn Fn(&mut gpui::App)>;
|
||||||
|
|
||||||
/// A component that can be told which size to render at.
|
/// A component that can be told which size to render at.
|
||||||
pub trait Sizable: Sized {
|
pub trait Sizable: Sized {
|
||||||
/// Sets the size.
|
/// Sets the size.
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ pub mod dialog;
|
|||||||
#[cfg(feature = "menu")]
|
#[cfg(feature = "menu")]
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
|
|
||||||
|
#[cfg(feature = "alert")]
|
||||||
|
pub mod alert;
|
||||||
|
|
||||||
#[cfg(feature = "popover")]
|
#[cfg(feature = "popover")]
|
||||||
pub mod popover;
|
pub mod popover;
|
||||||
|
|
||||||
|
|||||||
+178
-138
@@ -1,3 +1,7 @@
|
|||||||
|
//! Toasts: succinct, temporary messages at the bottom center of the window
|
||||||
|
//! — Adwaita Toast, the macOS banner. One toast at a time: content, an
|
||||||
|
//! optional action, and a close button; fades in, then fades out.
|
||||||
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -10,34 +14,38 @@ use gpui::{
|
|||||||
use gpui_native_icons::{IconRole, icon};
|
use gpui_native_icons::{IconRole, icon};
|
||||||
use gpui_native_theme::ActiveTheme as _;
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
use crate::common::v_flex;
|
use crate::common::DismissHandler;
|
||||||
|
|
||||||
/// The dismiss handler of a [`Toast`].
|
/// The action handler of a [`Toast`].
|
||||||
pub type DismissHandler = Rc<dyn Fn(&mut App)>;
|
pub type ActionHandler = Rc<dyn Fn(&mut App)>;
|
||||||
|
|
||||||
/// How alarming a [`Toast`] is; picks the icon and the accent color.
|
/// One user action offered by a toast.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone)]
|
||||||
pub enum ToastSeverity {
|
pub struct ToastAction {
|
||||||
/// The default; uses the primary accent.
|
label: SharedString,
|
||||||
#[default]
|
handler: ActionHandler,
|
||||||
Info,
|
|
||||||
/// Uses the success role.
|
|
||||||
Success,
|
|
||||||
/// Uses the warning role.
|
|
||||||
Warning,
|
|
||||||
/// Uses the destructive role.
|
|
||||||
Danger,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toast notification.
|
impl ToastAction {
|
||||||
|
/// Creates an action with this label.
|
||||||
|
pub fn new<L, F>(label: L, handler: F) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
F: Fn(&mut App) + 'static,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
label: label.into(),
|
||||||
|
handler: Rc::new(handler),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A toast notification.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Toast {
|
pub struct Toast {
|
||||||
title: SharedString,
|
message: SharedString,
|
||||||
message: Option<SharedString>,
|
|
||||||
severity: ToastSeverity,
|
|
||||||
/// The painted glyph; `None` derives it from the severity.
|
|
||||||
icon: Option<IconRole>,
|
icon: Option<IconRole>,
|
||||||
show_icon: bool,
|
action: Option<ToastAction>,
|
||||||
duration: Option<Duration>,
|
duration: Option<Duration>,
|
||||||
on_dismiss: Option<DismissHandler>,
|
on_dismiss: Option<DismissHandler>,
|
||||||
}
|
}
|
||||||
@@ -46,46 +54,29 @@ impl Toast {
|
|||||||
/// How long a toast stays up by default.
|
/// How long a toast stays up by default.
|
||||||
pub const DURATION: Duration = Duration::from_secs(5);
|
pub const DURATION: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
/// Creates an informational toast with this title.
|
/// Creates a toast with this one-line content.
|
||||||
pub fn new<L>(title: L) -> Self
|
pub fn new<L>(message: L) -> Self
|
||||||
where
|
where
|
||||||
L: Into<SharedString>,
|
L: Into<SharedString>,
|
||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
title: title.into(),
|
message: message.into(),
|
||||||
message: None,
|
|
||||||
severity: ToastSeverity::Info,
|
|
||||||
icon: None,
|
icon: None,
|
||||||
show_icon: true,
|
action: None,
|
||||||
duration: Some(Self::DURATION),
|
duration: Some(Self::DURATION),
|
||||||
on_dismiss: None,
|
on_dismiss: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a secondary line under the title.
|
/// Shows this glyph before the content.
|
||||||
pub fn message<L>(mut self, message: L) -> Self
|
|
||||||
where
|
|
||||||
L: Into<SharedString>,
|
|
||||||
{
|
|
||||||
self.message = Some(message.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets how alarming the toast is.
|
|
||||||
pub fn severity(mut self, severity: ToastSeverity) -> Self {
|
|
||||||
self.severity = severity;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Overrides the severity-derived icon with this glyph.
|
|
||||||
pub fn icon(mut self, role: IconRole) -> Self {
|
pub fn icon(mut self, role: IconRole) -> Self {
|
||||||
self.icon = Some(role);
|
self.icon = Some(role);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hides the icon entirely.
|
/// Offers the user this action.
|
||||||
pub fn no_icon(mut self) -> Self {
|
pub fn action(mut self, action: ToastAction) -> Self {
|
||||||
self.show_icon = false;
|
self.action = Some(action);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +86,7 @@ impl Toast {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs when the toast is dismissed by any means.
|
/// Runs when the toast is dismissed by any means, once it is fully gone.
|
||||||
pub fn on_dismiss<F>(mut self, handler: F) -> Self
|
pub fn on_dismiss<F>(mut self, handler: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(&mut App) + 'static,
|
F: Fn(&mut App) + 'static,
|
||||||
@@ -115,19 +106,24 @@ pub struct ToastEntry {
|
|||||||
/// The store of raised toasts.
|
/// The store of raised toasts.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Toasts {
|
pub struct Toasts {
|
||||||
entries: Vec<ToastEntry>,
|
current: Option<ToastEntry>,
|
||||||
|
exiting: Option<ToastEntry>,
|
||||||
next_id: usize,
|
next_id: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Toasts {
|
impl Toasts {
|
||||||
/// Raises a toast, starting its auto-dismiss timer. Returns its id.
|
/// Raises a toast, replacing the one on screen. Returns its id.
|
||||||
pub fn push(&mut self, toast: Toast, cx: &mut Context<Self>) -> usize {
|
pub fn push(&mut self, toast: Toast, cx: &mut Context<Self>) -> usize {
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
|
|
||||||
let duration = toast.duration;
|
let duration = toast.duration;
|
||||||
|
|
||||||
self.entries.push(ToastEntry { id, toast });
|
if let Some(previous) = self.current.take() {
|
||||||
|
self.start_exit(previous, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.current = Some(ToastEntry { id, toast });
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
if let Some(duration) = duration.filter(|duration| !duration.is_zero()) {
|
if let Some(duration) = duration.filter(|duration| !duration.is_zero()) {
|
||||||
@@ -141,34 +137,65 @@ impl Toasts {
|
|||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dismisses one toast by id, running its dismiss handler.
|
/// Dismisses one toast by id.
|
||||||
pub fn dismiss(&mut self, id: usize, cx: &mut Context<Self>) {
|
pub fn dismiss(&mut self, id: usize, cx: &mut Context<Self>) {
|
||||||
if let Some(position) = self.entries.iter().position(|entry| entry.id == id) {
|
if self.exiting.as_ref().is_some_and(|entry| entry.id == id) {
|
||||||
let entry = self.entries.remove(position);
|
self.finish_exit(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.current.as_ref().is_some_and(|entry| entry.id == id) {
|
||||||
|
let entry = self.current.take().expect("current checked above");
|
||||||
|
self.start_exit(entry, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses the toast on screen.
|
||||||
|
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if let Some(entry) = self.current.take() {
|
||||||
|
self.start_exit(entry, cx);
|
||||||
|
} else {
|
||||||
|
self.finish_exit(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The toast now showing, if any.
|
||||||
|
pub fn current(&self) -> Option<&ToastEntry> {
|
||||||
|
self.current.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many toasts are on screen, including one fading out.
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
usize::from(self.current.is_some()) + usize::from(self.exiting.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves a toast into the fade-out phase.
|
||||||
|
fn start_exit(&mut self, entry: ToastEntry, cx: &mut Context<Self>) {
|
||||||
|
if let Some(previous) = self.exiting.take()
|
||||||
|
&& let Some(on_dismiss) = &previous.toast.on_dismiss
|
||||||
|
{
|
||||||
|
(on_dismiss.clone())(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.exiting = Some(entry);
|
||||||
|
cx.notify();
|
||||||
|
|
||||||
|
let fade = cx.theme().motion.overlay;
|
||||||
|
|
||||||
|
cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(fade).await;
|
||||||
|
this.update(cx, |toasts, cx| toasts.finish_exit(cx)).ok();
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_exit(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if let Some(entry) = self.exiting.take() {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
if let Some(on_dismiss) = &entry.toast.on_dismiss {
|
if let Some(on_dismiss) = &entry.toast.on_dismiss {
|
||||||
on_dismiss(cx);
|
(on_dismiss.clone())(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dismisses every toast.
|
|
||||||
pub fn clear(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if !self.entries.is_empty() {
|
|
||||||
self.entries.clear();
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The raised toasts, oldest first.
|
|
||||||
pub fn entries(&self) -> &[ToastEntry] {
|
|
||||||
&self.entries
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many toasts are raised.
|
|
||||||
pub fn count(&self) -> usize {
|
|
||||||
self.entries.len()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
@@ -191,97 +218,118 @@ impl ToastsView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for ToastsView {
|
impl RenderOnce for ToastsView {
|
||||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let theme = cx.theme();
|
let fade = cx.theme().motion.overlay;
|
||||||
let surface = theme.colors.surface;
|
|
||||||
let border = theme.colors.border;
|
|
||||||
let foreground = theme.colors.foreground;
|
|
||||||
let muted_foreground = theme.colors.muted_foreground;
|
|
||||||
let muted = theme.colors.muted;
|
|
||||||
let radius = theme.radius.overlay;
|
|
||||||
let shadows = theme.elevations.popover.shadows();
|
|
||||||
let icon_size = theme.sizes.small.icon_size;
|
|
||||||
let enter_duration = theme.motion.overlay;
|
|
||||||
|
|
||||||
let entries: Vec<ToastEntry> = self.toasts.read(cx).entries().to_vec();
|
let exiting = self.toasts.read(cx).exiting.clone();
|
||||||
|
let current = self.toasts.read(cx).current.clone();
|
||||||
|
|
||||||
deferred(
|
deferred(
|
||||||
div()
|
div()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
.absolute()
|
.absolute()
|
||||||
.top_4()
|
.left_0()
|
||||||
.right_4()
|
.bottom_4()
|
||||||
|
.w_full()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.items_end()
|
.items_center()
|
||||||
.gap_2()
|
.when_some(exiting, |column, entry| {
|
||||||
.w_80()
|
let id = entry.id;
|
||||||
.children(entries.into_iter().map(|entry| {
|
column.child(pill(&entry, &self.toasts, cx).with_animation(
|
||||||
|
("toast-exit", id),
|
||||||
|
Animation::new(fade),
|
||||||
|
|pill, delta| pill.opacity(1. - delta),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.when_some(current, |column, entry| {
|
||||||
|
let id = entry.id;
|
||||||
|
column.child(pill(&entry, &self.toasts, cx).with_animation(
|
||||||
|
("toast-enter", id),
|
||||||
|
Animation::new(fade).with_easing(ease_out_quint()),
|
||||||
|
|pill, delta| pill.opacity(delta),
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pill(entry: &ToastEntry, toasts: &Entity<Toasts>, cx: &App) -> gpui::Stateful<gpui::Div> {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let surface = theme.colors.surface;
|
||||||
|
let border = theme.colors.border;
|
||||||
|
let foreground = theme.colors.foreground;
|
||||||
|
let muted = theme.colors.muted;
|
||||||
|
let primary = theme.ramps.primary.base;
|
||||||
|
let primary_hover = theme.ramps.primary.hover;
|
||||||
|
let primary_foreground = theme.colors.primary_foreground;
|
||||||
|
let radius = theme.radius.full;
|
||||||
|
let shadows = theme.elevations.popover.shadows();
|
||||||
|
let icon_size = theme.sizes.small.icon_size;
|
||||||
|
let text_size = theme.sizes.small.text_size;
|
||||||
let toast = &entry.toast;
|
let toast = &entry.toast;
|
||||||
let toasts = self.toasts.clone();
|
let toasts = toasts.clone();
|
||||||
|
let id = entry.id;
|
||||||
let accent = match toast.severity {
|
|
||||||
ToastSeverity::Info => theme.ramps.info.base,
|
|
||||||
ToastSeverity::Success => theme.ramps.success.base,
|
|
||||||
ToastSeverity::Warning => theme.ramps.warning.base,
|
|
||||||
ToastSeverity::Danger => theme.ramps.destructive.base,
|
|
||||||
};
|
|
||||||
|
|
||||||
let icon_role = toast.icon.unwrap_or(match toast.severity {
|
|
||||||
ToastSeverity::Info => IconRole::Info,
|
|
||||||
ToastSeverity::Success => IconRole::Check,
|
|
||||||
ToastSeverity::Warning | ToastSeverity::Danger => IconRole::Warning,
|
|
||||||
});
|
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id(("toast", entry.id))
|
.id(("toast", id))
|
||||||
|
.max_w(px(480.))
|
||||||
.flex()
|
.flex()
|
||||||
.items_start()
|
.items_center()
|
||||||
.gap_2()
|
.py_1p5()
|
||||||
.py_2()
|
|
||||||
.px_3()
|
.px_3()
|
||||||
.w_full()
|
|
||||||
.bg(surface)
|
.bg(surface)
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(border)
|
.border_color(border)
|
||||||
.rounded(radius)
|
.rounded(radius)
|
||||||
.shadow(shadows.clone())
|
.shadow(shadows.clone())
|
||||||
.when(toast.show_icon, |toast| {
|
.when_some(toast.icon, |pill, icon_role| {
|
||||||
toast.child(
|
pill.child(
|
||||||
div()
|
div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
|
.mr_2()
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.h(window.line_height())
|
.child(icon(icon_role).size(icon_size).color(foreground)),
|
||||||
.child(icon(icon_role).size(icon_size).color(accent)),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.child(
|
|
||||||
v_flex()
|
|
||||||
.flex_1()
|
|
||||||
.gap_0p5()
|
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_size(theme.sizes.small.text_size)
|
.min_w(px(0.))
|
||||||
|
.truncate()
|
||||||
|
.text_size(text_size)
|
||||||
.text_color(foreground)
|
.text_color(foreground)
|
||||||
.when(toast.message.is_some(), |toast| {
|
.child(toast.message.clone()),
|
||||||
toast.font_weight(gpui::FontWeight::SEMIBOLD)
|
|
||||||
})
|
|
||||||
.child(toast.title.clone()),
|
|
||||||
)
|
)
|
||||||
.when_some(toast.message.clone(), |toast, message| {
|
.when_some(toast.action.clone(), |pill, action| {
|
||||||
toast.child(
|
let toasts = toasts.clone();
|
||||||
|
pill.child(
|
||||||
div()
|
div()
|
||||||
.text_size(theme.sizes.small.text_size)
|
.id(("toast-action", id))
|
||||||
.text_color(muted_foreground)
|
.flex_none()
|
||||||
.child(message),
|
.ml_1p5()
|
||||||
)
|
.cursor_pointer()
|
||||||
|
.px_2()
|
||||||
|
.py_0p5()
|
||||||
|
.rounded(radius)
|
||||||
|
.bg(primary)
|
||||||
|
.hover(|style| style.bg(primary_hover))
|
||||||
|
.text_size(text_size)
|
||||||
|
.text_color(primary_foreground)
|
||||||
|
.child(action.label.clone())
|
||||||
|
.on_click(move |_, _window, cx| {
|
||||||
|
(action.handler.clone())(cx);
|
||||||
|
toasts.update(cx, |toasts, cx| {
|
||||||
|
toasts.dismiss(id, cx);
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.id(("toast-close", entry.id))
|
.id(("toast-close", id))
|
||||||
.flex_none()
|
.flex_none()
|
||||||
|
.ml_4()
|
||||||
.cursor_pointer()
|
.cursor_pointer()
|
||||||
.p_0p5()
|
.p_0p5()
|
||||||
.rounded(px(4.))
|
.rounded(px(4.))
|
||||||
@@ -293,16 +341,8 @@ impl RenderOnce for ToastsView {
|
|||||||
)
|
)
|
||||||
.on_click(move |_, _window, cx| {
|
.on_click(move |_, _window, cx| {
|
||||||
toasts.update(cx, |toasts, cx| {
|
toasts.update(cx, |toasts, cx| {
|
||||||
toasts.dismiss(entry.id, cx);
|
toasts.dismiss(id, cx);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.with_animation(
|
|
||||||
("toast-enter", entry.id),
|
|
||||||
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
|
||||||
|toast, delta| toast.mt(px(8. * (1. - delta))),
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
AnyWindowHandle, AppContext as _, InteractiveElement as _, IntoElement, Modifiers,
|
||||||
|
ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point,
|
||||||
|
px,
|
||||||
|
};
|
||||||
|
use gpui_native_core::alert::{Alert, AlertSeverity};
|
||||||
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
severity: AlertSeverity,
|
||||||
|
dismissed: Rc<RefCell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Harness {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||||
|
let dismissed = self.dismissed.clone();
|
||||||
|
div().size_full().p_4().child(
|
||||||
|
div().debug_selector(|| "test-alert".to_string()).child(
|
||||||
|
Alert::new("test-alert")
|
||||||
|
.title("Heads up")
|
||||||
|
.severity(self.severity)
|
||||||
|
.message("Secondary line.")
|
||||||
|
.on_dismiss(move |_| *dismissed.borrow_mut() = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alert stretches across the top of the content area; its close button
|
||||||
|
/// sits in the banner's top-right corner.
|
||||||
|
fn alert_window(
|
||||||
|
severity: AlertSeverity,
|
||||||
|
) -> (VisualTestContext, AnyWindowHandle, Rc<RefCell<bool>>) {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let dismissed = Rc::new(RefCell::new(false));
|
||||||
|
let harness_dismissed = dismissed.clone();
|
||||||
|
let window = cx.add_window(move |_, _| Harness {
|
||||||
|
severity,
|
||||||
|
dismissed: harness_dismissed,
|
||||||
|
});
|
||||||
|
let window = window.into();
|
||||||
|
cx.update_window(window, |_, window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
(
|
||||||
|
VisualTestContext::from_window(window, &cx),
|
||||||
|
window,
|
||||||
|
dismissed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A banner with only a message paints, so the title stays optional.
|
||||||
|
#[test]
|
||||||
|
fn a_message_only_alert_renders() {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let window = cx.add_window(|_, _| MessageOnlyHarness);
|
||||||
|
let window = window.into();
|
||||||
|
let mut cx = VisualTestContext::from_window(window, &cx);
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
assert!(cx.debug_bounds("message-only-alert").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MessageOnlyHarness;
|
||||||
|
|
||||||
|
impl Render for MessageOnlyHarness {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||||
|
div().size_full().p_4().child(
|
||||||
|
div()
|
||||||
|
.debug_selector(|| "message-only-alert".to_string())
|
||||||
|
.child(Alert::new("message-only").message("Only line.")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking the close button is how the user acts on the alert; the banner
|
||||||
|
/// reports the intent instead of hiding itself.
|
||||||
|
#[test]
|
||||||
|
fn closing_the_alert_reports_dismissal() {
|
||||||
|
let (mut cx, _window, dismissed) = alert_window(AlertSeverity::Danger);
|
||||||
|
|
||||||
|
let bounds = cx.debug_bounds("test-alert").expect("alert rendered");
|
||||||
|
cx.simulate_click(
|
||||||
|
point(bounds.right() - px(14.), bounds.top() + px(14.)),
|
||||||
|
Modifiers::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(*dismissed.borrow(), "the close button reported dismissal");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every severity paints a banner.
|
||||||
|
#[test]
|
||||||
|
fn every_severity_renders_a_banner() {
|
||||||
|
for severity in [
|
||||||
|
AlertSeverity::Info,
|
||||||
|
AlertSeverity::Success,
|
||||||
|
AlertSeverity::Warning,
|
||||||
|
AlertSeverity::Danger,
|
||||||
|
] {
|
||||||
|
let (mut cx, _window, _dismissed) = alert_window(severity);
|
||||||
|
assert!(
|
||||||
|
cx.debug_bounds("test-alert").is_some(),
|
||||||
|
"{severity:?} alert rendered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gpui::{AppContext as _, TestAppContext};
|
||||||
|
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts};
|
||||||
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
|
fn alerts_stack() -> (TestAppContext, gpui::Entity<Alerts>) {
|
||||||
|
let cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let alerts = cx.update(|cx| cx.new(|_| Alerts::default()));
|
||||||
|
(cx, alerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pushed_alerts_stack_up_and_dismiss_by_id() {
|
||||||
|
let (cx, alerts) = alerts_stack();
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
alerts.update(cx, |alerts, cx| {
|
||||||
|
alerts.push(
|
||||||
|
Alert::new("a").title("First").severity(AlertSeverity::Info),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
alerts.push(
|
||||||
|
Alert::new("b")
|
||||||
|
.title("Second")
|
||||||
|
.severity(AlertSeverity::Warning),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 2);
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
alerts.update(cx, |alerts, cx| alerts.dismiss(0, cx));
|
||||||
|
});
|
||||||
|
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 1);
|
||||||
|
|
||||||
|
// Duplicate ids are fine: the stack re-keys each entry to its own id.
|
||||||
|
cx.update(|cx| {
|
||||||
|
alerts.update(cx, |alerts, cx| {
|
||||||
|
alerts.push(Alert::new("b").title("Third"), cx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_close_button_routes_through_the_store_and_runs_the_handler() {
|
||||||
|
let (cx, alerts) = alerts_stack();
|
||||||
|
let dismissed = Rc::new(std::cell::RefCell::new(false));
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
let handler = dismissed.clone();
|
||||||
|
alerts.update(cx, |alerts, cx| {
|
||||||
|
alerts.push(
|
||||||
|
Alert::new("a").title("Sync failed").on_dismiss(move |_| {
|
||||||
|
*handler.borrow_mut() = true;
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pushed alert's close button calls the store's dismissal, which
|
||||||
|
// then runs the handler.
|
||||||
|
cx.update(|cx| {
|
||||||
|
alerts.update(cx, |alerts, cx| alerts.dismiss(0, cx));
|
||||||
|
});
|
||||||
|
assert!(*dismissed.borrow());
|
||||||
|
assert_eq!(cx.update(|cx| alerts.read(cx).count()), 0);
|
||||||
|
}
|
||||||
+94
-21
@@ -3,7 +3,7 @@ use std::rc::Rc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::{AppContext as _, TestAppContext};
|
use gpui::{AppContext as _, TestAppContext};
|
||||||
use gpui_native_core::toast::{Toast, ToastSeverity, Toasts};
|
use gpui_native_core::toast::{Toast, ToastAction, Toasts};
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
fn toasts_store() -> (TestAppContext, gpui::Entity<Toasts>) {
|
fn toasts_store() -> (TestAppContext, gpui::Entity<Toasts>) {
|
||||||
@@ -14,23 +14,49 @@ fn toasts_store() -> (TestAppContext, gpui::Entity<Toasts>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn push_raises_a_toast_and_the_timer_dismisses_it() {
|
fn push_raises_a_toast_and_the_timer_fades_it_out() {
|
||||||
let (cx, toasts) = toasts_store();
|
let (cx, toasts) = toasts_store();
|
||||||
|
|
||||||
let id = cx.update(|cx| {
|
let id = cx.update(|cx| toasts.update(cx, |toasts, cx| toasts.push(Toast::new("Saved"), cx)));
|
||||||
toasts.update(cx, |toasts, cx| {
|
|
||||||
toasts.push(Toast::new("Saved").severity(ToastSeverity::Success), cx)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1);
|
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1);
|
||||||
|
|
||||||
// The default lifetime is five seconds.
|
// The default lifetime is five seconds; the fade-out follows it.
|
||||||
cx.executor().advance_clock(Toast::DURATION);
|
cx.executor().advance_clock(Toast::DURATION);
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
|
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1, "fading out");
|
||||||
|
cx.executor()
|
||||||
|
.advance_clock(std::time::Duration::from_secs(1));
|
||||||
|
cx.run_until_parked();
|
||||||
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
|
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
|
||||||
let _ = id;
|
let _ = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pushing_replaces_the_toast_on_screen() {
|
||||||
|
let (cx, toasts) = toasts_store();
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
toasts.update(cx, |toasts, cx| toasts.push(Toast::new("first"), cx));
|
||||||
|
});
|
||||||
|
cx.update(|cx| {
|
||||||
|
toasts.update(cx, |toasts, cx| toasts.push(Toast::new("second"), cx));
|
||||||
|
});
|
||||||
|
|
||||||
|
// One showing, one fading out: never two current toasts.
|
||||||
|
let (current, total) = cx.update(|cx| {
|
||||||
|
toasts.read_with(cx, |toasts, _| {
|
||||||
|
(toasts.current().map(|entry| entry.id), toasts.count())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert_eq!(current, Some(1));
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
|
||||||
|
cx.executor()
|
||||||
|
.advance_clock(std::time::Duration::from_secs(6));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sticky_toasts_ignore_the_timer_until_dismissed() {
|
fn sticky_toasts_ignore_the_timer_until_dismissed() {
|
||||||
let (cx, toasts) = toasts_store();
|
let (cx, toasts) = toasts_store();
|
||||||
@@ -48,42 +74,89 @@ fn sticky_toasts_ignore_the_timer_until_dismissed() {
|
|||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx));
|
toasts.update(cx, |toasts, cx| toasts.dismiss(id, cx));
|
||||||
});
|
});
|
||||||
|
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 1, "fading out");
|
||||||
|
cx.executor()
|
||||||
|
.advance_clock(std::time::Duration::from_secs(1));
|
||||||
|
cx.run_until_parked();
|
||||||
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
|
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dismiss_runs_the_handler_and_clear_removes_everything() {
|
fn dismiss_runs_the_handler_only_after_the_fade_completes() {
|
||||||
let (cx, toasts) = toasts_store();
|
let (cx, toasts) = toasts_store();
|
||||||
let dismissed = Rc::new(std::cell::RefCell::new(Vec::<&'static str>::new()));
|
let dismissed = Rc::new(std::cell::RefCell::new(Vec::<&'static str>::new()));
|
||||||
|
|
||||||
let ids = cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
toasts.update(cx, |toasts, cx| {
|
|
||||||
let first = dismissed.clone();
|
let first = dismissed.clone();
|
||||||
let second = dismissed.clone();
|
toasts.update(cx, |toasts, cx| {
|
||||||
vec![
|
|
||||||
toasts.push(
|
toasts.push(
|
||||||
Toast::new("first").on_dismiss(move |_| record(&first, "first")),
|
Toast::new("first").on_dismiss(move |_| record(&first, "first")),
|
||||||
cx,
|
cx,
|
||||||
),
|
);
|
||||||
toasts.push(
|
});
|
||||||
Toast::new("second").on_dismiss(move |_| record(&second, "second")),
|
|
||||||
cx,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
toasts.update(cx, |toasts, cx| toasts.dismiss(ids[0], cx));
|
toasts.update(cx, |toasts, cx| toasts.dismiss(0, cx));
|
||||||
});
|
});
|
||||||
|
assert!(
|
||||||
|
dismissed.borrow().is_empty(),
|
||||||
|
"the handler waits out the fade"
|
||||||
|
);
|
||||||
|
cx.executor()
|
||||||
|
.advance_clock(std::time::Duration::from_secs(1));
|
||||||
|
cx.run_until_parked();
|
||||||
assert_eq!(*dismissed.borrow(), vec!["first"]);
|
assert_eq!(*dismissed.borrow(), vec!["first"]);
|
||||||
|
|
||||||
cx.update(|cx| {
|
cx.update(|cx| {
|
||||||
toasts.update(cx, |toasts, cx| toasts.clear(cx));
|
toasts.update(cx, |toasts, cx| toasts.clear(cx));
|
||||||
});
|
});
|
||||||
|
cx.executor()
|
||||||
|
.advance_clock(std::time::Duration::from_secs(1));
|
||||||
|
cx.run_until_parked();
|
||||||
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
|
assert_eq!(cx.update(|cx| toasts.read(cx).count()), 0);
|
||||||
|
|
||||||
fn record(sink: &Rc<RefCell<Vec<&'static str>>>, what: &'static str) {
|
fn record(sink: &Rc<RefCell<Vec<&'static str>>>, what: &'static str) {
|
||||||
sink.borrow_mut().push(what);
|
sink.borrow_mut().push(what);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dismissing by any other path never runs the action: only a click on the
|
||||||
|
/// action itself does (the view wires the two together).
|
||||||
|
#[test]
|
||||||
|
fn dismissal_does_not_run_the_action() {
|
||||||
|
let (cx, toasts) = toasts_store();
|
||||||
|
let ran = Rc::new(std::cell::RefCell::new(false));
|
||||||
|
let dismissed = Rc::new(std::cell::RefCell::new(false));
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
let action_ran = ran.clone();
|
||||||
|
let toast_dismissed = dismissed.clone();
|
||||||
|
toasts.update(cx, |toasts, cx| {
|
||||||
|
toasts.push(
|
||||||
|
Toast::new("Draft deleted")
|
||||||
|
.action(ToastAction::new("Undo", move |_| {
|
||||||
|
*action_ran.borrow_mut() = true;
|
||||||
|
}))
|
||||||
|
.on_dismiss(move |_| *toast_dismissed.borrow_mut() = true),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.update(|cx| {
|
||||||
|
toasts.update(cx, |toasts, cx| toasts.dismiss(0, cx));
|
||||||
|
});
|
||||||
|
assert!(!*ran.borrow(), "dismiss is not the action");
|
||||||
|
cx.executor()
|
||||||
|
.advance_clock(std::time::Duration::from_secs(1));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert!(
|
||||||
|
*dismissed.borrow(),
|
||||||
|
"on_dismiss still reports the dismissal"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
*dismissed.borrow(),
|
||||||
|
"on_dismiss still reports the dismissal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+4
-3
@@ -156,7 +156,7 @@ The set stays role-minimal and grows one concept at a time, driven by the unit t
|
|||||||
|
|
||||||
- ~~**Overlay scrim + shadow/elevation tokens**~~ — ✅ landed with unit 11 (2026-10-29): `colors.overlay` (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), `Radius.overlay` (10 pt), `Elevations.popover`/`dialog` (transcribed NSPopover-class shadows, builder → `Vec<BoxShadow>`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog.
|
- ~~**Overlay scrim + shadow/elevation tokens**~~ — ✅ landed with unit 11 (2026-10-29): `colors.overlay` (translucent-black scrim, denser in dark; TOML-overridable; no platform states one), `Radius.overlay` (10 pt), `Elevations.popover`/`dialog` (transcribed NSPopover-class shadows, builder → `Vec<BoxShadow>`), and `Motion.overlay` (200 ms enter transition). Popover consumes elevation + motion; the scrim waits for Dialog.
|
||||||
- ~~**Scrollbar thumb token + metrics**~~ — ✅ landed with the ScrollArea unit (2026-10-29): `colors.scrollbar_thumb` (translucent over content: black in light, light gray in dark; TOML-overridable) and `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt).
|
- ~~**Scrollbar thumb token + metrics**~~ — ✅ landed with the ScrollArea unit (2026-10-29): `colors.scrollbar_thumb` (translucent over content: black in light, light gray in dark; TOML-overridable) and `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt).
|
||||||
- ~~**`success` / `warning` roles**~~ — ✅ landed with Toast (2026-10-29): `colors.success`/`warning` (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived `Ramps.success`/`warning` via the OKLCH engine, matching the destructive treatment. **`info` joined them (2026-10-31)** — the fourth severity, same treatment (`colors.info` + foreground + `Ramps.info`, neutral sky blue, no platform states it, TOML `info =`). Trigger: `ToastSeverity::Info` was painting from the primary ramp, so a warm platform accent (GNOME red, Omarchy red themes) made Info read as Danger; the token keeps Info calm however the accent turns out. Severity tokens now use the same picked value for the ramp and the token (the success/warning tokens used to re-pick with the system slot dropped, diverging from their own ramps if a platform ever stated one).
|
- ~~**`success` / `warning` roles**~~ — ✅ landed with Toast (2026-10-29): `colors.success`/`warning` (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived `Ramps.success`/`warning` via the OKLCH engine, matching the destructive treatment. **`info` joined them (2026-10-31)** — the fourth severity, same treatment (`colors.info` + foreground + `Ramps.info`, neutral sky blue, no platform states it, TOML `info =`). Trigger: the Alert's Info severity was painting from the primary ramp, so a warm platform accent (GNOME red, Omarchy red themes) made Info read as Danger; the token keeps Info calm however the accent turns out. Severity tokens now use the same picked value for the ramp and the token (the success/warning tokens used to re-pick with the system slot dropped, diverging from their own ramps if a platform ever stated one).
|
||||||
- **Motion tokens** — after the reverted framework attempt (2026-10-30), motion stays minimal: `Motion.overlay` (200 ms) is the only token. Components reach for gpui's own easings and `SpringConfig` inline (D21); a token is added only when a third component shares the same value (D16). TOML `[motion]` overrides and per-platform motion presets wait for a real need. ~~The reduce-motion forwarding fix~~ ✅ landed (pass a, 2026-10-30): `reapply` calls `cx.set_reduce_motion(resolved.accessibility.reduce_motion)`, so gpui's animation elements snap under the OS setting.
|
- **Motion tokens** — after the reverted framework attempt (2026-10-30), motion stays minimal: `Motion.overlay` (200 ms) is the only token. Components reach for gpui's own easings and `SpringConfig` inline (D21); a token is added only when a third component shares the same value (D16). TOML `[motion]` overrides and per-platform motion presets wait for a real need. ~~The reduce-motion forwarding fix~~ ✅ landed (pass a, 2026-10-30): `reapply` calls `cx.set_reduce_motion(resolved.accessibility.reduce_motion)`, so gpui's animation elements snap under the OS setting.
|
||||||
- **`knob` / control-fill role** — only if Slider (unit 7) confirms the Switch's borrowed roles (`primary_foreground` thumb, `border` off-track) as a second consumer; until then no expansion.
|
- **`knob` / control-fill role** — only if Slider (unit 7) confirms the Switch's borrowed roles (`primary_foreground` thumb, `border` off-track) as a second consumer; until then no expansion.
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move
|
|||||||
| 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred. ✅ Fixed (2026-10-30): the caller's size refinement now lands on the outer wrapper too — previously it applied to the pane alone, so a percentage-sized pane resolved against an auto-height wrapper and a window-filling scroll area (the gallery's root shell) never actually scrolled; a headless repro of the gallery shell locks the behavior |
|
| 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred. ✅ Fixed (2026-10-30): the caller's size refinement now lands on the outer wrapper too — previously it applied to the pane alone, so a percentage-sized pane resolved against an auto-height wrapper and a window-filling scroll area (the gallery's root shell) never actually scrolled; a headless repro of the gallery shell locks the behavior |
|
||||||
| 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling |
|
| 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling |
|
||||||
| 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app |
|
| 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app |
|
||||||
| 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. Toast: `Toasts` store entity (push/dismiss/clear + auto-dismiss timers on the GPUI executor, `Toast::DURATION` 5 s, zero = sticky) + `ToastsView` deferred stack (top-right, `Radius.overlay` + popover elevation, severity icon from the new success/warning ramps, close button, `on_dismiss` windowless handler). Toast landed the deferred `success`/`warning` tokens (§5.2). Sheet dropped from the roadmap |
|
| 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29), Alert + Toast rework ✅ (2026-10-31) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. **The 2026-10-31 rework split the old Toast in two (D23):** `Alert` (`alert` feature) — a persistent, fully controlled callout banner rendered inline where the app places it: severity icon (`Ramps.<sev>.base` on a `surface` + `border` + `Radius.overlay` card with the popover shadow, halved in light mode so it doesn't pop on white), title and message both optional (a message-only banner paints it as the primary line), close button only when `on_dismiss` is set, reporting intent instead of hiding anything. A pushed, macOS-style stacking `Alerts`/`AlertsView` (collapse-slivers + hover-unroll + slide animations) was tried and rolled back the same day — inline-only won. The reworked `Toast` (`toast` feature) — **one at a time** (pushing replaces the one on screen), a succinct severity-less pill at the bottom center: optional icon + one-line truncating content + one optional action (`ToastAction`, the suggested-action pill) + an always-present close button, laid out as content, small gap, action, large gap, close; fades in and out (`Motion.overlay` each — exits via a store-held exiting slot whose handler runs once the fade completes). The store keeps GPUI-executor timers (`Toast::DURATION` 5 s, zero = sticky). Toast landed the deferred `success`/`warning` tokens (§5.2); Alert is the `info` token's consumer (D22). Sheet dropped from the roadmap |
|
||||||
| 14 | core + theme | Motion (per component) | ✅ Landed in five passes (2026-10-30), replacing the reverted same-day framework attempt — no token tower, no `motion` module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component: (a) theme — `reapply` forwards `reduce_motion` via `cx.set_reduce_motion`, so gpui snaps every animation under the OS setting; (b) Switch — the thumb absolute-positions in the track and slides on `with_spring` (inline `SpringConfig::new(500, 35, 1)`, `Pixels` target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast — each toast slides up 8 px into its stack slot on `with_animation` at `Motion.overlay` with `ease_out_quint` (margin-driven, so only the entering toast moves; keyed per entry so the clock survives unrelated stack re-renders); (d) Dialog — the scrim enters with the panel (same timing and easing, its own `dialog-scrim-enter` animation id so the clocks start in the same frame); (e) Progress — the indeterminate sweep caps at 30 fps via `with_max_fps`. Overlay enter fades already existed from units 11/12. Exits and hover washes stay deferred — they were the complexity sink of the reverted attempt (§ deferred list) |
|
| 14 | core + theme | Motion (per component) | ✅ Landed in five passes (2026-10-30), replacing the reverted same-day framework attempt — no token tower, no `motion` module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component: (a) theme — `reapply` forwards `reduce_motion` via `cx.set_reduce_motion`, so gpui snaps every animation under the OS setting; (b) Switch — the thumb absolute-positions in the track and slides on `with_spring` (inline `SpringConfig::new(500, 35, 1)`, `Pixels` target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast — each toast slides up 8 px into its stack slot on `with_animation` at `Motion.overlay` with `ease_out_quint` (margin-driven, so only the entering toast moves; keyed per entry so the clock survives unrelated stack re-renders); (d) Dialog — the scrim enters with the panel (same timing and easing, its own `dialog-scrim-enter` animation id so the clocks start in the same frame); (e) Progress — the indeterminate sweep caps at 30 fps via `with_max_fps`. Overlay enter fades already existed from units 11/12. Exits and hover washes stay deferred — they were the complexity sink of the reverted attempt (§ deferred list) |
|
||||||
| then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), Sidebar ✅ (2026-10-30) | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option<usize>` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred. Tabs: a controlled strip only — `selected: usize` + `on_change`; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with `aria_selected`; no wrap, no segmented-pill variant yet. Sidebar: List with section headers — `SidebarEntry::header(label)` / `row(label).icon().detail().disabled()`; headers are never selectable, selection indices address the flat entry list; same selection/keyboard/paint rules as List; roles List/ListItem (a sidebar is a list to accesskit, like macOS AXList); selection stays filled when unfocused (native) |
|
| then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), Sidebar ✅ (2026-10-30) | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option<usize>` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred. Tabs: a controlled strip only — `selected: usize` + `on_change`; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with `aria_selected`; no wrap, no segmented-pill variant yet. Sidebar: List with section headers — `SidebarEntry::header(label)` / `row(label).icon().detail().disabled()`; headers are never selectable, selection indices address the flat entry list; same selection/keyboard/paint rules as List; roles List/ListItem (a sidebar is a list to accesskit, like macOS AXList); selection stays filled when unfocused (native) |
|
||||||
| then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. |
|
| then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. |
|
||||||
@@ -314,7 +314,8 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio
|
|||||||
| D19 | System icons via semantic `IconRole`s with per-platform resolvers (SF Symbols / Segoe Fluent Icons / freedesktop) and a bundled Lucide fallback; platform glyph sets are used at runtime only, never bundled |
|
| D19 | System icons via semantic `IconRole`s with per-platform resolvers (SF Symbols / Segoe Fluent Icons / freedesktop) and a bundled Lucide fallback; platform glyph sets are used at runtime only, never bundled |
|
||||||
| D20 | Components land one element at a time behind a review gate: API sketch → implementation → tests → gallery page → review → next. bezel and gpuikit are the API-design references for these units; conventions like controlled state, one activation path, semantic states, and a11y labels are adopted, simplified. gpui-kit is no longer consulted — judged over-engineered for our needs |
|
| D20 | Components land one element at a time behind a review gate: API sketch → implementation → tests → gallery page → review → next. bezel and gpuikit are the API-design references for these units; conventions like controlled state, one activation path, semantic states, and a11y labels are adopted, simplified. gpui-kit is no longer consulted — judged over-engineered for our needs |
|
||||||
| D21 | Motion is per component and inline: each component uses gpui's own animation/spring primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its file; the theme owns only the durations components actually share (today `Motion.overlay`), growing per the role-minimal rule. No shared motion module, no exit ghosts, no hover-wash machinery — exits and washes stay out until a genuinely simple approach exists. Reduce motion is gpui-side: forward the OS flag once in `reapply` and every primitive snaps. Supersedes the reverted 2026-10-30 framework attempt (which proved the substrate but over-built the plumbing) |
|
| D21 | Motion is per component and inline: each component uses gpui's own animation/spring primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its file; the theme owns only the durations components actually share (today `Motion.overlay`), growing per the role-minimal rule. No shared motion module, no exit ghosts, no hover-wash machinery — exits and washes stay out until a genuinely simple approach exists. Reduce motion is gpui-side: forward the OS flag once in `reapply` and every primitive snaps. Supersedes the reverted 2026-10-30 framework attempt (which proved the substrate but over-built the plumbing) |
|
||||||
| D22 | The color set does not expand along gpuikit's `Themeable` shape (evaluated 2026-10-31): their per-component token layers (`button_*`, `input_*`) are passthroughs of their own primitives — a drift-prone parallel layer our role-plus-ramp model replaces with OKLCH-derived hover/pressed shades; depth variants (`surface_secondary`), the badge palette, and `fg_disabled` wait for real consumers (Badge, nested elevation, a theme that can't express disabled by opacity). The one adoption: `info` as the fourth severity — `ToastSeverity::Info` had a live consumer painting from the primary ramp, which collides with Danger under warm accents. Domain palettes (editor diff colors) may revisit gpuikit's `ThemeExtension` idea when `crates/editor` lands |
|
| D22 | The color set does not expand along gpuikit's `Themeable` shape (evaluated 2026-10-31): their per-component token layers (`button_*`, `input_*`) are passthroughs of their own primitives — a drift-prone parallel layer our role-plus-ramp model replaces with OKLCH-derived hover/pressed shades; depth variants (`surface_secondary`), the badge palette, and `fg_disabled` wait for real consumers (Badge, nested elevation, a theme that can't express disabled by opacity). The one adoption: `info` as the fourth severity — the Alert's Info severity had a live consumer painting from the primary ramp, which collides with Danger under warm accents. Domain palettes (editor diff colors) may revisit gpuikit's `ThemeExtension` idea when `crates/editor` lands |
|
||||||
|
| D23 | The old Toast split into `Alert` + `Toast` (2026-10-31): one component can't be both a persistent page callout and a fleeting bottom-center nudge — different lifetime (persists until acted on vs auto-dismisses), different placement (inline in flow vs floating bottom center), different content shape (title + message + severity tint vs one line + icon + action). Alert is fully controlled — the app renders it while the condition holds and its close button reports intent; Toast keeps the store + timers. Toast drops severity and titles: Adwaita toasts are single-line nudges, and severity belongs to the banners that stay on the page |
|
||||||
|
|
||||||
Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19.
|
Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19.
|
||||||
|
|
||||||
|
|||||||
+114
-32
@@ -6,6 +6,7 @@ use gpui::{
|
|||||||
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
|
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
|
||||||
Styled as _, Window, WindowBounds, WindowOptions, div, px, size,
|
Styled as _, Window, WindowBounds, WindowOptions, div, px, size,
|
||||||
};
|
};
|
||||||
|
use gpui_native_core::alert::{Alert, AlertSeverity, Alerts, AlertsView};
|
||||||
use gpui_native_core::avatar::Avatar;
|
use gpui_native_core::avatar::Avatar;
|
||||||
use gpui_native_core::button::{Button, ButtonVariant};
|
use gpui_native_core::button::{Button, ButtonVariant};
|
||||||
use gpui_native_core::checkbox::Checkbox;
|
use gpui_native_core::checkbox::Checkbox;
|
||||||
@@ -20,7 +21,7 @@ use gpui_native_core::radio::{Radio, RadioGroup};
|
|||||||
use gpui_native_core::scroll_area::ScrollArea;
|
use gpui_native_core::scroll_area::ScrollArea;
|
||||||
use gpui_native_core::slider::Slider;
|
use gpui_native_core::slider::Slider;
|
||||||
use gpui_native_core::switch::Switch;
|
use gpui_native_core::switch::Switch;
|
||||||
use gpui_native_core::toast::{Toast, ToastSeverity, Toasts, ToastsView};
|
use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView};
|
||||||
use gpui_native_core::toggle::Toggle;
|
use gpui_native_core::toggle::Toggle;
|
||||||
use gpui_native_core::tooltip::Tooltip;
|
use gpui_native_core::tooltip::Tooltip;
|
||||||
use gpui_native_data::list::{List, ListItem};
|
use gpui_native_data::list::{List, ListItem};
|
||||||
@@ -43,6 +44,8 @@ struct Gallery {
|
|||||||
menu_open: bool,
|
menu_open: bool,
|
||||||
dialog_open: bool,
|
dialog_open: bool,
|
||||||
alert_open: bool,
|
alert_open: bool,
|
||||||
|
update_alert_dismissed: bool,
|
||||||
|
alerts: Entity<Alerts>,
|
||||||
toasts: Entity<Toasts>,
|
toasts: Entity<Toasts>,
|
||||||
scroll: gpui::ScrollHandle,
|
scroll: gpui::ScrollHandle,
|
||||||
list_selected: Option<usize>,
|
list_selected: Option<usize>,
|
||||||
@@ -550,21 +553,113 @@ impl Render for Gallery {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.child(section_label(
|
.child(section_label(
|
||||||
"toast · native ref — NSUserNotification stack, WinUI InfoBar, Adwaita in-app notifications",
|
"alert · static callout — Adwaita InfoBar, WinUI InfoBar",
|
||||||
|
colors,
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
Alert::new("gallery-alert-update").title("Update available")
|
||||||
|
.message("Version 2.4.0 is ready to install.")
|
||||||
|
.severity(AlertSeverity::Info),
|
||||||
|
)
|
||||||
|
.when(!self.update_alert_dismissed, |section| {
|
||||||
|
section.child(
|
||||||
|
Alert::new("gallery-alert-sync")
|
||||||
|
.title("Sync failed")
|
||||||
|
.message("Will retry when you're back online.")
|
||||||
|
.severity(AlertSeverity::Danger)
|
||||||
|
.on_dismiss({
|
||||||
|
let gallery = gallery.clone();
|
||||||
|
move |cx| {
|
||||||
|
gallery.update(cx, |gallery, cx| {
|
||||||
|
gallery.update_alert_dismissed = true;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(section_label(
|
||||||
|
"alert stack · pushed, persistent — macOS notification stack",
|
||||||
|
colors,
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
div().flex().gap_2().children([
|
||||||
|
{
|
||||||
|
let alerts = self.alerts.clone();
|
||||||
|
Button::new("gallery-alert-push-info")
|
||||||
|
.label("Push info")
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
alerts.update(cx, |alerts, cx| {
|
||||||
|
alerts.push(
|
||||||
|
Alert::new("pushed-info")
|
||||||
|
.title("Backed up")
|
||||||
|
.message("Everything is in sync.")
|
||||||
|
.severity(AlertSeverity::Info),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let alerts = self.alerts.clone();
|
||||||
|
Button::new("gallery-alert-push-warning")
|
||||||
|
.label("Push warning")
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
alerts.update(cx, |alerts, cx| {
|
||||||
|
alerts.push(
|
||||||
|
Alert::new("pushed-warning")
|
||||||
|
.title("Storage almost full")
|
||||||
|
.message("1.2 GB remaining on this device.")
|
||||||
|
.severity(AlertSeverity::Warning),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let alerts = self.alerts.clone();
|
||||||
|
Button::new("gallery-alert-clear")
|
||||||
|
.label("Clear all")
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
alerts.update(cx, |alerts, cx| alerts.clear(cx));
|
||||||
|
})
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(section_label(
|
||||||
|
"toast · temporary message, one at a time — Adwaita Toast, macOS banner",
|
||||||
colors,
|
colors,
|
||||||
))
|
))
|
||||||
.child(
|
.child(
|
||||||
div().flex().gap_2().children([
|
div().flex().gap_2().children([
|
||||||
{
|
{
|
||||||
let toasts = self.toasts.clone();
|
let toasts = self.toasts.clone();
|
||||||
Button::new("gallery-toast-info")
|
Button::new("gallery-toast-undo")
|
||||||
.label("Info")
|
.label("Deleted (with undo)")
|
||||||
.variant(ButtonVariant::Secondary)
|
.variant(ButtonVariant::Secondary)
|
||||||
.on_click(move |_, _, cx| {
|
.on_click(move |_, _, cx| {
|
||||||
toasts.update(cx, |toasts, cx| {
|
toasts.update(cx, |toasts, cx| {
|
||||||
toasts.push(
|
toasts.push(
|
||||||
Toast::new("New version available")
|
Toast::new("Draft deleted")
|
||||||
.message("Reload to update to 2.4.0."),
|
.action(ToastAction::new("Undo", |cx| {
|
||||||
|
// A real app restores the draft here.
|
||||||
|
let _ = cx;
|
||||||
|
})),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -572,16 +667,14 @@ impl Render for Gallery {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
let toasts = self.toasts.clone();
|
let toasts = self.toasts.clone();
|
||||||
Button::new("gallery-toast-success")
|
Button::new("gallery-toast-saved")
|
||||||
.label("Success")
|
.label("Saved (with icon)")
|
||||||
.variant(ButtonVariant::Secondary)
|
.variant(ButtonVariant::Secondary)
|
||||||
.on_click(move |_, _, cx| {
|
.on_click(move |_, _, cx| {
|
||||||
toasts.update(cx, |toasts, cx| {
|
toasts.update(cx, |toasts, cx| {
|
||||||
toasts.push(
|
toasts.push(
|
||||||
Toast::new("Workspace saved")
|
Toast::new("Workspace saved")
|
||||||
.message("All changes written to disk.")
|
.icon(IconRole::Check),
|
||||||
.severity(ToastSeverity::Success)
|
|
||||||
.no_icon(),
|
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -589,31 +682,14 @@ impl Render for Gallery {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
let toasts = self.toasts.clone();
|
let toasts = self.toasts.clone();
|
||||||
Button::new("gallery-toast-warning")
|
Button::new("gallery-toast-connection")
|
||||||
.label("Warning")
|
.label("Connection lost (sticky)")
|
||||||
.variant(ButtonVariant::Secondary)
|
.variant(ButtonVariant::Secondary)
|
||||||
.on_click(move |_, _, cx| {
|
.on_click(move |_, _, cx| {
|
||||||
toasts.update(cx, |toasts, cx| {
|
toasts.update(cx, |toasts, cx| {
|
||||||
toasts.push(
|
toasts.push(
|
||||||
Toast::new("Disk almost full")
|
Toast::new("Connection lost")
|
||||||
.message("1.2 GB remaining.")
|
.icon(IconRole::Warning)
|
||||||
.severity(ToastSeverity::Warning),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
let toasts = self.toasts.clone();
|
|
||||||
Button::new("gallery-toast-danger")
|
|
||||||
.label("Danger (sticky)")
|
|
||||||
.variant(ButtonVariant::Destructive)
|
|
||||||
.on_click(move |_, _, cx| {
|
|
||||||
toasts.update(cx, |toasts, cx| {
|
|
||||||
toasts.push(
|
|
||||||
Toast::new("Sync failed")
|
|
||||||
.message("Will retry when online.")
|
|
||||||
.severity(ToastSeverity::Danger)
|
|
||||||
.duration(Duration::ZERO),
|
.duration(Duration::ZERO),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
@@ -1081,6 +1157,7 @@ impl Render for Gallery {
|
|||||||
)
|
)
|
||||||
.child(dialog)
|
.child(dialog)
|
||||||
.child(alert)
|
.child(alert)
|
||||||
|
.child(AlertsView::new("gallery-alerts", self.alerts.clone()))
|
||||||
.child(ToastsView::new("gallery-toasts", self.toasts.clone()))
|
.child(ToastsView::new("gallery-toasts", self.toasts.clone()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1162,9 +1239,12 @@ fn main() {
|
|||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
let toasts = cx.new(|_| Toasts::default());
|
let toasts = cx.new(|_| Toasts::default());
|
||||||
|
let alerts = cx.new(|_| Alerts::default());
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
// Toast pushes and dismissals repaint the stack.
|
// Toast pushes and dismissals repaint the stack.
|
||||||
cx.observe(&toasts, |_, _, cx| cx.notify()).detach();
|
cx.observe(&toasts, |_, _, cx| cx.notify()).detach();
|
||||||
|
// Alert pushes and dismissals repaint it.
|
||||||
|
cx.observe(&alerts, |_, _, cx| cx.notify()).detach();
|
||||||
Gallery {
|
Gallery {
|
||||||
button_clicks: 0,
|
button_clicks: 0,
|
||||||
checkbox_checked: false,
|
checkbox_checked: false,
|
||||||
@@ -1177,6 +1257,8 @@ fn main() {
|
|||||||
menu_open: false,
|
menu_open: false,
|
||||||
dialog_open: false,
|
dialog_open: false,
|
||||||
alert_open: false,
|
alert_open: false,
|
||||||
|
update_alert_dismissed: false,
|
||||||
|
alerts,
|
||||||
toasts,
|
toasts,
|
||||||
scroll: gpui::ScrollHandle::new(),
|
scroll: gpui::ScrollHandle::new(),
|
||||||
list_selected: None,
|
list_selected: None,
|
||||||
|
|||||||
Reference in New Issue
Block a user