Skip to main content

liora_components/
message.rs

1use crate::motion::pop_in;
2use gpui::{
3    App, AsyncApp, Context, Entity, ForegroundExecutor, Global, IntoElement, Render, SharedString,
4    Window, div, prelude::*, px,
5};
6use liora_core::{Config, push_passive_portal};
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9use liora_theme::Theme;
10use std::{cell::RefCell, time::Duration};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum MessageType {
14    Info,
15    Success,
16    Warning,
17    Error,
18}
19
20#[derive(Clone)]
21pub struct MessageItem {
22    pub id: usize,
23    pub content: SharedString,
24    pub msg_type: MessageType,
25}
26
27pub struct MessageManager {
28    messages: Vec<MessageItem>,
29    next_id: usize,
30}
31
32pub struct MessageManagerGlobal(pub Entity<MessageManager>);
33impl Global for MessageManagerGlobal {}
34
35#[derive(Clone)]
36struct ToastDispatcherGlobal {
37    app: AsyncApp,
38    foreground_executor: ForegroundExecutor,
39}
40impl Global for ToastDispatcherGlobal {}
41
42thread_local! {
43    static TOAST_DISPATCHER: RefCell<Option<ToastDispatcherGlobal>> = const { RefCell::new(None) };
44}
45
46impl ToastDispatcherGlobal {
47    fn new(cx: &mut App) -> Self {
48        Self {
49            app: cx.to_async(),
50            foreground_executor: cx.foreground_executor().clone(),
51        }
52    }
53
54    fn show(&self, content: SharedString, msg_type: MessageType) {
55        let app = self.app.clone();
56        self.foreground_executor
57            .spawn(async move {
58                app.update(|cx| {
59                    show_message(content, msg_type, cx);
60                    cx.refresh_windows();
61                });
62            })
63            .detach();
64    }
65}
66
67impl MessageManager {
68    pub fn new() -> Self {
69        Self {
70            messages: vec![],
71            next_id: 0,
72        }
73    }
74
75    pub fn init(cx: &mut App) {
76        if !cx.has_global::<MessageManagerGlobal>() {
77            let manager = cx.new(|_| Self::new());
78            cx.set_global(MessageManagerGlobal(manager));
79        }
80        if !cx.has_global::<ToastDispatcherGlobal>() {
81            let dispatcher = ToastDispatcherGlobal::new(cx);
82            cx.set_global(dispatcher);
83        }
84        TOAST_DISPATCHER.with(|dispatcher| {
85            *dispatcher.borrow_mut() = Some(cx.global::<ToastDispatcherGlobal>().clone());
86        });
87    }
88
89    pub fn show(content: impl Into<SharedString>, msg_type: MessageType, cx: &mut App) {
90        Self::init(cx);
91        let manager = cx.global::<MessageManagerGlobal>().0.clone();
92        let content = content.into();
93
94        manager.update(cx, |this, cx| {
95            let id = this.next_id;
96            this.messages.push(MessageItem {
97                id,
98                content: content.clone(),
99                msg_type,
100            });
101            this.next_id += 1;
102
103            let async_cx = cx.to_async();
104            let executor = cx.background_executor().clone();
105            cx.foreground_executor()
106                .spawn(async move {
107                    executor.timer(Duration::from_secs(3)).await;
108                    async_cx.update(|cx| {
109                        if cx.has_global::<MessageManagerGlobal>() {
110                            let manager = cx.global::<MessageManagerGlobal>().0.clone();
111                            manager.update(cx, |this, cx| {
112                                this.messages.retain(|m| m.id != id);
113                                cx.notify();
114                            });
115                        }
116                    });
117                })
118                .detach();
119
120            cx.notify();
121        });
122    }
123}
124
125impl Render for MessageManager {
126    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
127        let messages = self.messages.clone();
128        if messages.is_empty() {
129            return div();
130        }
131
132        let theme = cx.global::<Config>().theme.clone();
133
134        div()
135            .absolute()
136            .top_8()
137            .left_0()
138            .w_full()
139            .flex()
140            .flex_col()
141            .items_center()
142            .gap_2()
143            .children(messages.into_iter().map(|msg| {
144                let style = message_style(&theme, msg.msg_type);
145
146                pop_in(
147                    ("liora-message", msg.id),
148                    div()
149                        .bg(style.bg)
150                        .border_1()
151                        .border_color(style.border)
152                        .px_4()
153                        .py_2()
154                        .rounded(px(theme.radius.md))
155                        .shadow_lg()
156                        .flex()
157                        .flex_row()
158                        .items_center()
159                        .gap_2()
160                        .child(Icon::new(style.icon).size(px(16.0)).color(style.fg))
161                        .child(
162                            div()
163                                .text_color(style.fg)
164                                .text_size(px(theme.font_size.sm))
165                                .child(msg.content),
166                        ),
167                )
168            }))
169    }
170}
171
172pub fn show_message(content: impl Into<SharedString>, msg_type: MessageType, cx: &mut App) {
173    MessageManager::show(content, msg_type, cx);
174}
175
176pub fn toast(content: impl Into<SharedString>, msg_type: MessageType, cx: &mut App) {
177    show_message(content, msg_type, cx);
178}
179
180pub fn toast_info(content: impl Into<SharedString>, cx: &mut App) {
181    toast(content, MessageType::Info, cx);
182}
183
184pub fn toast_success(content: impl Into<SharedString>, cx: &mut App) {
185    toast(content, MessageType::Success, cx);
186}
187
188pub fn toast_warning(content: impl Into<SharedString>, cx: &mut App) {
189    toast(content, MessageType::Warning, cx);
190}
191
192pub fn toast_error(content: impl Into<SharedString>, cx: &mut App) {
193    toast(content, MessageType::Error, cx);
194}
195
196pub fn dispatch_toast(content: impl Into<SharedString>, msg_type: MessageType) {
197    let content = content.into();
198    TOAST_DISPATCHER.with(|dispatcher| {
199        let Some(dispatcher) = dispatcher.borrow().clone() else {
200            panic!("toast macros require MessageManager::init(cx) before use");
201        };
202        dispatcher.show(content, msg_type);
203    });
204}
205
206pub fn dispatch_toast_info(content: impl Into<SharedString>) {
207    dispatch_toast(content, MessageType::Info);
208}
209
210pub fn dispatch_toast_success(content: impl Into<SharedString>) {
211    dispatch_toast(content, MessageType::Success);
212}
213
214pub fn dispatch_toast_warning(content: impl Into<SharedString>) {
215    dispatch_toast(content, MessageType::Warning);
216}
217
218pub fn dispatch_toast_error(content: impl Into<SharedString>) {
219    dispatch_toast(content, MessageType::Error);
220}
221
222pub fn render_messages(cx: &mut App) {
223    if cx.has_global::<MessageManagerGlobal>() {
224        let manager = cx.global::<MessageManagerGlobal>().0.clone();
225        if !manager.read(cx).messages.is_empty() {
226            push_passive_portal(move |_window, _cx| manager.clone().into_any_element(), cx);
227        }
228    }
229}
230
231struct MessageStyle {
232    bg: gpui::Hsla,
233    fg: gpui::Hsla,
234    border: gpui::Hsla,
235    icon: IconName,
236}
237
238fn message_style(theme: &Theme, msg_type: MessageType) -> MessageStyle {
239    let (family, icon) = match msg_type {
240        MessageType::Info => (&theme.info, IconName::Info),
241        MessageType::Success => (&theme.success, IconName::Check),
242        MessageType::Warning => (&theme.warning, IconName::TriangleAlert),
243        MessageType::Error => (&theme.danger, IconName::CircleX),
244    };
245
246    MessageStyle {
247        bg: family.base,
248        fg: theme.neutral.card,
249        border: family.base,
250        icon,
251    }
252}
253
254#[doc(hidden)]
255#[macro_export]
256macro_rules! __liora_toast_dispatch {
257    ($dispatch:path, $fmt:literal $(, $($arg:tt)+)?) => {{
258        $dispatch(format!($fmt $(, $($arg)+)?));
259    }};
260    ($dispatch:path, $message:expr $(,)?) => {{
261        $dispatch($message);
262    }};
263}
264
265#[macro_export]
266macro_rules! toast_info {
267    ($($arg:tt)*) => {{
268        $crate::__liora_toast_dispatch!($crate::dispatch_toast_info, $($arg)*);
269    }};
270}
271
272#[macro_export]
273macro_rules! toast_success {
274    ($($arg:tt)*) => {{
275        $crate::__liora_toast_dispatch!($crate::dispatch_toast_success, $($arg)*);
276    }};
277}
278
279#[macro_export]
280macro_rules! toast_warning {
281    ($($arg:tt)*) => {{
282        $crate::__liora_toast_dispatch!($crate::dispatch_toast_warning, $($arg)*);
283    }};
284}
285
286#[macro_export]
287macro_rules! toast_error {
288    ($($arg:tt)*) => {{
289        $crate::__liora_toast_dispatch!($crate::dispatch_toast_error, $($arg)*);
290    }};
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn message_styles_use_solid_type_background_and_inverted_foreground() {
299        let theme = Theme::light();
300        let cases = [
301            (MessageType::Info, theme.info.base),
302            (MessageType::Success, theme.success.base),
303            (MessageType::Warning, theme.warning.base),
304            (MessageType::Error, theme.danger.base),
305        ];
306
307        for (message_type, expected_bg) in cases {
308            let message = message_style(&theme, message_type);
309
310            assert_eq!(message.bg, expected_bg);
311            assert_eq!(message.border, expected_bg);
312            assert_eq!(message.fg, theme.neutral.card);
313        }
314    }
315
316    #[test]
317    fn toast_helpers_map_to_message_types() {
318        let source = include_str!("message.rs")
319            .split("#[cfg(test)]")
320            .next()
321            .unwrap();
322
323        assert!(source.contains("pub fn toast_info"));
324        assert!(source.contains("pub fn toast_success"));
325        assert!(source.contains("pub fn toast_warning"));
326        assert!(source.contains("pub fn toast_error"));
327        assert!(source.contains("MessageType::Info"));
328        assert!(source.contains("MessageType::Success"));
329        assert!(source.contains("MessageType::Warning"));
330        assert!(source.contains("MessageType::Error"));
331    }
332
333    #[test]
334    fn toast_macros_support_format_arguments() {
335        let source = include_str!("message.rs")
336            .split("#[cfg(test)]")
337            .next()
338            .unwrap();
339
340        assert!(source.contains("macro_rules! toast_info"));
341        assert!(source.contains("format!("));
342        assert!(source.contains("dispatch_toast_info"));
343        assert!(source.contains("dispatch_toast_success"));
344        assert!(source.contains("dispatch_toast_warning"));
345        assert!(source.contains("dispatch_toast_error"));
346    }
347
348    #[test]
349    #[should_panic(expected = "toast macros require MessageManager::init(cx) before use")]
350    fn toast_macro_expands_format_arguments() {
351        crate::toast_info!("{left}, {right}", left = "left", right = "right");
352    }
353
354    #[test]
355    fn messages_use_liora_motion() {
356        let source = include_str!("message.rs")
357            .split("#[cfg(test)]")
358            .next()
359            .unwrap();
360
361        assert!(source.contains("pop_in("));
362        assert!(source.contains("liora-message"));
363    }
364}