diff --git a/README.md b/README.md index 596bc20..5734f9e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ and native Settings app. - Native Settings app - Desktop widgets - Area and full-screen screenshots with Satty annotation +- [Native themed notifications and history](docs/notifications.md) - Themes, blur and gradient borders - Multi-monitor support and fractional scaling - Live configuration reload diff --git a/compositor/src/config.rs b/compositor/src/config.rs index b26cf87..e6055b4 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -15,6 +15,8 @@ use crate::window_rules::{self, WindowRule, WindowRuleConfig}; #[derive(Debug, Default, Deserialize)] pub struct Config { + #[serde(default)] + notifications: ferese_core::notifications::NotificationConfig, #[serde(default)] desktop_widgets: ferese_core::desktop::DesktopWidgets, #[serde(default)] @@ -271,6 +273,9 @@ impl Config { } pub(crate) fn runtime_config(&self) -> Result { + self.notifications + .validate() + .map_err(ConfigError::InvalidBinding)?; self.desktop_widgets .validate() .map_err(ConfigError::InvalidBinding)?; diff --git a/compositor/src/input.rs b/compositor/src/input.rs index f1170e8..bc6547b 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -507,6 +507,17 @@ impl Ferese { .map(|workspace| workspace.id) { self.activate_managed_workspace(workspace); + } else if next { + let next_number = self + .workspaces + .iter() + .filter_map(|workspace| workspace.name.parse::().ok()) + .max() + .unwrap_or(1) + .checked_add(1); + if let Some(number) = next_number { + self.switch_workspace(number); + } } } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 5f6b5a5..af40894 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -690,11 +690,6 @@ impl Ferese { xdg_shell_state, xdg_toplevel_icon_manager, }; - // Keep the numbered shortcuts and bar selectors available from startup, - // including empty workspaces that have not been assigned to an output. - for index in 1..=9 { - state.workspaces.ensure_numeric(index)?; - } state._ipc_socket = Some(crate::ipc::init(event_loop)?); Ok(state) } diff --git a/docs/configuration.md b/docs/configuration.md index 5e525fc..616996c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -379,3 +379,11 @@ The installed session launcher enables screencopy for screenshots unless `FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment. Text input is available by default; Ctrl+Alt+Escape releases an active shortcut inhibitor. Use `feresectl --help` for runtime control commands. + +## Notifications + +Settings → Notifications controls popups, Do Not Disturb, and the default timeout. See [Notifications](notifications.md) for KDL options and app behavior. + +A single-output session starts with workspace 1. Numbered workspace shortcuts create +workspaces on demand; switching forward past the last workspace also creates the +next one. Additional displays receive their own workspace. diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..8570a40 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,46 @@ +# Notifications + +Ferese Shell provides themed notification cards and an in-session history panel. +Cards appear beneath the bar on the focused output and follow the selected theme. +Click the notification icon in the bar to open history, toggle Do Not Disturb, or +clear notifications. Escape or the close button closes the history panel. + +Notifications use the standard `org.freedesktop.Notifications` D-Bus interface. +Existing apps and `notify-send` work without Ferese-specific commands: + +```sh +notify-send --app-name=Ferese --icon=dialog-information "Hello" "Your desktop notification" +``` + +Up to three popup cards are visible at once. History keeps the most recent 100 +notifications in memory and clears when the shell exits. Hovering a popup pauses +its timer. Critical notifications bypass Do Not Disturb and do not expire unless +the sender supplies a timeout. App action buttons emit the standard action signal; +a replacement updates the same notification ID. Transient messages are removed +from history when they close. + +Settings → Notifications controls popup visibility, the initial Do Not Disturb +state, and the default timeout. The bar's Do Not Disturb toggle is temporary; +changing the Settings option saves the preference for future sessions. Apps can +request their own timeout, including no timeout. + +```kdl +notifications { + show-popups #true + do-not-disturb #false + timeout-ms 6000 +} +``` + +The timeout must be between 1000 and 30000 milliseconds. Hiding popups still +records notifications in history. Appearance follows the Ferese theme. + +Only one notification server can own the D-Bus interface. The Ferese session +launcher stops the SwayNotificationCenter user service before starting its shell. +For a manually started shell, stop that service with `systemctl --user stop +swaync.service`. If another server already owns the interface, Ferese leaves it +running and logs that native notifications are unavailable. + +This implementation supports plain text, app icons, urgency, transient/resident +hints, actions, replacements, and close signals. It does not advertise rich body +markup, embedded image data, notification sounds, or history persistence. diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..0973f33 --- /dev/null +++ b/docs/performance-baseline.md @@ -0,0 +1,217 @@ +# Release performance baseline + +Measured 2026-09-27 at commit `08a5866`, using +`cargo build --release --locked -p ferese -p ferese-shell -p feresectl`. + +Two disposable nested sessions used separate runtime/config directories, default +configuration, the bundled wallpaper, and the release shell. Performance logging +was enabled with `FERESE_TRACE_PERFORMANCE=1`. The nested output reported +1422 × 1696 physical pixels, 2× scale and 60 Hz. The parent DRM output was a +2880 × 1800 eDP panel at 120 Hz and 2× scale. + +## Measurements + +CPU is process CPU time from `/proc/PID/stat` divided by elapsed monotonic time, +expressed as a percentage of one core. First-run samples lasted 10 seconds; +second-run samples lasted 5 seconds. These are short observational samples, +not statistically established improvements or before/after comparisons. + +| Scenario | Compositor CPU, run 1 / run 2 | Shell CPU, run 1 / run 2 | +| --- | --- | --- | +| Idle shell, no application window | 5.3% / 5.8% | 1.5% / 1.6% | +| Overview with one terminal | 18.6% / 16.2% | 1.3% / 1.4% | +| After window transitions, terminal still open | 6.6% / 6.4% | 1.5% / 1.4% | + +Compositor RSS was approximately 81–82 MiB initially and 91–92 MiB after the +short exercise. This does not establish a leak or long-term memory stability. +Main-thread voluntary context switches were approximately 162–165/s at idle +and 498–505/s in overview; these are scheduling proxies, not hardware wakeups. + +A five-second idle log interval contained 60 damaged frames and 531 no-damage +attempts, approximately 118 attempts/s. A later overview interval contained +591 damaged frames in 5001 ms. Nested frame scheduling and overview damage +therefore deserve investigation first. The parent runs at 120 Hz, so the +mismatch with the nested output's reported 60 Hz must be understood before +attributing these counts to duplicate scheduling. + +## Behavioral exercise + +Both sessions exited with status 0. The probes created a terminal, entered and +left overview, and toggled maximize, fullscreen and floating twice each. +The second session exercised `theme.typography.font_family`, wallpaper fit/fill, +and replacement image paths through filesystem-driven reloads. Logs confirmed +accepted reloads and replacement wallpaper decoding. This verifies execution +and liveness, not pixel-perfect appearance or GPU allocation-failure recovery. +The first probe used an incorrect font key; only the second probe counts as +font reload coverage. + +Raw local artifacts: + +- `/tmp/ferese-profile-7cbrpopn/{results.json,session.log}` +- `/tmp/ferese-profile-h2ypepn6/{results.json,session.log}` +- Probe scripts: `/tmp/ferese-profile.py` and `/tmp/ferese-profile-reloads.py` + +## Limits and next work + +The installed DRM compositor is release `20260927-042025-168287`; its binary hash +differs from the tested release build. A read-only host sample measured 9.8% +compositor CPU and 1.7% shell CPU, but overlapped the nested workload and is not +an idle baseline or validation of the new DRM code. No session was replaced. +Only one monitor was connected, so multi-monitor behavior remains untested. + +Next investigate nested refresh scheduling and repeated overview damage, using +these scenarios for before/after measurements. Preserve client frame callbacks, +animation pacing and idle responsiveness. Status command polling remains a +secondary target; blur invalidation needs a separate translucent-material +workload before ranking its cost. New-build DRM and multi-monitor validation +remain necessary before claiming hardware performance improvements. + +## Follow-up investigation + +A repeat baseline (`/tmp/ferese-profile-n8437l1i`) confirmed 60 Hz after startup +but ran near 60 attempts/s rather than 118. Compositor CPU was 3.0% idle, 9.2% +in overview and 4.6% after transitions. The earlier high-rate behavior is not +consistently reproduced, so it is not sufficient evidence of duplicate redraws. +The candidate refresh-period guard was discarded after testing. + +The guard run (`/tmp/ferese-profile-llcrkvm5`) measured 4.0% idle, 8.6% overview, +and 3.6% after transitions. Render attempts fell slightly below the nominal +cadence. This did not establish a benefit, so no frame-pacing change was kept. +The next investigation is unconditional thumbnail damage in overview. + +## Broader profiling (visible preview) + +Artifacts: `/tmp/ferese-profile-all-0lhodit4/`; script: +`/tmp/ferese-profile-all.py`. This run used the unchanged release compositor, +release shell and release Settings. The user kept the preview visible after an +earlier hidden-window run proved that host occlusion invalidated render samples. +Six-second samples are exploratory, not statistically stable benchmarks. +The captured output was 2856 × 1696 pixels; the preview was resized during the +session, so these workloads must not be compared as if output size were fixed. + +| Scenario | Compositor CPU | Shell CPU | Shell waited-child CPU | Settings CPU | +| --- | ---: | ---: | ---: | ---: | +| Idle shell | 4.33% | 1.33% | 7.50% | — | +| Three terminals | 6.67% | 1.50% | 11.33% | — | +| Overview, three terminals | 17.50% | 1.17% | 8.17% | — | +| Settings open, idle | 6.17% | 1.50% | 10.83% | 0.00% | +| After capture/window transitions | 8.50% | 1.33% | 11.00% | 2.50% | + +Percentages use one CPU core as 100%. Child CPU is Linux's accumulated CPU time +for reaped children, separate from the shell process. This includes the timing +wrapper: a PATH-local `timeout` wrapper invokes GNU time and the real timeout +command. Read-only PipeWire queries use the host runtime socket; no status +control actions were sent. The native installed shell cross-check, without +instrumentation, measured 2.0% self CPU plus 8.8% waited-child CPU over ten +seconds. It is an older binary and a different scene, so this corroborates the +existence of subprocess overhead rather than providing an exact comparison. + +The preview recorded 273 successful status command invocations across 21 polls: +13 per poll. The three login1 capability calls consumed 1.88 seconds total wall +time, about 90 ms/poll. Three nmcli queries consumed 1.35 seconds total; two wpctl +queries consumed 0.80 seconds. GNU time's hundredth-second granularity makes +per-command CPU totals unsuitable for precise attribution. Stable capability +queries, device metadata and frequently changing values currently share one +poll cycle. Separate refresh policies or service subscriptions deserve priority. + +Settings mapped in 109 ms, measured from process launch until IPC reported it +focused (not first completed paint). Idle Settings RSS was 108068 KiB, with 38 +threads and 65 descriptors. Zero measured CPU over six seconds means below this +sample's resolution, not a guarantee of zero work. Startup font enumeration was +not isolated separately. Settings interaction/search/page-switch profiling is +still outstanding. + +Twenty `feresectl get-outputs` calls had median process-inclusive latency of +3.05 ms and maximum 3.95 ms. Five captures took 1.13 seconds total, including +client startup, transfer and PNG encoding. Capture was enabled only in the +isolated compositor with `FERESE_ENABLE_SCREENCOPY=1`. The first screenshot was +visually inspected and showed the shell and Settings rendered. It does not +measure GPU readback independently. Window maximize/fullscreen/layout toggles +completed, but there was no instrumented input-to-photon latency measurement. + +The four-second visible overview perf recording (`cpu-clock:u`, 999 Hz) collected +471 samples with no lost samples. Shared-object attribution: 47.77% Ferese +executable (including statically linked dependencies), 30.36% Mesa Gallium, +14.23% libc, 2.97% Wayland client library. Approximately 96.4% of samples were in +the main compositor thread. Texture upload/memory-copy paths appear in the +sampled stacks. This is user-space CPU sampling, not GPU timing; short samples +and incomplete frame-pointer unwinding limit function-level attribution. + +The earlier broad run (`/tmp/ferese-profile-all-boiq_r30`) had a 37-second render +reporting gap while hidden, no usable perf samples, and a disabled screencopy +protocol. Its render/overview and capture results are excluded. Its bare idle +sample was 1.0% CPU, but visibility differences preclude subtracting it from the +visible shell run. + +Next priority: shell status subprocesses. Then investigate sustained overview +damage and texture uploads with controlled window visibility and dimensions. +Control-center interaction, widgets, translucent-material workloads, long-term +memory growth, new-build DRM and multi-monitor runs remain unprofiled. No source +changes or profiling-note commits were made during this profiling pass. + +## Status-worker optimization comparison + +Compared the production status module at `08a5866` with the new single-command +brightness reader and reusable native D-Bus connections. A standalone harness +started `Service::start(None)`, consumed updates every 250 ms, and ran for 20 +seconds without a UI. Both variants used the same host runtime/services and +command timing wrapper. Artifacts: `/tmp/ferese-status-compare/`. + +Both completed nine polls. Notifications were available in this comparison, so +the original worker launched 15 commands/poll and the new one launched 10: +135 versus 90 invocations. Four busctl launches and one brightnessctl launch per +poll were removed. When notifications are absent, the equivalent counts are +13 versus 8. Poll frequency, fresh capability queries and post-action generation +handling remain unchanged; no status-value caching was introduced. + +GNU time measured total user+system CPU, including waited children: 2.41 seconds +before, 1.90 seconds after over 20.01 seconds each (about 21% lower in this single +comparison). Timing-wrapper overhead is included, so this is not a precise +uninstrumented CPU improvement estimate. Native D-Bus adds persistent sockets +and a runtime thread pool; UI samples observed roughly 26–28 shell threads and +48 descriptors versus roughly 18 threads and 42 descriptors previously. + +One UI run showed a transient self-CPU spike; a subsequent perf recording +identified tiny-skia raster work rather than D-Bus queries among its hot paths. +The UI-free comparison avoids treating variable UI activity as polling cost. + +Validation: workspace suite and explicit private-dbus-daemon test passed. The +private bus exercised live capability changes, yes/challenge/no/na handling, +notification-owner appearance/disappearance, error replies, missing methods, +and a reply delayed past the configured timeout. Brightness parser tests cover +rounding, zero brightness, invalid classes and malformed/nonfinite values. + +### Push status delivery measurement (b0583eb) + +Compared release binaries for 4bf0757 and b0583eb with the same nested compositor, +empty isolated config and a visible preview. Binary SHA-256 hashes differ and +were checked after forcing a shell rebuild to avoid shared-target artifact reuse. +No status-command timing wrappers were active. Discarded an initial baseline +that overlapped compilation. Retained after/before/after runs, each with three +10-second samples following a three-second startup settle. Artifacts and harness: +`/tmp/ferese-status-push-measure/` (`summary.json`, `ui.py`, `latency.rs`). + +Shell CPU percentages of one core: +- Before: 1.5, 1.4, 1.6 (median 1.5). +- After run 1: 0.8, 1.1, 1.1. +- After run 2: 1.0, 1.1, 7.5 (combined after median 1.1). + +The 7.5% sample coincided with a large damaged-pixel burst (44.3 million in a +five-second interval), higher RSS and two additional descriptors. Its cause was +not isolated; it is retained, not silently excluded. Combined after mean is +2.1%, versus 1.5% before, so these short runs do not establish a robust average +CPU saving. Typical steady-state samples suggest a 0.4 percentage-point reduction. +Status child CPU remained roughly 6–8% of one core, dominating shell self CPU. + +Steady-state damaged frames were about 12/s before versus 6.6–7.6/s in the first +after run. The repeat had similar quiet intervals plus bursts at 15.6 and 22.7/s. +Total render attempts stayed around 60/s, confirming active rather than hidden +preview sampling. No claim is made about DRM-session CPU or total idle wakeups. + +An isolated transport harness used the old bounded mpsc + 250ms polling path and +an extracted copy of the new Updates::stream implementation, with 40 synthetic +updates per path at 317ms intervals. Worker-to-consumer delay: +- Before: median 129.398ms, p95 239.957ms, maximum 245.881ms. +- After: median 0.130ms, p95 0.330ms, maximum 0.425ms. +This measures delivery only, excluding status queries, UI dispatch and painting. +The preview processes exited and the temporary source worktree was removed. diff --git a/packaging/config.kdl b/packaging/config.kdl index 05b8ef4..e3530cb 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -43,3 +43,9 @@ desktop-widgets { opacity 0.9 } } + +notifications { + show-popups #true + do-not-disturb #false + timeout-ms 6000 +} diff --git a/packaging/ferese-session b/packaging/ferese-session index f7e8840..a598362 100644 --- a/packaging/ferese-session +++ b/packaging/ferese-session @@ -34,6 +34,10 @@ if [[ $backend == drm ]]; then unset DISPLAY WAYLAND_DISPLAY fi +if [[ $backend == drm ]] && command -v systemctl >/dev/null; then + systemctl --user stop swaync.service 2>/dev/null || true +fi + command=("$release_dir/ferese" --backend "$backend" --grant-effects --grant-shell-control -- "$release_dir/ferese-shell") if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 9cd1fc7..e5e45aa 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -4,6 +4,7 @@ pub enum Page { Wallpaper, Desktop, Bar, + Notifications, Windows, Motion, Keyboard, @@ -13,11 +14,12 @@ pub enum Page { } impl Page { - pub const ALL: [Self; 10] = [ + pub const ALL: [Self; 11] = [ Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar, + Self::Notifications, Self::Windows, Self::Motion, Self::Keyboard, @@ -32,6 +34,7 @@ impl Page { Self::Wallpaper => "Wallpaper", Self::Desktop => "Desktop widgets", Self::Bar => "Menu bar", + Self::Notifications => "Notifications", Self::Windows => "Windows", Self::Motion => "Motion", Self::Keyboard => "Keyboard & mouse", @@ -47,6 +50,7 @@ impl Page { Self::Wallpaper => "Set the scene for your workspace.", Self::Desktop => "A clock that feels at home on your wallpaper.", Self::Bar => "Everything you need, within reach.", + Self::Notifications => "Stay informed on your terms.", Self::Windows => "Make room for the way you work.", Self::Motion => "Find your rhythm.", Self::Keyboard => "Fine-tune the everyday details.", @@ -64,6 +68,7 @@ impl Page { Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01", Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2", Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01", + Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4", Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z", Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5", Self::Keyboard => { @@ -626,6 +631,31 @@ pub fn fields(page: Page) -> Vec { ), ], Page::Desktop => clock_fields(), + Page::Notifications => vec![ + toggle( + "notifications.show_popups", + "Show popup notifications", + "Keep notifications in history when popups are hidden.", + true, + ), + toggle( + "notifications.do_not_disturb", + "Do Not Disturb", + "Silence normal popups; critical notifications can still appear.", + false, + ), + range( + "notifications.timeout_ms", + "Popup timeout", + "Default duration; apps can request another timeout. Pauses while hovered.", + 6000.0, + 1000.0, + 30000.0, + 1000.0, + " ms", + true, + ), + ], Page::Bar => vec![ range( "theme.geometry.top_bar_height", diff --git a/shell/ferese-shell/assets/icons/status/chevron-up.svg b/shell/ferese-shell/assets/icons/status/chevron-up.svg new file mode 100644 index 0000000..9b6fcbc --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/chevron-up.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/close.svg b/shell/ferese-shell/assets/icons/status/close.svg new file mode 100644 index 0000000..c48c54e --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/close.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/assets/icons/status/trash.svg b/shell/ferese-shell/assets/icons/status/trash.svg new file mode 100644 index 0000000..6349c55 --- /dev/null +++ b/shell/ferese-shell/assets/icons/status/trash.svg @@ -0,0 +1 @@ + diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 35a90d7..4225ca8 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -272,6 +272,8 @@ enum Message { HoverNotification(u32, bool), ToggleNotificationHistory, ClearNotifications, + ToggleNotificationGroup(String), + RemoveNotificationGroup(String), AnimateMenu, OpenMenu(status_ui::Menu, cosmic::iced::Rectangle), OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle), @@ -398,6 +400,10 @@ impl cosmic::Application for FereseShell { .menu .as_ref() .is_some_and(|menu| menu.animating() || menu.motion.closing()) + || self + .notification_surface + .as_ref() + .is_some_and(|surface| surface.animating()) { cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu) } else { @@ -499,6 +505,17 @@ impl cosmic::Application for FereseShell { self.attach_effects(id, &surface) } Ok((_connection, surface)) => { + if let Some(entry) = &mut self.notification_surface + && entry.id == id + && entry.effects.is_none() + { + match EffectsBinding::attach_role(&surface, None, entry.progress()) { + Ok(binding) => entry.effects = Some(binding), + Err(error) => eprintln!( + "ferese-shell: notification effects unavailable: {error}" + ), + } + } if let Some(menu) = &mut self.menu && menu.id == id && menu.effects.is_none() @@ -566,24 +583,29 @@ impl cosmic::Application for FereseShell { self.notifications.hover(id, hovered); self.sync_notification_surface() } - Message::ToggleNotificationHistory => { - self.notifications.toggle_history(); + Message::ToggleNotificationHistory => self.toggle_notification_history(), + Message::ToggleNotificationGroup(app) => { + self.notifications.toggle_group(app); + self.sync_notification_surface() + } + Message::RemoveNotificationGroup(app) => { + self.notifications.dismiss_group(&app); self.sync_notification_surface() } Message::ClearNotifications => { self.notifications.clear(); self.sync_notification_surface() } - Message::AnimateMenu => self.animate_menu(), + Message::AnimateMenu => { + Task::batch([self.animate_menu(), self.animate_notification_history()]) + } Message::OpenMenu(kind, anchor) => self.open_menu(kind, anchor), Message::OpenMenuOn(id, kind, anchor) => { if kind == status_ui::Menu::Notifications && self.notifications.ready { - self.notifications.toggle_history(); - return Task::batch([self.destroy_menu(), self.sync_notification_surface()]); + return Task::batch([self.destroy_menu(), self.toggle_notification_history()]); } let hide_history = if self.notifications.history_open { - self.notifications.history_open = false; - self.sync_notification_surface() + self.close_notification_history() } else { Task::none() }; @@ -621,10 +643,9 @@ impl cosmic::Application for FereseShell { return Task::none(); } status::Action::Notifications => { - self.notifications.toggle_history(); return Task::batch([ self.close_menu(), - self.sync_notification_surface(), + self.toggle_notification_history(), ]); } _ => {} @@ -756,6 +777,14 @@ impl FereseShell { { menu.motion.update_settings(config.animations); } + if self.config.animations != config.animations + && let Some(motion) = self + .notification_surface + .as_mut() + .and_then(|surface| surface.motion.as_mut()) + { + motion.update_settings(config.animations); + } self.status_service .update_settings(config.status.settings_command.clone()); let old = self.config.theme; @@ -1730,8 +1759,7 @@ impl FereseShell { return self.finish_note_edit(); } if self.notifications.history_open { - self.notifications.history_open = false; - return self.sync_notification_surface(); + return self.close_notification_history(); } } match event { @@ -1741,8 +1769,20 @@ impl FereseShell { ))) => self.output_event(event, output), Event::Window(window::Event::Opened { .. }) if self.outputs.iter().any(|entry| entry.bar == id) - || self.menu.as_ref().is_some_and(|menu| menu.id == id) => + || self.menu.as_ref().is_some_and(|menu| menu.id == id) + || self + .notification_surface + .as_ref() + .is_some_and(|surface| surface.id == id) => { + if let Some(surface) = &mut self.notification_surface + && surface.id == id + { + surface.ready = true; + if let Some(motion) = &mut surface.motion { + motion.begin(Instant::now()); + } + } // Iced emits the first xdg_popup configure as Window::Opened, // not Popup::Configured. This is the popup's ready signal. if let Some(menu) = &mut self.menu @@ -1851,6 +1891,30 @@ impl FereseShell { self.attach_effects(frame_id, &surface); Task::none() } + Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( + _, + surface, + layer_id, + ))) if self + .notification_surface + .as_ref() + .is_some_and(|entry| entry.id == layer_id) => + { + let entry = self.notification_surface.as_mut().unwrap(); + entry.ready = true; + if let Some(motion) = &mut entry.motion { + motion.begin(Instant::now()); + } + if entry.effects.is_none() { + match EffectsBinding::attach_role(&surface, None, entry.progress()) { + Ok(binding) => entry.effects = Some(binding), + Err(error) => { + eprintln!("ferese-shell: notification effects unavailable: {error}") + } + } + } + Task::none() + } Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer( _, surface, diff --git a/shell/ferese-shell/src/notification_ui.rs b/shell/ferese-shell/src/notification_ui.rs index 3ff427e..014c363 100644 --- a/shell/ferese-shell/src/notification_ui.rs +++ b/shell/ferese-shell/src/notification_ui.rs @@ -1,23 +1,61 @@ use super::*; +use cosmic::iced::widget::scrollable::{Direction, Scrollbar}; use cosmic::widget::{column, scrollable}; const POPUP_WIDTH: u32 = 368; -fn popup_height(notice: ¬ifications::Notice, hovered: bool, count: usize) -> u32 { +fn popup_height(notice: ¬ifications::Notice, count: usize) -> u32 { let body = if notice.body.is_empty() && count == 1 { 0 } else { 22 }; - let actions = - if hovered && notice.live && notice.actions.iter().any(|(key, _)| key != "default") { - 39 - } else { - 0 - }; + let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") { + 39 + } else { + 0 + }; 76 + body + actions + if count > 1 { 4 } else { 0 } } +fn history_height(center: ¬ifications::Center) -> u32 { + if center.entries.is_empty() { + return 280; + } + let groups = center.history_groups(); + let card_height = |notice: ¬ifications::Notice, count| { + let wrapped_lines = if count == 1 { + notice + .body + .lines() + .map(|line| line.chars().count().div_ceil(40).max(1)) + .sum::() + } else { + 1 + }; + popup_height(notice, count) + + wrapped_lines.saturating_sub(1) as u32 * 17 + + if count > 1 { 12 } else { 0 } + }; + let cards: u32 = groups + .iter() + .map(|group| { + if group.len() > 1 && center.expanded_apps.contains(&group[0].app) { + 32 + group + .iter() + .map(|notice| card_height(notice, 1)) + .sum::() + + group.len().saturating_sub(1) as u32 * 8 + } else { + card_height(group[0], group.len()) + } + }) + .sum(); + // Header, DND row and section tools share the same compact rhythm as the + // cards. Long histories scroll; short histories do not reserve blank space. + (170 + cards + groups.len().saturating_sub(1) as u32 * 8).min(620) +} + fn blend(base: Color, foreground: Color, amount: f32) -> Color { Color { r: base.r + (foreground.r - base.r) * amount, @@ -54,6 +92,7 @@ fn card_button<'a>( foreground: Color, hover: Color, round: bool, + accessible_name: String, ) -> Element<'a, cosmic::Action> { let style = move |active: bool| button::Style { text_color: Some(foreground), @@ -63,6 +102,7 @@ fn card_button<'a>( ..Default::default() }; button::custom(content) + .name(accessible_name) .width(if round { Length::Shrink } else { Length::Fill }) .padding(0) .on_press(cosmic::Action::App(message)) @@ -75,13 +115,117 @@ fn card_button<'a>( .into() } +fn icon_control<'a>( + source: &'static [u8], + label: &'static str, + message: Message, + foreground: Color, + background: Color, + extent: u16, +) -> Element<'a, cosmic::Action> { + let control = card_button( + container(bar_icon(source, 14, foreground)) + .center_x(extent) + .center_y(extent), + message, + foreground, + background, + true, + label.into(), + ); + cosmic::widget::tooltip( + control, + text(label).size(11).class(theme::Text::Color(foreground)), + cosmic::widget::tooltip::Position::Left, + ) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(background)), + border: Border { + radius: 6.0.into(), + ..Default::default() + }, + text_color: Some(foreground), + ..Default::default() + })) + .into() +} + pub(super) struct NotificationSurface { pub id: window::Id, output: wl_output::WlOutput, height: u32, + pub motion: Option, + pub effects: Option, + pub ready: bool, + regions: motion::Regions, +} + +impl NotificationSurface { + pub fn animating(&self) -> bool { + self.motion + .as_ref() + .is_some_and(|motion| motion.animating() || motion.closing()) + } + + pub fn progress(&self) -> f32 { + self.motion + .as_ref() + .map_or(1.0, motion::PopupMotion::progress) + } } impl FereseShell { + pub(super) fn toggle_notification_history(&mut self) -> Task { + if self.notifications.history_open { + if let Some(motion) = self + .notification_surface + .as_mut() + .and_then(|surface| surface.motion.as_mut()) + { + if motion.closing() { + motion.retarget(1.0, Instant::now()); + return self.sync_notification_surface(); + } + } + return self.close_notification_history(); + } + self.notifications.toggle_history(); + self.sync_notification_surface() + } + + pub(super) fn close_notification_history(&mut self) -> Task { + if let Some(motion) = self + .notification_surface + .as_mut() + .and_then(|surface| surface.motion.as_mut()) + { + if !motion.closing() { + motion.retarget(0.0, Instant::now()); + } + return self.animate_notification_history(); + } + self.notifications.history_open = false; + self.sync_notification_surface() + } + + pub(super) fn animate_notification_history(&mut self) -> Task { + if let Some(surface) = &mut self.notification_surface { + if surface + .motion + .as_ref() + .is_some_and(|motion| motion.closing() && !motion.animating()) + { + self.notifications.history_open = false; + self.notifications.hovered = None; + surface.motion = None; + } + if let Some(effects) = &surface.effects { + let _ = effects.set_opacity(surface.progress()); + } + } + self.sync_notification_surface() + } + pub(super) fn sync_notification_surface(&mut self) -> Task { if self.notifications.ready { self.status.notifications = Some(status::Notifications { @@ -117,17 +261,13 @@ impl FereseShell { return Task::batch(tasks); }; let desired_height = if self.notifications.history_open { - 520 + history_height(&self.notifications) } else { self.notifications .popup_groups() .iter() .map(|(notice, _, count)| { - popup_height( - notice, - self.notifications.hovered == Some(notice.id), - *count, - ) + if *count > 1 { 12 } else { 0 } + popup_height(notice, *count) + if *count > 1 { 12 } else { 0 } }) .sum::() + count.saturating_sub(1) as u32 * 8 @@ -152,6 +292,10 @@ impl FereseShell { id, output: output.clone(), height, + motion: None, + effects: None, + ready: false, + regions: Default::default(), }); let top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0; let action = cosmic::surface::action::app_layer_shell::( @@ -177,6 +321,19 @@ impl FereseShell { ); tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); } + if self.notifications.history_open { + let surface = self.notification_surface.as_mut().unwrap(); + if surface.motion.is_none() { + let mut motion = motion::PopupMotion::new(self.config.animations); + if surface.ready { + motion.begin(Instant::now()); + } + surface.motion = Some(motion); + } + if let Some(effects) = &surface.effects { + let _ = effects.set_opacity(surface.progress()); + } + } Task::batch(tasks) } @@ -200,7 +357,6 @@ impl FereseShell { let divider = tint(palette.border); let well = blend(base, foreground, 0.07); let id = notice.id; - let hovered = self.notifications.hovered == Some(id); let mut bold = *SHELL_FONT.get().unwrap().read().unwrap(); bold.weight = cosmic::iced::font::Weight::Bold; let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() @@ -288,7 +444,21 @@ impl FereseShell { text(age_label(notice.received_at.elapsed())) .size(11) .class(theme::Text::Color(muted)), - ); + ) + .push(icon_control( + include_bytes!("../assets/icons/status/close.svg"), + "Dismiss notification", + if history && count > 1 { + Message::RemoveNotificationGroup(notice.app.clone()) + } else if history { + Message::RemoveNotification(id) + } else { + Message::DismissNotification(id) + }, + muted, + well, + 22, + )); let mut content = column([]).spacing(4).push(header).push( container( text(notice.title.clone()) @@ -311,7 +481,7 @@ impl FereseShell { if !body.is_empty() { content = content.push( container(text(body).size(13).class(theme::Text::Color(muted))) - .height(if history { + .height(if history && count == 1 { Length::Shrink } else { Length::Fixed(18.0) @@ -320,7 +490,7 @@ impl FereseShell { ); } let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill)); - if hovered && notice.live { + if notice.live { let available: Vec<_> = notice .actions .iter() @@ -340,13 +510,19 @@ impl FereseShell { ); } actions = actions.push(card_button( - container(text(label.clone()).font(bold).size(13)) - .center_x(Length::Fill) - .center_y(38), + container( + text(label.clone()) + .font(bold) + .size(13) + .class(theme::Text::Color(accent)), + ) + .center_x(Length::Fill) + .center_y(38), Message::InvokeNotification(id, key.clone()), accent, well, false, + label.clone(), )); } face = face @@ -377,31 +553,9 @@ impl FereseShell { }, ..Default::default() })); - let card: Element<'_, cosmic::Action> = if hovered { - let close = card_button( - container(text("×").size(14)).center_x(18).center_y(18), - if history { - Message::RemoveNotification(id) - } else { - Message::DismissNotification(id) - }, - muted, - well, - true, - ); - cosmic::iced::widget::stack([ - card.into(), - container(close) - .align_right(Length::Fill) - .padding([5, 6]) - .into(), - ]) - .into() - } else { - card.into() - }; + let card: Element<'_, cosmic::Action> = card.into(); let card: Element<'_, cosmic::Action> = if count > 1 { - let height = popup_height(notice, hovered, count) as f32; + let height = popup_height(notice, count) as f32; let back = move |inset: f32, offset: f32| { container( container( @@ -436,7 +590,11 @@ impl FereseShell { .on_enter(cosmic::Action::App(Message::HoverNotification(id, true))) .on_exit(cosmic::Action::App(Message::HoverNotification(id, false))); if count > 1 { - area = area.on_press(cosmic::Action::App(Message::ToggleNotificationHistory)); + area = area.on_press(cosmic::Action::App(if history { + Message::ToggleNotificationGroup(notice.app.clone()) + } else { + Message::ToggleNotificationHistory + })); } else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") { area = area.on_press(cosmic::Action::App(Message::InvokeNotification( id, @@ -449,101 +607,277 @@ impl FereseShell { fn view_notifications(&self) -> Element<'_, cosmic::Action> { if self.notifications.history_open { let palette = self.config.theme; + let surface = self.notification_surface.as_ref(); + let progress = surface.map_or(1.0, NotificationSurface::progress); + let opacity = if surface + .and_then(|surface| surface.effects.as_ref()) + .is_some_and(|effects| effects.surface.version() >= 3) + { + 1.0 + } else { + progress + }; + let color = move |rgba| color_with_opacity(rgba, opacity); let primary = color(palette.text_primary); let muted = color(palette.text_muted); let accent = color(palette.accent); let base = color(palette.surface_base); - let header = row([]) - .spacing(8) - .align_y(alignment::Vertical::Center) + let panel = blend(base, primary, 0.035); + let well = blend(base, primary, 0.07); + let mut bold = *SHELL_FONT.get().unwrap().read().unwrap(); + bold.weight = cosmic::iced::font::Weight::Bold; + let groups = self.notifications.history_groups(); + let count = self.notifications.entries.len(); + let subtitle = if count == 0 { + "A quiet moment".to_owned() + } else { + format!( + "{count} {} · {} {}", + if count == 1 { + "notification" + } else { + "notifications" + }, + groups.len(), + if groups.len() == 1 { "app" } else { "apps" } + ) + }; + let heading = column([]) + .spacing(3) .push( text("Notifications") .size(16) - .class(theme::Text::Color(primary)) - .width(Length::Fill), + .font(bold) + .class(theme::Text::Color(primary)), ) - .push(motion::button( - button::custom(text("Clear all").size(11)) - .on_press(cosmic::Action::App(Message::ClearNotifications)) - .padding([4, 6]), - muted, - false, - 1.0, - )) - .push(card_button( - container(text("×").size(14)).center_x(18).center_y(18), + .push(text(subtitle).size(11).class(theme::Text::Color(muted))); + let header = row([]) + .spacing(10) + .align_y(alignment::Vertical::Center) + .push(container(heading).width(Length::Fill)) + .push(icon_control( + include_bytes!("../assets/icons/status/close.svg"), + "Close notification center", Message::ToggleNotificationHistory, muted, - blend(base, primary, 0.07), - true, + well, + 28, )); + let dnd_color = if self.notifications.dnd { + accent + } else { + muted + }; + let dnd_icon = if self.notifications.dnd { + include_bytes!("../assets/icons/status/notifications-off.svg").as_slice() + } else { + include_bytes!("../assets/icons/status/notifications.svg").as_slice() + }; let dnd = row([]) + .spacing(10) .align_y(alignment::Vertical::Center) + .push(accented_icon(dnd_icon, 20, dnd_color, dnd_color)) .push( - text("Do Not Disturb") - .size(12) - .class(theme::Text::Color(muted)) - .width(Length::Fill), + container( + column([]) + .spacing(2) + .push( + text("Do Not Disturb") + .size(12) + .font(bold) + .class(theme::Text::Color(primary)), + ) + .push( + text(if self.notifications.dnd { + "Popups paused" + } else { + "Popups enabled" + }) + .size(11) + .class(theme::Text::Color(muted)), + ), + ) + .width(Length::Fill), ) .push(motion::button( button::custom( - text(if self.notifications.dnd { "On" } else { "Off" }).size(12), + container( + text(if self.notifications.dnd { "On" } else { "Off" }) + .size(12) + .font(bold) + .class(theme::Text::Color(dnd_color)), + ) + .center_x(40) + .center_y(26), ) .on_press(cosmic::Action::App(Message::Control(status::Action::Dnd( !self.notifications.dnd, )))) - .padding([4, 8]), - if self.notifications.dnd { - accent - } else { - muted - }, + .padding(0), + dnd_color, self.notifications.dnd, 1.0, )); - let controls = container(column([]).spacing(8).push(header).push(dnd)) - .padding(14) - .width(Length::Fill) - .class(theme::Container::custom(move |_| container::Style { - background: Some(Background::Color(base)), - border: Border { - radius: palette.material_radius.into(), + let controls = + container(dnd) + .padding(10) + .width(Length::Fill) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(base)), + border: Border { + radius: palette.material_radius.into(), + ..Default::default() + }, ..Default::default() - }, - ..Default::default() - })); - let mut entries = column([]).spacing(8); - if self.notifications.entries.is_empty() { - entries = entries.push( - container( - text("You're all caught up") - .size(14) - .class(theme::Text::Color(muted)), + })); + let mut content = column([]).spacing(10).push(header).push(controls); + if count == 0 { + let empty = column([]) + .spacing(10) + .align_x(alignment::Horizontal::Center) + .push( + container(accented_icon( + include_bytes!("../assets/icons/status/notifications.svg"), + 26, + muted, + accent, + )) + .center_x(56) + .center_y(56) + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(well)), + border: Border { + radius: 28.0.into(), + ..Default::default() + }, + ..Default::default() + })), ) - .padding(32) - .center_x(Length::Fill), + .push( + text("You're all caught up") + .size(15) + .font(bold) + .class(theme::Text::Color(primary)), + ) + .push( + text("New notifications will appear here.") + .size(11) + .class(theme::Text::Color(muted)), + ); + content = content.push( + container(empty) + .center_x(Length::Fill) + .center_y(Length::Fill), + ); + } else { + content = content.push( + row([]) + .align_y(alignment::Vertical::Center) + .push( + text("Recent") + .size(11) + .font(bold) + .class(theme::Text::Color(muted)) + .width(Length::Fill), + ) + .push(icon_control( + include_bytes!("../assets/icons/status/trash.svg"), + "Clear all notifications", + Message::ClearNotifications, + muted, + well, + 26, + )), + ); + let mut entries = column([]).spacing(8); + for group in groups { + let latest = group[0]; + if group.len() > 1 && self.notifications.expanded_apps.contains(&latest.app) { + let app = if latest.app.is_empty() { + "Notifications" + } else { + &latest.app + }; + let group_header = row([]) + .spacing(6) + .align_y(alignment::Vertical::Center) + .push( + text(format!("{} · {}", app, group.len())) + .size(11) + .font(bold) + .class(theme::Text::Color(muted)) + .width(Length::Fill), + ) + .push(icon_control( + include_bytes!("../assets/icons/status/chevron-up.svg"), + "Collapse group", + Message::ToggleNotificationGroup(latest.app.clone()), + muted, + well, + 24, + )) + .push(icon_control( + include_bytes!("../assets/icons/status/trash.svg"), + "Clear group", + Message::RemoveNotificationGroup(latest.app.clone()), + muted, + well, + 24, + )); + let mut cards = column([]).spacing(8).push(group_header); + for notice in group { + cards = cards.push(self.notification_card(notice, opacity, true, 1)); + } + entries = entries.push(cards); + } else { + entries = entries.push(self.notification_card( + latest, + opacity, + true, + group.len(), + )); + } + } + content = content.push( + scrollable(entries) + .direction(Direction::Vertical(Scrollbar::hidden())) + .height(Length::Fill), ); } - for notice in self.notifications.entries.iter().rev() { - entries = entries.push(self.notification_card(notice, 1.0, true, 1)); - } - let content = column([]) - .spacing(8) - .push(controls) - .push(scrollable(entries).height(Length::Fill)); - container(content) - .padding(4) + let panel = container(content) + .padding(14) .height(Length::Fill) .width(Length::Fill) - .into() + .class(theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(panel)), + border: Border { + color: color(palette.border), + width: 1.0, + radius: palette.material_radius.into(), + }, + ..Default::default() + })) + .into(); + motion::animated( + panel, + progress, + surface + .map(|surface| surface.regions.clone()) + .unwrap_or_default(), + palette.material_radius, + ) } else { let mut cards = column([]).spacing(8); for (notice, opacity, count) in self.notifications.popup_groups() { cards = cards.push(self.notification_card(notice, opacity, false, count)); } - container(scrollable(cards).height(Length::Fill)) - .padding(4) - .into() + container( + scrollable(cards) + .direction(Direction::Vertical(Scrollbar::hidden())) + .height(Length::Fill), + ) + .padding(4) + .into() } } } diff --git a/shell/ferese-shell/src/notifications.rs b/shell/ferese-shell/src/notifications.rs index 2c4852a..379ccc9 100644 --- a/shell/ferese-shell/src/notifications.rs +++ b/shell/ferese-shell/src/notifications.rs @@ -284,6 +284,7 @@ pub struct Center { pub dnd: bool, pub history_open: bool, pub hovered: Option, + pub expanded_apps: HashSet, pub entries: VecDeque, toasts: VecDeque, last_tick: Instant, @@ -298,6 +299,7 @@ impl Center { config, history_open: false, hovered: None, + expanded_apps: HashSet::new(), entries: VecDeque::new(), toasts: VecDeque::new(), last_tick: Instant::now(), @@ -363,6 +365,39 @@ impl Center { groups } + pub fn history_groups(&self) -> Vec> { + let mut groups: Vec> = Vec::new(); + for notice in self.entries.iter().rev() { + if let Some(group) = groups.iter_mut().find(|group| group[0].app == notice.app) { + group.push(notice); + } else { + groups.push(vec![notice]); + } + } + groups + } + + pub fn toggle_group(&mut self, app: String) { + if !self.expanded_apps.remove(&app) { + self.expanded_apps.insert(app); + } + self.hovered = None; + } + + pub fn dismiss_group(&mut self, app: &str) { + let ids: Vec<_> = self + .entries + .iter() + .filter(|notice| notice.app == app) + .map(|notice| notice.id) + .collect(); + for id in ids { + self.dismiss(id); + } + self.expanded_apps.remove(app); + self.hovered = None; + } + pub fn hover(&mut self, id: u32, hovered: bool) { if hovered { self.hovered = Some(id); @@ -445,6 +480,8 @@ impl Center { } self.entries.clear(); self.toasts.clear(); + self.expanded_apps.clear(); + self.hovered = None; } fn receive(&mut self, mut notice: Notice) { @@ -583,6 +620,7 @@ mod tests { dnd: false, history_open: false, hovered: None, + expanded_apps: HashSet::new(), entries: VecDeque::new(), toasts: VecDeque::new(), last_tick: Instant::now(), @@ -609,6 +647,42 @@ mod tests { } } + #[test] + fn history_groups_expand_and_clear_without_removing_other_apps() { + let (mut center, commands) = fixture(); + center.receive(notice(1)); + let mut other = notice(2); + other.app = "Other".into(); + center.receive(other); + center.receive(notice(3)); + let groups = center.history_groups(); + assert_eq!( + groups + .iter() + .map(|group| group.iter().map(|n| n.id).collect::>()) + .collect::>(), + [vec![3, 1], vec![2]] + ); + center.toggle_group("Test".into()); + assert!(center.expanded_apps.contains("Test")); + center.toggle_group("Test".into()); + assert!(!center.expanded_apps.contains("Test")); + center.dismiss_group("Test"); + assert_eq!(center.entries.iter().map(|n| n.id).collect::>(), [2]); + assert_eq!(center.toasts.iter().map(|n| n.id).collect::>(), [2]); + let closed: Vec<_> = commands + .try_iter() + .map(|command| match command { + Command::Close(id, _, 2) => id, + _ => panic!("unexpected action"), + }) + .collect(); + assert_eq!(closed, [1, 3]); + center.toggle_group("Other".into()); + center.clear(); + assert!(center.entries.is_empty() && center.expanded_apps.is_empty()); + } + #[test] fn groups_are_newest_first_and_hover_keeps_the_whole_stack_alive() { let (mut center, _) = fixture();