Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions crates/app/src/common/ui/tab_strip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,12 +185,14 @@ impl TabStrip {
/// selection and emitted events.
/// - `render_content` renders the visible content for one tab. It receives the tab body `Ui`
/// and the tab index from `tabs`.
/// - `render_context_menu` renders tab-specific commands and returns whether it added any.
pub fn show(
self,
ui: &mut Ui,
control_rect: Rect,
tabs: &[TabSpec<'_>],
mut render_content: impl FnMut(&mut Ui, usize),
mut render_context_menu: impl FnMut(&mut Ui, usize) -> bool,
) -> Option<TabEvent> {
let rail_fill = colors::main_accent_background(ui.visuals().dark_mode);
ui.painter().rect_filled(control_rect, 0, rail_fill);
Expand Down Expand Up @@ -236,11 +238,16 @@ impl TabStrip {

for (tab_index, spec) in tabs.iter().enumerate() {
let selected = tab_index == selected_tab_index;
if let Some(event) =
render_tab(ui, &self, strip_rect, tab_index, spec, selected, |ui| {
render_content(ui, tab_index)
})
{
if let Some(event) = render_tab(
ui,
&self,
strip_rect,
tab_index,
spec,
selected,
|ui| render_content(ui, tab_index),
|ui| render_context_menu(ui, tab_index),
) {
record_tab_event(&mut pending_event, event);
}
}
Expand Down Expand Up @@ -400,6 +407,7 @@ fn record_tab_event(pending_event: &mut Option<TabEvent>, event: TabEvent) {
}
}

#[allow(clippy::too_many_arguments)]
fn render_tab(
ui: &mut Ui,
strip: &TabStrip,
Expand All @@ -408,6 +416,7 @@ fn render_tab(
spec: &TabSpec<'_>,
selected: bool,
add_content: impl FnOnce(&mut Ui),
add_context_menu: impl FnOnce(&mut Ui) -> bool,
) -> Option<TabEvent> {
let TabSpec {
key,
Expand All @@ -431,6 +440,10 @@ fn render_tab(
let mut action = None;
if can_close {
body_response.context_menu(|ui| {
ui.set_min_width(140.0);
if add_context_menu(ui) {
ui.separator();
}
if ui.button("Close").clicked() {
action = Some(TabEvent::Close(tab_index));
ui.close();
Expand Down
3 changes: 3 additions & 0 deletions crates/app/src/host/communication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ impl ServiceSenders {

/// Send notification to host and waking up UI.
///
/// Direct service notifications are displayed as banners and retained in notification
/// history.
///
/// # Return
/// Returns `true` if the notification is sent successfully. On send errors
/// it will log the error and return `false`.
Expand Down
18 changes: 18 additions & 0 deletions crates/app/src/host/notification.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
//! Application notification content and UI display policy.

use crate::{host::error::HostError, session::error::SessionError};

/// A queued application notification and its UI display policy.
#[derive(Debug)]
pub struct NotificationRequest {
/// Notification content and severity.
pub notification: AppNotification,
/// UI surfaces on which to show the notification.
pub display: NotificationDisplay,
}

#[derive(Debug)]
pub enum AppNotification {
HostError(HostError),
Expand All @@ -12,3 +23,10 @@ pub enum AppNotification {
/// General info notification.
Info(String),
}

/// UI surfaces on which a notification is shown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationDisplay {
HistoryAndBanner,
BannerOnly,
}
25 changes: 20 additions & 5 deletions crates/app/src/host/ui/actions/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use std::time::Duration;

use crate::host::notification::AppNotification;
use tokio::{runtime::Handle, sync::mpsc};

use crate::host::notification::{AppNotification, NotificationDisplay, NotificationRequest};

mod file_dialog;
mod host_action;

Expand All @@ -16,7 +17,7 @@ pub struct UiActions {
/// Tokio runtime handle for UI components that need to spawn service-side work.
#[expect(dead_code, reason = "Reserved for upcoming UI async actions.")]
pub tokio_handle: Handle,
pending_notifications: Vec<AppNotification>,
pending_notifications: Vec<NotificationRequest>,
pub file_dialog: FileDialogHandle,
// Queue of actions for the Host to process next frame
host_actions: Vec<HostAction>,
Expand All @@ -32,11 +33,25 @@ impl UiActions {
}
}

pub fn add_notification(&mut self, notifi: AppNotification) {
self.pending_notifications.push(notifi);
/// Queues a notification for history and the active banner.
pub fn add_notification(&mut self, notification: AppNotification) {
let request = NotificationRequest {
notification,
display: NotificationDisplay::HistoryAndBanner,
};
self.pending_notifications.push(request);
}

/// Queues a banner without adding it to notification history.
pub fn add_transient_notification(&mut self, notification: AppNotification) {
let request = NotificationRequest {
notification,
display: NotificationDisplay::BannerOnly,
};
self.pending_notifications.push(request);
}

pub fn drain_notifications(&mut self) -> impl Iterator<Item = AppNotification> {
pub fn drain_notifications(&mut self) -> impl Iterator<Item = NotificationRequest> {
self.pending_notifications.drain(..)
}

Expand Down
14 changes: 11 additions & 3 deletions crates/app/src/host/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use crate::{
common::{app_style, colors},
communication::{UiReceivers, UiSenders},
message::HostMessage,
notification::NotificationDisplay,
service::HostService,
ui::{
command_palette::CommandPalette,
Expand Down Expand Up @@ -420,9 +421,16 @@ impl Host {

fn handle_ui_actions(&mut self, ctx: &Context) {
let mut changed = false;
for notifi in self.ui_actions.drain_notifications() {
for request in self.ui_actions.drain_notifications() {
changed = true;
self.notifications.add(notifi);
match request.display {
NotificationDisplay::HistoryAndBanner => {
self.notifications.add(request.notification)
}
NotificationDisplay::BannerOnly => {
self.notifications.add_transient(request.notification)
}
}
}

let host_actions: Vec<_> = self.ui_actions.drain_host_actions().collect();
Expand Down Expand Up @@ -595,7 +603,7 @@ impl eframe::App for Host {
continue;
};

session.handle_messages(&mut self.ui_actions, &mut self.storage, registry);
session.handle_messages(ctx, &mut self.ui_actions, &mut self.storage, registry);
}

self.handle_recent_sessions();
Expand Down
26 changes: 25 additions & 1 deletion crates/app/src/host/ui/notification/banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use super::NotificationEntry;
#[derive(Debug, Clone)]
pub struct NotificationBanner {
entry: NotificationEntry,
history_backed: bool,
remaining: Duration,
created_at: Instant,
last_updated: Instant,
Expand All @@ -22,19 +23,41 @@ pub struct NotificationBanner {
}

impl NotificationBanner {
pub fn new(entry: NotificationEntry) -> Self {
/// Creates a banner with its full display lifetime available.
///
/// # Arguments
///
/// * `entry` - Display-ready notification content.
/// * `history_backed` - Whether dismissing the banner should mark notification history as seen.
pub fn new(entry: NotificationEntry, history_backed: bool) -> Self {
const BANNER_TTL: Duration = Duration::from_secs(4);

let now = Instant::now();
Self {
entry,
history_backed,
remaining: BANNER_TTL,
created_at: now,
last_updated: now,
reset_cached_size: true,
}
}

/// Returns whether this banner represents a notification retained in history.
pub fn history_backed(&self) -> bool {
self.history_backed
}

/// Renders the banner below the notification button and advances its display lifetime.
///
/// # Arguments
///
/// * `button_rect` - Notification button bounds used to position the banner.
/// * `ui` - Parent UI used for rendering and repaint scheduling.
///
/// # Return
///
/// Returns `true` when the banner is clicked.
pub fn render(&mut self, button_rect: Rect, ui: &mut Ui) -> bool {
const BANNER_MAX_WIDTH: f32 = 340.0;
const BANNER_MARGIN: f32 = 8.0;
Expand Down Expand Up @@ -144,6 +167,7 @@ impl NotificationBanner {
false
}

/// Returns whether the banner display lifetime has elapsed.
pub fn expired(&self) -> bool {
self.remaining.is_zero()
}
Expand Down
50 changes: 45 additions & 5 deletions crates/app/src/host/ui/notification/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,21 @@ impl NotificationUi {
self.unseen_top_level = Some(entry.level);
}

let active_banner = NotificationBanner::new(entry.clone());
self.active_banner = Some(active_banner);
self.show_banner(entry.clone(), true);
self.queue.add_item(entry);
}

/// Shows a notification as a banner without adding it to history.
pub fn add_transient(&mut self, notification: AppNotification) {
let entry = NotificationEntry::from(notification);
self.show_banner(entry, false);
}

fn show_banner(&mut self, entry: NotificationEntry, history_backed: bool) {
let banner = NotificationBanner::new(entry, history_backed);
self.active_banner = Some(banner);
}

/// Renders the notification button with its popup, modal message, and latest banner.
pub fn render_content(&mut self, ui: &mut Ui) {
let popup_open = Popup::is_id_open(ui.ctx(), self.popup_id);
Expand Down Expand Up @@ -146,14 +156,22 @@ impl NotificationUi {

let clicked = banner.render(button_rect, ui);
if clicked {
// Dismiss the banner and mark notifications as seen on click.
self.active_banner = None;
self.unseen_top_level = None;
self.dismiss_banner();
} else if banner.expired() {
self.active_banner = None;
}
}

fn dismiss_banner(&mut self) {
let Some(banner) = self.active_banner.take() else {
return;
};

if banner.history_backed() {
self.unseen_top_level = None;
}
}

/// Renders the notification history popup content.
fn popup_content(&mut self, ui: &mut Ui) {
let panel_width = (ui.content_rect().width() - 20.)
Expand Down Expand Up @@ -301,3 +319,25 @@ impl From<AppNotification> for NotificationEntry {
Self { level, message }
}
}

#[cfg(test)]
mod tests {
use super::{NotificationLevel, NotificationUi};
use crate::host::notification::AppNotification;

#[test]
fn transient_dismissal_preserves_unseen_history() {
let mut notifications = NotificationUi::default();
notifications.add(AppNotification::Warning("Retained".to_owned()));
notifications.add_transient(AppNotification::Error("Transient".to_owned()));

notifications.dismiss_banner();

assert_eq!(notifications.queue.len(), 1);
assert_eq!(
notifications.unseen_top_level,
Some(NotificationLevel::Warning)
);
assert!(notifications.active_banner.is_none());
}
}
35 changes: 34 additions & 1 deletion crates/app/src/host/ui/shortcuts/handler.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Dispatches host-level shortcuts before forwarding unconsumed shortcuts to the active session.
//! Dispatches host-level input shortcuts to the active application or session scope.

use egui::{Context, Event};

Expand All @@ -22,6 +22,10 @@ pub fn handle(host: &mut Host, ctx: &Context) -> bool {
return false;
}

if handle_copy_event(host, ctx) {
return true;
}

if !has_key_press(ctx) {
return false;
}
Expand All @@ -46,6 +50,35 @@ pub fn handle(host: &mut Host, ctx: &Context) -> bool {
false
}

/// Routes a native copy event to the active session without consuming it preemptively.
fn handle_copy_event(host: &mut Host, ctx: &Context) -> bool {
let has_copy_event = ctx.input(|input| {
input
.events
.iter()
.any(|event| matches!(event, Event::Copy))
});
if !has_copy_event {
return false;
}

let Host {
state,
tabs,
ui_actions,
..
} = host;

match tabs.active_mut() {
HostTab::Session(session) => session.handle_copy_event(ui_actions, &state.preferences, ctx),
HostTab::Home(_)
| HostTab::SessionSetup(_)
| HostTab::MultiFileSetup(_)
| HostTab::PluginManager(_)
| HostTab::AppSettings(_) => false,
}
}

/// Returns true when a key press event exists in the current frame.
fn has_key_press(ctx: &Context) -> bool {
ctx.input(|input| {
Expand Down
10 changes: 8 additions & 2 deletions crates/app/src/host/ui/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,10 @@ mod tests {
assert!(storage.file_explorer.get_save_data().is_some());
assert!(storage.recent_sessions.get_save_data().is_some());
assert!(matches!(
ui_actions.drain_notifications().next(),
ui_actions
.drain_notifications()
.next()
.map(|request| request.notification),
Some(AppNotification::Error(message)) if message.contains("disk full")
));
}
Expand Down Expand Up @@ -566,7 +569,10 @@ mod tests {
);

assert!(matches!(
ui_actions.drain_notifications().next(),
ui_actions
.drain_notifications()
.next()
.map(|request| request.notification),
Some(AppNotification::Warning(_))
));
}
Expand Down
Loading
Loading