331 lines
9.5 KiB
Rust
331 lines
9.5 KiB
Rust
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 _, Size};
|
|
|
|
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 shadows = theme.elevations.popover.shadows();
|
|
let metrics = theme.sizes.resolve(Size::Medium);
|
|
let icon_size = metrics.icon_size;
|
|
let title_size = metrics.text_size;
|
|
let caption_size = metrics.caption_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(title_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| {
|
|
let color = if self.title.is_some() {
|
|
muted_foreground
|
|
} else {
|
|
foreground
|
|
};
|
|
alert.child(
|
|
div()
|
|
.text_size(caption_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(theme.radius.sm)
|
|
.hover(|style| style.bg(muted))
|
|
.child(
|
|
icon(IconRole::Close)
|
|
.size(theme.sizes.small.dense_icon_size)
|
|
.color(theme.colors.muted_foreground),
|
|
)
|
|
.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)))),
|
|
)
|
|
})),
|
|
)
|
|
}
|
|
}
|