summaryrefslogtreecommitdiff
path: root/src/ui.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-06 17:41:44 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-06 17:42:38 +0300
commit4001fd2ec8ff05a982da18744b8cbde9df3ab530 (patch)
treef8dbf984cff9a4b025e86481d3f70c89322bd79f /src/ui.rs
parent048d8a29195d580014b24ee3fc7e89ac6bdfee8a (diff)
feat: improve toast ergonomics
refs #486
Diffstat (limited to 'src/ui.rs')
-rw-r--r--src/ui.rs164
1 files changed, 154 insertions, 10 deletions
diff --git a/src/ui.rs b/src/ui.rs
index bbdc0b04..bdae81c9 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -51,7 +51,7 @@ pub(crate) use self::scrollbar::{
use self::settings::render_settings_overlay;
use self::sidebar::{render_sidebar, render_sidebar_collapsed};
use self::status::{
- render_config_diagnostic, render_copy_feedback, render_toast_notification,
+ copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_toast_notification,
toast_notification_rect,
};
use self::tabs::render_tab_bar;
@@ -254,7 +254,14 @@ fn compute_view_internal(
let toast_hit_area = app
.toast
.as_ref()
- .map(|toast| toast_notification_rect(terminal_area, toast, app.config_diagnostic.is_some()))
+ .map(|toast| {
+ toast_notification_rect(
+ area,
+ toast,
+ app.config_diagnostic.is_some(),
+ toast.position.unwrap_or(app.toast_config.herdr.position),
+ )
+ })
.unwrap_or_default();
app.view = crate::app::ViewState {
@@ -412,6 +419,7 @@ fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect)
render_config_diagnostic(frame, terminal_area, message, &app.palette);
}
let mut copy_feedback_offset = u16::from(has_config_diagnostic);
+ let mut toast_rect = None;
if let Some(toast) = &app.toast {
if app.view.layout == ViewLayout::Mobile {
render_mobile_toast_banner(
@@ -424,18 +432,25 @@ fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect)
} else {
render_toast_notification(
frame,
- terminal_area,
+ frame.area(),
toast,
has_config_diagnostic,
+ toast.position.unwrap_or(app.toast_config.herdr.position),
&app.palette,
);
+ toast_rect = Some(toast_notification_rect(
+ frame.area(),
+ toast,
+ has_config_diagnostic,
+ toast.position.unwrap_or(app.toast_config.herdr.position),
+ ));
+ }
+ if app.view.layout == ViewLayout::Mobile {
+ toast_rect = Some(mobile_toast_banner_rect(
+ frame.area(),
+ has_config_diagnostic,
+ ));
}
- copy_feedback_offset =
- copy_feedback_offset.saturating_add(if app.view.layout == ViewLayout::Mobile {
- 1
- } else {
- toast_notification_rect(terminal_area, toast, has_config_diagnostic).height
- });
}
if let Some(feedback) = &app.copy_feedback {
let area = if app.view.layout == ViewLayout::Mobile {
@@ -443,10 +458,48 @@ fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect)
} else {
terminal_area
};
- render_copy_feedback(frame, area, feedback, copy_feedback_offset, &app.palette);
+ if let Some(toast_rect) = toast_rect {
+ copy_feedback_offset = copy_feedback_offset_for_toast(
+ area,
+ feedback,
+ copy_feedback_offset,
+ app.toast_config.clipboard.position,
+ toast_rect,
+ );
+ }
+ render_copy_feedback(
+ frame,
+ area,
+ feedback,
+ copy_feedback_offset,
+ app.toast_config.clipboard.position,
+ &app.palette,
+ );
}
}
+fn copy_feedback_offset_for_toast(
+ area: Rect,
+ feedback: &crate::app::state::CopyFeedback,
+ base_offset: u16,
+ position: crate::config::ToastClipboardPosition,
+ toast_rect: Rect,
+) -> u16 {
+ let feedback_rect = copy_feedback_rect(area, feedback, base_offset, position);
+ if rects_overlap(feedback_rect, toast_rect) {
+ base_offset.saturating_add(toast_rect.height)
+ } else {
+ base_offset
+ }
+}
+
+fn rects_overlap(a: Rect, b: Rect) -> bool {
+ a.x < b.x.saturating_add(b.width)
+ && b.x < a.x.saturating_add(a.width)
+ && a.y < b.y.saturating_add(b.height)
+ && b.y < a.y.saturating_add(a.height)
+}
+
fn dim_background(frame: &mut Frame, area: Rect) {
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
@@ -480,6 +533,50 @@ mod tests {
use ratatui::style::Color;
use ratatui::{backend::TestBackend, Terminal};
+ #[test]
+ fn copy_feedback_offset_only_increases_when_toast_rect_overlaps() {
+ let area = Rect::new(0, 0, 80, 24);
+ let feedback = crate::app::state::CopyFeedback {
+ message: "copied to clipboard".into(),
+ };
+ let toast = crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::Finished,
+ title: "pi finished".into(),
+ context: "workspace ยท 1".into(),
+ position: None,
+ target: None,
+ };
+
+ let bottom_right_toast = toast_notification_rect(
+ area,
+ &toast,
+ false,
+ crate::config::ToastHerdrPosition::BottomRight,
+ );
+ assert_eq!(
+ copy_feedback_offset_for_toast(
+ area,
+ &feedback,
+ 0,
+ crate::config::ToastClipboardPosition::TopCenter,
+ bottom_right_toast,
+ ),
+ 0
+ );
+
+ let bottom_center_toast = Rect::new(28, 21, 24, 3);
+ assert_eq!(
+ copy_feedback_offset_for_toast(
+ area,
+ &feedback,
+ 0,
+ crate::config::ToastClipboardPosition::BottomCenter,
+ bottom_center_toast,
+ ),
+ bottom_center_toast.height
+ );
+ }
+
#[tokio::test]
async fn focused_pane_cursor_wins_during_terminal_render() {
let mut app = crate::app::state::AppState::test_new();
@@ -542,6 +639,53 @@ mod tests {
}
#[test]
+ fn desktop_toast_hit_area_uses_full_frame_not_terminal_area() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+ app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft;
+ app.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::Finished,
+ title: "pi finished".into(),
+ context: "one".into(),
+ position: None,
+ target: None,
+ });
+
+ compute_view(&mut app, Rect::new(0, 0, 100, 20));
+
+ assert_eq!(app.view.layout, ViewLayout::Desktop);
+ assert!(app.view.terminal_area.x > 0);
+ assert_eq!(app.view.toast_hit_area.x, 0);
+ assert_eq!(app.view.toast_hit_area.y, 0);
+ }
+
+ #[test]
+ fn desktop_toast_hit_area_still_offsets_for_config_diagnostic() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.workspaces = vec![Workspace::test_new("one")];
+ app.active = Some(0);
+ app.selected = 0;
+ app.mode = Mode::Terminal;
+ app.config_diagnostic = Some("config warning".into());
+ app.toast_config.herdr.position = crate::config::ToastHerdrPosition::TopLeft;
+ app.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::Finished,
+ title: "pi finished".into(),
+ context: "one".into(),
+ position: None,
+ target: None,
+ });
+
+ compute_view(&mut app, Rect::new(0, 0, 100, 20));
+
+ assert_eq!(app.view.toast_hit_area.x, 0);
+ assert_eq!(app.view.toast_hit_area.y, 1);
+ }
+
+ #[test]
fn configured_mobile_width_threshold_controls_layout_switch() {
let mut app = crate::app::state::AppState::test_new();
app.workspaces = vec![Workspace::test_new("one")];