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}