diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 15:49:07 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 15:50:06 +0300 |
| commit | 1b980eaa9d4cbc050b6b81525e4b6b72ae853be5 (patch) | |
| tree | 48afc3595519c9e229e22528ecc229a66f9bd874 /src | |
| parent | 57c880f0ebaa5011abc90f4e2fd4f84fc072ce35 (diff) | |
feat: show clipboard copy feedback
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api.rs | 8 | ||||
| -rw-r--r-- | src/app/mod.rs | 43 | ||||
| -rw-r--r-- | src/app/runtime.rs | 10 | ||||
| -rw-r--r-- | src/app/state.rs | 7 | ||||
| -rw-r--r-- | src/server/headless.rs | 25 | ||||
| -rw-r--r-- | src/ui.rs | 20 | ||||
| -rw-r--r-- | src/ui/status.rs | 62 |
7 files changed, 167 insertions, 8 deletions
diff --git a/src/app/api.rs b/src/app/api.rs index c51a1bb0..2488ce40 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -16,6 +16,7 @@ impl App { pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { if let AppEvent::ClipboardWrite { content } = ev { crate::selection::write_osc52_bytes(&content); + self.show_clipboard_feedback(); return; } @@ -177,6 +178,13 @@ impl App { self.shutdown_detached_terminal_runtimes(); } + pub(crate) fn show_clipboard_feedback(&mut self) { + self.state.copy_feedback = Some(crate::app::state::CopyFeedback { + message: "copied to clipboard".to_string(), + }); + self.copy_feedback_deadline = Some(Instant::now() + super::COPY_FEEDBACK_DURATION); + } + fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { for temp_file in &overlay.temp_files { let _ = std::fs::remove_file(temp_file); diff --git a/src/app/mod.rs b/src/app/mod.rs index 534a81d8..1c221be8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -38,6 +38,7 @@ const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5); const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); const PANE_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350); const PANE_COPY_HIGHLIGHT_DURATION: Duration = Duration::from_millis(500); +const COPY_FEEDBACK_DURATION: Duration = Duration::from_secs(2); use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, @@ -93,6 +94,7 @@ pub struct App { pub(crate) last_terminal_size: Option<(u16, u16)>, pub(crate) config_diagnostic_deadline: Option<Instant>, pub(crate) toast_deadline: Option<Instant>, + pub(crate) copy_feedback_deadline: Option<Instant>, pub(crate) last_git_remote_status_refresh: Instant, pub(crate) git_refresh_in_flight: bool, pub(crate) last_sidebar_divider_click: Option<Instant>, @@ -467,6 +469,7 @@ impl App { update_dismissed: false, config_diagnostic, toast: None, + copy_feedback: None, outer_terminal_focus: None, prefix_code, prefix_mods, @@ -547,6 +550,7 @@ impl App { Self { config_diagnostic_deadline: None, toast_deadline: None, + copy_feedback_deadline: None, state, terminal_runtimes: restored_terminal_runtimes, event_tx, @@ -1465,6 +1469,45 @@ mod tests { } #[test] + fn clipboard_write_event_shows_feedback_toast() { + let mut app = test_app(); + + app.handle_internal_event(AppEvent::ClipboardWrite { + content: b"copied".to_vec(), + }); + + assert!(app.state.toast.is_none()); + let feedback = app.state.copy_feedback.as_ref().expect("copy feedback"); + assert_eq!(feedback.message, "copied to clipboard"); + assert!(app.copy_feedback_deadline.is_some()); + } + + #[test] + fn clipboard_feedback_does_not_replace_notification_toast() { + let mut app = test_app(); + app.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "pi needs attention".to_string(), + context: "background · 2".to_string(), + target: None, + }); + let original_toast = app.state.toast.clone(); + + app.handle_internal_event(AppEvent::ClipboardWrite { + content: b"copied".to_vec(), + }); + + assert_eq!(app.state.toast, original_toast); + assert_eq!( + app.state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); + } + + #[test] fn internal_event_drain_limits_work_per_tick() { let mut app = test_app(); for i in 0..=APP_EVENT_DRAIN_LIMIT { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index bbef7444..2e1f4d81 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -163,6 +163,15 @@ impl App { } if self + .copy_feedback_deadline + .is_some_and(|deadline| now >= deadline) + { + self.copy_feedback_deadline = None; + self.state.copy_feedback = None; + changed = true; + } + + if self .next_animation_tick .is_some_and(|deadline| now >= deadline) { @@ -431,6 +440,7 @@ impl App { include_resize_poll.then_some(self.next_resize_poll), self.config_diagnostic_deadline, self.toast_deadline, + self.copy_feedback_deadline, self.next_animation_tick, self.git_refresh_deadline(), self.next_auto_update_check, diff --git a/src/app/state.rs b/src/app/state.rs index 5e6aeafd..de1db052 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1001,6 +1001,11 @@ pub struct ToastNotification { pub target: Option<ToastTarget>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopyFeedback { + pub message: String, +} + pub struct ReleaseNotesState { pub version: String, pub body: String, @@ -1103,6 +1108,7 @@ pub struct AppState { pub update_dismissed: bool, pub config_diagnostic: Option<String>, pub toast: Option<ToastNotification>, + pub copy_feedback: Option<CopyFeedback>, /// Last reported focus state for the outer terminal hosting herdr. /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option<bool>, @@ -1410,6 +1416,7 @@ impl AppState { update_dismissed: false, config_diagnostic: None, toast: None, + copy_feedback: None, outer_terminal_focus: None, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, diff --git a/src/server/headless.rs b/src/server/headless.rs index 4d9a2cc5..e65b741c 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1150,8 +1150,8 @@ impl HeadlessServer { let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice()); self.send_to_client(client_id, ServerMessage::Clipboard { data }); } - // ClipboardWrite doesn't change visual state — no render needed. - false + self.app.show_clipboard_feedback(); + true } AppEvent::StateChanged { pane_id, agent, .. } => { // Capture toast before handling. @@ -2428,6 +2428,16 @@ impl HeadlessServer { if self .app + .copy_feedback_deadline + .is_some_and(|deadline| now >= deadline) + { + self.app.copy_feedback_deadline = None; + self.app.state.copy_feedback = None; + changed = true; + } + + if self + .app .next_animation_tick .is_some_and(|deadline| now >= deadline) { @@ -4544,7 +4554,16 @@ next_tab = "" content: b"test".to_vec(), }); - assert!(!changed); + assert!(changed); + assert_eq!( + server + .app + .state + .copy_feedback + .as_ref() + .map(|feedback| feedback.message.as_str()), + Some("copied to clipboard") + ); match read_server_message( foreground_control_rx .recv_timeout(Duration::from_millis(100)) @@ -50,7 +50,10 @@ 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_toast_notification, toast_notification_rect}; +use self::status::{ + render_config_diagnostic, render_copy_feedback, render_toast_notification, + toast_notification_rect, +}; use self::tabs::render_tab_bar; pub(crate) use self::{ dialogs::{ @@ -405,6 +408,7 @@ fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) if let Some(message) = &app.config_diagnostic { render_config_diagnostic(frame, terminal_area, message, &app.palette); } + let mut copy_feedback_offset = u16::from(has_config_diagnostic); if let Some(toast) = &app.toast { if app.view.layout == ViewLayout::Mobile { render_mobile_toast_banner( @@ -423,6 +427,20 @@ fn render_notifications(app: &AppState, frame: &mut Frame, terminal_area: Rect) &app.palette, ); } + 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 { + frame.area() + } else { + terminal_area + }; + render_copy_feedback(frame, area, feedback, copy_feedback_offset, &app.palette); } } diff --git a/src/ui/status.rs b/src/ui/status.rs index 28f5d17b..8ec4f392 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -8,10 +8,23 @@ use ratatui::{ use super::widgets::panel_contrast_fg; use crate::{ - app::state::{Palette, ToastKind, ToastNotification}, + app::state::{CopyFeedback, Palette, ToastKind, ToastNotification}, detect::AgentState, }; +pub(crate) fn copy_feedback_rect(area: Rect, feedback: &CopyFeedback, offset_rows: u16) -> Rect { + if area.width == 0 || area.height == 0 { + return Rect::default(); + } + + let content_width = feedback.message.len() as u16 + 4; + let width = content_width.min(area.width); + let height = 3u16.min(area.height); + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height.saturating_sub(height + offset_rows); + Rect::new(x, y, width, height) +} + pub(crate) fn toast_notification_rect( area: Rect, toast: &ToastNotification, @@ -19,7 +32,8 @@ pub(crate) fn toast_notification_rect( ) -> Rect { let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; let width = content_width.saturating_add(2).min(area.width); - let height = 4u16.min(area.height); + let content_height = if toast.context.is_empty() { 1 } else { 2 }; + let height = (content_height + 2).min(area.height); let x = area.x + area.width.saturating_sub(width); let y = area.y + area @@ -50,7 +64,7 @@ pub(super) fn render_toast_notification( let inner = block.inner(toast_area); frame.render_widget(block, toast_area); - if inner.height < 2 { + if inner.height < 1 { return; } @@ -71,7 +85,47 @@ pub(super) fn render_toast_notification( ]); frame.render_widget(Paragraph::new(title), title_row); - frame.render_widget(Paragraph::new(context), context_row); + if !toast.context.is_empty() && inner.height >= 2 { + frame.render_widget(Paragraph::new(context), context_row); + } +} + +pub(super) fn render_copy_feedback( + frame: &mut Frame, + area: Rect, + feedback: &CopyFeedback, + offset_rows: u16, + p: &Palette, +) { + let feedback_area = copy_feedback_rect(area, feedback, offset_rows); + if feedback_area.is_empty() { + return; + } + + frame.render_widget(Clear, feedback_area); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(p.green)) + .style(Style::default().bg(p.panel_bg)); + let inner = block.inner(feedback_area); + frame.render_widget(block, feedback_area); + + if inner.height == 0 { + return; + } + + let text = Line::from(vec![ + Span::styled("●", Style::default().fg(p.green).bg(p.panel_bg)), + Span::raw(" "), + Span::styled( + &feedback.message, + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ), + ]); + frame.render_widget(Paragraph::new(text), inner); } pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { |
