feat: Add global toasts and annotation copy feedback

Create a reusable ToastHost/ToastGlobal rendered as an overlay, and use
it to show a toast after copying an annotation. Adjust modal backdrop
click handling so toasts don’t close the modal, add a simple color mix
helper for toast styling, and remove the unused task table priority
color helper.
This commit is contained in:
Ignacio Perez
2025-12-31 14:59:51 -03:00
parent 37760cda74
commit 7cf431d761
8 changed files with 196 additions and 28 deletions
+8
View File
@@ -3,6 +3,7 @@ use std::collections::HashMap;
use gpui::prelude::*; use gpui::prelude::*;
use crate::{ use crate::{
components::toast::{ToastGlobal, ToastHost},
keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack}, keymap::{Command, CommandDispatcher, ContextId, FocusTarget, KeyChord, KeymapStack},
models::{FilterState, ProjectTree}, models::{FilterState, ProjectTree},
task::{self, TaskOverview, TaskService, TaskSummary}, task::{self, TaskOverview, TaskService, TaskSummary},
@@ -25,6 +26,7 @@ pub(super) struct App {
pub(super) status_bar: gpui::Entity<StatusBar>, pub(super) status_bar: gpui::Entity<StatusBar>,
pub(super) task_table: gpui::Entity<TaskTable>, pub(super) task_table: gpui::Entity<TaskTable>,
pub(super) task_detail_modal: gpui::Entity<TaskDetailModal>, pub(super) task_detail_modal: gpui::Entity<TaskDetailModal>,
pub(super) toast_host: gpui::Entity<ToastHost>,
pub(super) task_service: TaskService, pub(super) task_service: TaskService,
pub(super) tasks: Vec<TaskSummary>, pub(super) tasks: Vec<TaskSummary>,
pub(super) focus_before_modal: FocusTarget, pub(super) focus_before_modal: FocusTarget,
@@ -71,6 +73,7 @@ impl gpui::Render for App {
self.sidebar.clone(), self.sidebar.clone(),
self.task_table.clone(), self.task_table.clone(),
self.status_bar.clone(), self.status_bar.clone(),
self.toast_host.clone(),
on_root_key_down, on_root_key_down,
on_sidebar_mouse_down, on_sidebar_mouse_down,
on_table_mouse_down, on_table_mouse_down,
@@ -367,6 +370,10 @@ impl App {
.collect(); .collect();
let status_bar = cx.new(|cx| StatusBar::new(cx)); let status_bar = cx.new(|cx| StatusBar::new(cx));
let toast_host = cx.new(|cx| ToastHost::new(cx));
cx.set_global(ToastGlobal {
host: toast_host.clone(),
});
let sidebar = let sidebar =
cx.new(|cx| Sidebar::new(project_tree, tags, filter_state.clone(), cx)); cx.new(|cx| Sidebar::new(project_tree, tags, filter_state.clone(), cx));
@@ -396,6 +403,7 @@ impl App {
status_bar: status_bar.clone(), status_bar: status_bar.clone(),
task_table, task_table,
task_detail_modal, task_detail_modal,
toast_host,
task_service, task_service,
tasks: task_summaries, tasks: task_summaries,
focus_before_modal: FocusTarget::Table, focus_before_modal: FocusTarget::Table,
+1
View File
@@ -6,3 +6,4 @@ pub mod label;
pub mod list; pub mod list;
pub mod modal; pub mod modal;
pub mod panel; pub mod panel;
pub mod toast;
+11 -11
View File
@@ -48,13 +48,7 @@ impl ModalFrame {
impl gpui::RenderOnce for ModalFrame { impl gpui::RenderOnce for ModalFrame {
fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement { fn render(self, _window: &mut gpui::Window, _cx: &mut gpui::App) -> impl gpui::IntoElement {
let mut panel_wrap = gpui::div().child(self.panel); let panel_wrap = gpui::div().child(self.panel);
if let Some(handler) = self.on_close {
panel_wrap = panel_wrap.on_mouse_down_out(move |event, window, app| {
(handler)(event, window, app);
});
}
let root = gpui::div() let root = gpui::div()
.id(self.id) .id(self.id)
@@ -65,14 +59,20 @@ impl gpui::RenderOnce for ModalFrame {
.occlude() .occlude()
.track_focus(&self.focus); .track_focus(&self.focus);
root.child( let mut backdrop = gpui::div()
gpui::div()
.size_full() .size_full()
.bg(self.backdrop) .bg(self.backdrop)
.absolute() .absolute()
.top_0() .top_0()
.left_0(), .left_0();
)
if let Some(handler) = self.on_close {
backdrop = backdrop.on_mouse_down(gpui::MouseButton::Left, move |event, window, app| {
(handler)(event, window, app);
});
}
root.child(backdrop)
.child( .child(
gpui::div() gpui::div()
.size_full() .size_full()
+144
View File
@@ -0,0 +1,144 @@
use gpui::prelude::*;
use crate::components::label::Label;
use crate::theme::{ActiveTheme, Theme};
use crate::ui::mix_color;
pub struct ToastGlobal {
pub host: gpui::Entity<ToastHost>,
}
impl gpui::Global for ToastGlobal {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Info,
Success,
Error,
}
struct Toast {
id: u64,
kind: ToastKind,
message: String,
}
pub struct ToastHost {
toasts: Vec<Toast>,
next_id: u64,
}
impl ToastHost {
pub fn new(_cx: &mut gpui::Context<Self>) -> Self {
Self {
toasts: Vec::new(),
next_id: 1,
}
}
pub fn push(&mut self, kind: ToastKind, message: impl Into<String>, cx: &mut Context<Self>) {
let toast = Toast {
id: self.next_id,
kind,
message: message.into(),
};
self.next_id += 1;
self.toasts.push(toast);
cx.notify();
}
fn dismiss(&mut self, id: u64, cx: &mut Context<Self>) {
self.toasts.retain(|toast| toast.id != id);
cx.notify();
}
fn kind_color(kind: ToastKind, theme: &Theme) -> gpui::Rgba {
match kind {
ToastKind::Info => theme.info,
ToastKind::Success => theme.success,
ToastKind::Error => theme.error,
}
}
}
impl gpui::Render for ToastHost {
fn render(
&mut self,
_window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
if self.toasts.is_empty() {
return gpui::div().into_any_element();
}
let theme = cx.theme();
let items = self.toasts.iter().map(|toast| {
let toast_id = toast.id;
let accent = Self::kind_color(toast.kind, theme);
let close_button = gpui::div()
.flex()
.items_center()
.justify_center()
.w(gpui::rems(1.5))
.h(gpui::rems(1.5))
.text_sm()
.text_color(theme.muted)
.cursor_pointer()
.hover(|s| s.text_color(theme.accent))
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(move |host, _event, _window, cx| {
host.dismiss(toast_id, cx);
}),
)
.child(Label::new("X"));
let background = mix_color(theme.background, accent, 0.2);
let border = Theme::alpha(accent, 0.45);
gpui::div()
.flex()
.items_center()
.gap_3()
.px_5()
.py_3()
.occlude()
.border_1()
.border_color(border)
.bg(background)
.rounded_md()
.shadow_lg()
.child(
gpui::div()
.w(gpui::px(6.0))
.h_full()
.bg(Theme::alpha(accent, 0.9))
.rounded_md(),
)
.child(
gpui::div().flex_1().min_w(gpui::rems(18.0)).child(
Label::new(toast.message.clone())
.text_sm()
.text_color(theme.foreground)
.font_weight(gpui::FontWeight::MEDIUM),
),
)
.child(close_button)
.into_any_element()
});
gpui::div()
.id("toast-host")
.absolute()
.top(gpui::rems(1.0))
.right(gpui::rems(1.0))
.flex()
.flex_col()
.gap_2()
.children(items)
.into_any_element()
}
}
+11 -1
View File
@@ -1,7 +1,7 @@
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{Pixels, px, rems}; use gpui::{Pixels, px, rems};
use crate::theme::Theme; use crate::theme::{Color, Theme};
pub const CARD_RADIUS: Pixels = px(6.0); pub const CARD_RADIUS: Pixels = px(6.0);
pub const CARD_PADDING: Pixels = px(8.0); pub const CARD_PADDING: Pixels = px(8.0);
@@ -170,3 +170,13 @@ pub fn text_button_style(div: gpui::Div, theme: &Theme) -> gpui::Div {
.cursor_pointer() .cursor_pointer()
.hover(|s| s.text_color(theme.accent)) .hover(|s| s.text_color(theme.accent))
} }
pub fn mix_color(base: Color, tint: Color, amount: f32) -> Color {
let amount = amount.clamp(0.0, 1.0);
gpui::Rgba {
r: base.r + (tint.r - base.r) * amount,
g: base.g + (tint.g - base.g) * amount,
b: base.b + (tint.b - base.b) * amount,
a: 1.0,
}
}
+12
View File
@@ -1,5 +1,6 @@
use gpui::prelude::*; use gpui::prelude::*;
use crate::components::toast::ToastHost;
use crate::keymap::FocusTarget; use crate::keymap::FocusTarget;
use crate::theme::Theme; use crate::theme::Theme;
use crate::ui::{CARD_PADDING, CARD_RADIUS, ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH}; use crate::ui::{CARD_PADDING, CARD_RADIUS, ROOT_PADDING, SECTION_GAP, SIDEBAR_WIDTH};
@@ -14,6 +15,7 @@ pub fn render_app_layout(
sidebar: gpui::Entity<Sidebar>, sidebar: gpui::Entity<Sidebar>,
task_table: gpui::Entity<TaskTable>, task_table: gpui::Entity<TaskTable>,
status_bar: gpui::Entity<StatusBar>, status_bar: gpui::Entity<StatusBar>,
toast_host: gpui::Entity<ToastHost>,
on_root_key_down: impl Fn(&gpui::KeyDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, on_root_key_down: impl Fn(&gpui::KeyDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
on_sidebar_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, on_sidebar_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
on_table_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static, on_table_mouse_down: impl Fn(&gpui::MouseDownEvent, &mut gpui::Window, &mut gpui::App) + 'static,
@@ -87,5 +89,15 @@ pub fn render_app_layout(
root = root.child(modal); root = root.child(modal);
} }
let toast_layer = gpui::div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(toast_host);
root = root.child(toast_layer);
root.into_any_element() root.into_any_element()
} }
+5 -1
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::components::label::Label; use crate::components::label::Label;
use crate::components::modal::ModalFrame; use crate::components::modal::ModalFrame;
use crate::components::toast::{ToastGlobal, ToastKind};
use crate::task::model::TaskLinkVm; use crate::task::model::TaskLinkVm;
use crate::task::{self, TaskDetailState, TaskDetailVm}; use crate::task::{self, TaskDetailState, TaskDetailVm};
use crate::theme::{ActiveTheme, Theme}; use crate::theme::{ActiveTheme, Theme};
@@ -632,7 +633,6 @@ where
.map(|(index, annotation)| { .map(|(index, annotation)| {
let timestamp = annotation.entry.format(DATE_TIME_FORMAT).to_string(); let timestamp = annotation.entry.format(DATE_TIME_FORMAT).to_string();
let content_for_copy = annotation.content.clone(); let content_for_copy = annotation.content.clone();
let copy_action = gpui::div() let copy_action = gpui::div()
.text_xs() .text_xs()
.text_color(theme.muted) .text_color(theme.muted)
@@ -642,6 +642,10 @@ where
app.write_to_clipboard(gpui::ClipboardItem::new_string( app.write_to_clipboard(gpui::ClipboardItem::new_string(
content_for_copy.clone(), content_for_copy.clone(),
)); ));
let toast_host = app.global::<ToastGlobal>().host.clone();
app.update_entity(&toast_host, |host, cx| {
host.push(ToastKind::Info, "Annotation copied", cx);
});
}) })
.child(Label::new("Copy")); .child(Label::new("Copy"));
-11
View File
@@ -592,17 +592,6 @@ impl TaskTable {
} }
} }
fn priority_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
let theme = cx.theme();
match row.priority.as_str() {
"High" => theme.high,
"Medium" => theme.medium,
"Low" => theme.low,
_ => theme.foreground,
}
}
fn due_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color { fn due_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
let theme = cx.theme(); let theme = cx.theme();