Refine notification center and configure native notifications

Keep notification actions and dismiss controls visible, compact and animate history, and add themed icon controls and grouping.

Include notification settings, session integration and documentation, on-demand workspaces, and performance baseline notes.
This commit is contained in:
Samuel Onoja committed 2026-09-27 08:35:38 +01:00
1 parent a98d7a75c3
commit ab6bf8b961
16 files changed
+902 -104

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ and native Settings app.
- Native Settings app - Native Settings app
- Desktop widgets - Desktop widgets
- Area and full-screen screenshots with Satty annotation - Area and full-screen screenshots with Satty annotation
- [Native themed notifications and history](docs/notifications.md)
- Themes, blur and gradient borders - Themes, blur and gradient borders
- Multi-monitor support and fractional scaling - Multi-monitor support and fractional scaling
- Live configuration reload - Live configuration reload
+5
View File
@@ -15,6 +15,8 @@ use crate::window_rules::{self, WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default)]
notifications: ferese_core::notifications::NotificationConfig,
#[serde(default)] #[serde(default)]
desktop_widgets: ferese_core::desktop::DesktopWidgets, desktop_widgets: ferese_core::desktop::DesktopWidgets,
#[serde(default)] #[serde(default)]
@@ -271,6 +273,9 @@ impl Config {
} }
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> { pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self.notifications
.validate()
.map_err(ConfigError::InvalidBinding)?;
self.desktop_widgets self.desktop_widgets
.validate() .validate()
.map_err(ConfigError::InvalidBinding)?; .map_err(ConfigError::InvalidBinding)?;
+11
View File
@@ -507,6 +507,17 @@ impl Ferese {
.map(|workspace| workspace.id) .map(|workspace| workspace.id)
{ {
self.activate_managed_workspace(workspace); self.activate_managed_workspace(workspace);
} else if next {
let next_number = self
.workspaces
.iter()
.filter_map(|workspace| workspace.name.parse::<u32>().ok())
.max()
.unwrap_or(1)
.checked_add(1);
if let Some(number) = next_number {
self.switch_workspace(number);
}
} }
} }
-5
View File
@@ -690,11 +690,6 @@ impl Ferese {
xdg_shell_state, xdg_shell_state,
xdg_toplevel_icon_manager, 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)?); state._ipc_socket = Some(crate::ipc::init(event_loop)?);
Ok(state) Ok(state)
} }
+8
View File
@@ -379,3 +379,11 @@ The installed session launcher enables screencopy for screenshots unless
`FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment. `FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment.
Text input is available by default; Ctrl+Alt+Escape releases an active shortcut Text input is available by default; Ctrl+Alt+Escape releases an active shortcut
inhibitor. Use `feresectl --help` for runtime control commands. 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.
+46
View File
@@ -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.
+217
View File
@@ -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.
+6
View File
@@ -43,3 +43,9 @@ desktop-widgets {
opacity 0.9 opacity 0.9
} }
} }
notifications {
show-popups #true
do-not-disturb #false
timeout-ms 6000
}
+4
View File
@@ -34,6 +34,10 @@ if [[ $backend == drm ]]; then
unset DISPLAY WAYLAND_DISPLAY unset DISPLAY WAYLAND_DISPLAY
fi 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 command=("$release_dir/ferese" --backend "$backend" --grant-effects
--grant-shell-control -- "$release_dir/ferese-shell") --grant-shell-control -- "$release_dir/ferese-shell")
if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then
+31 -1
View File
@@ -4,6 +4,7 @@ pub enum Page {
Wallpaper, Wallpaper,
Desktop, Desktop,
Bar, Bar,
Notifications,
Windows, Windows,
Motion, Motion,
Keyboard, Keyboard,
@@ -13,11 +14,12 @@ pub enum Page {
} }
impl Page { impl Page {
pub const ALL: [Self; 10] = [ pub const ALL: [Self; 11] = [
Self::Appearance, Self::Appearance,
Self::Wallpaper, Self::Wallpaper,
Self::Desktop, Self::Desktop,
Self::Bar, Self::Bar,
Self::Notifications,
Self::Windows, Self::Windows,
Self::Motion, Self::Motion,
Self::Keyboard, Self::Keyboard,
@@ -32,6 +34,7 @@ impl Page {
Self::Wallpaper => "Wallpaper", Self::Wallpaper => "Wallpaper",
Self::Desktop => "Desktop widgets", Self::Desktop => "Desktop widgets",
Self::Bar => "Menu bar", Self::Bar => "Menu bar",
Self::Notifications => "Notifications",
Self::Windows => "Windows", Self::Windows => "Windows",
Self::Motion => "Motion", Self::Motion => "Motion",
Self::Keyboard => "Keyboard & mouse", Self::Keyboard => "Keyboard & mouse",
@@ -47,6 +50,7 @@ impl Page {
Self::Wallpaper => "Set the scene for your workspace.", Self::Wallpaper => "Set the scene for your workspace.",
Self::Desktop => "A clock that feels at home on your wallpaper.", Self::Desktop => "A clock that feels at home on your wallpaper.",
Self::Bar => "Everything you need, within reach.", Self::Bar => "Everything you need, within reach.",
Self::Notifications => "Stay informed on your terms.",
Self::Windows => "Make room for the way you work.", Self::Windows => "Make room for the way you work.",
Self::Motion => "Find your rhythm.", Self::Motion => "Find your rhythm.",
Self::Keyboard => "Fine-tune the everyday details.", 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::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::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::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::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::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5",
Self::Keyboard => { Self::Keyboard => {
@@ -626,6 +631,31 @@ pub fn fields(page: Page) -> Vec<Field> {
), ),
], ],
Page::Desktop => clock_fields(), 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![ Page::Bar => vec![
range( range(
"theme.geometry.top_bar_height", "theme.geometry.top_bar_height",
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m5 15 7-7 7 7"/></svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 6 12 12M18 6 6 18"/></svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v5M14 11v5"/></svg>

After

Width:  |  Height:  |  Size: 229 B

+76 -12
View File
@@ -272,6 +272,8 @@ enum Message {
HoverNotification(u32, bool), HoverNotification(u32, bool),
ToggleNotificationHistory, ToggleNotificationHistory,
ClearNotifications, ClearNotifications,
ToggleNotificationGroup(String),
RemoveNotificationGroup(String),
AnimateMenu, AnimateMenu,
OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>), OpenMenu(status_ui::Menu, cosmic::iced::Rectangle<i32>),
OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>), OpenMenuOn(window::Id, status_ui::Menu, cosmic::iced::Rectangle<i32>),
@@ -398,6 +400,10 @@ impl cosmic::Application for FereseShell {
.menu .menu
.as_ref() .as_ref()
.is_some_and(|menu| menu.animating() || menu.motion.closing()) .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) cosmic::iced::time::every(Duration::from_millis(16)).map(|_| Message::AnimateMenu)
} else { } else {
@@ -499,6 +505,17 @@ impl cosmic::Application for FereseShell {
self.attach_effects(id, &surface) self.attach_effects(id, &surface)
} }
Ok((_connection, 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 if let Some(menu) = &mut self.menu
&& menu.id == id && menu.id == id
&& menu.effects.is_none() && menu.effects.is_none()
@@ -566,24 +583,29 @@ impl cosmic::Application for FereseShell {
self.notifications.hover(id, hovered); self.notifications.hover(id, hovered);
self.sync_notification_surface() self.sync_notification_surface()
} }
Message::ToggleNotificationHistory => { Message::ToggleNotificationHistory => self.toggle_notification_history(),
self.notifications.toggle_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() self.sync_notification_surface()
} }
Message::ClearNotifications => { Message::ClearNotifications => {
self.notifications.clear(); self.notifications.clear();
self.sync_notification_surface() 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::OpenMenu(kind, anchor) => self.open_menu(kind, anchor),
Message::OpenMenuOn(id, kind, anchor) => { Message::OpenMenuOn(id, kind, anchor) => {
if kind == status_ui::Menu::Notifications && self.notifications.ready { if kind == status_ui::Menu::Notifications && self.notifications.ready {
self.notifications.toggle_history(); return Task::batch([self.destroy_menu(), self.toggle_notification_history()]);
return Task::batch([self.destroy_menu(), self.sync_notification_surface()]);
} }
let hide_history = if self.notifications.history_open { let hide_history = if self.notifications.history_open {
self.notifications.history_open = false; self.close_notification_history()
self.sync_notification_surface()
} else { } else {
Task::none() Task::none()
}; };
@@ -621,10 +643,9 @@ impl cosmic::Application for FereseShell {
return Task::none(); return Task::none();
} }
status::Action::Notifications => { status::Action::Notifications => {
self.notifications.toggle_history();
return Task::batch([ return Task::batch([
self.close_menu(), self.close_menu(),
self.sync_notification_surface(), self.toggle_notification_history(),
]); ]);
} }
_ => {} _ => {}
@@ -756,6 +777,14 @@ impl FereseShell {
{ {
menu.motion.update_settings(config.animations); 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 self.status_service
.update_settings(config.status.settings_command.clone()); .update_settings(config.status.settings_command.clone());
let old = self.config.theme; let old = self.config.theme;
@@ -1730,8 +1759,7 @@ impl FereseShell {
return self.finish_note_edit(); return self.finish_note_edit();
} }
if self.notifications.history_open { if self.notifications.history_open {
self.notifications.history_open = false; return self.close_notification_history();
return self.sync_notification_surface();
} }
} }
match event { match event {
@@ -1741,8 +1769,20 @@ impl FereseShell {
))) => self.output_event(event, output), ))) => self.output_event(event, output),
Event::Window(window::Event::Opened { .. }) Event::Window(window::Event::Opened { .. })
if self.outputs.iter().any(|entry| entry.bar == id) 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, // Iced emits the first xdg_popup configure as Window::Opened,
// not Popup::Configured. This is the popup's ready signal. // not Popup::Configured. This is the popup's ready signal.
if let Some(menu) = &mut self.menu if let Some(menu) = &mut self.menu
@@ -1851,6 +1891,30 @@ impl FereseShell {
self.attach_effects(frame_id, &surface); self.attach_effects(frame_id, &surface);
Task::none() 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( Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Layer(
_, _,
surface, surface,
+420 -86
View File
@@ -1,16 +1,16 @@
use super::*; use super::*;
use cosmic::iced::widget::scrollable::{Direction, Scrollbar};
use cosmic::widget::{column, scrollable}; use cosmic::widget::{column, scrollable};
const POPUP_WIDTH: u32 = 368; const POPUP_WIDTH: u32 = 368;
fn popup_height(notice: &notifications::Notice, hovered: bool, count: usize) -> u32 { fn popup_height(notice: &notifications::Notice, count: usize) -> u32 {
let body = if notice.body.is_empty() && count == 1 { let body = if notice.body.is_empty() && count == 1 {
0 0
} else { } else {
22 22
}; };
let actions = let actions = if notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
if hovered && notice.live && notice.actions.iter().any(|(key, _)| key != "default") {
39 39
} else { } else {
0 0
@@ -18,6 +18,44 @@ fn popup_height(notice: &notifications::Notice, hovered: bool, count: usize) ->
76 + body + actions + if count > 1 { 4 } else { 0 } 76 + body + actions + if count > 1 { 4 } else { 0 }
} }
fn history_height(center: &notifications::Center) -> u32 {
if center.entries.is_empty() {
return 280;
}
let groups = center.history_groups();
let card_height = |notice: &notifications::Notice, count| {
let wrapped_lines = if count == 1 {
notice
.body
.lines()
.map(|line| line.chars().count().div_ceil(40).max(1))
.sum::<usize>()
} 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::<u32>()
+ 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 { fn blend(base: Color, foreground: Color, amount: f32) -> Color {
Color { Color {
r: base.r + (foreground.r - base.r) * amount, r: base.r + (foreground.r - base.r) * amount,
@@ -54,6 +92,7 @@ fn card_button<'a>(
foreground: Color, foreground: Color,
hover: Color, hover: Color,
round: bool, round: bool,
accessible_name: String,
) -> Element<'a, cosmic::Action<Message>> { ) -> Element<'a, cosmic::Action<Message>> {
let style = move |active: bool| button::Style { let style = move |active: bool| button::Style {
text_color: Some(foreground), text_color: Some(foreground),
@@ -63,6 +102,7 @@ fn card_button<'a>(
..Default::default() ..Default::default()
}; };
button::custom(content) button::custom(content)
.name(accessible_name)
.width(if round { Length::Shrink } else { Length::Fill }) .width(if round { Length::Shrink } else { Length::Fill })
.padding(0) .padding(0)
.on_press(cosmic::Action::App(message)) .on_press(cosmic::Action::App(message))
@@ -75,13 +115,117 @@ fn card_button<'a>(
.into() .into()
} }
fn icon_control<'a>(
source: &'static [u8],
label: &'static str,
message: Message,
foreground: Color,
background: Color,
extent: u16,
) -> Element<'a, cosmic::Action<Message>> {
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(super) struct NotificationSurface {
pub id: window::Id, pub id: window::Id,
output: wl_output::WlOutput, output: wl_output::WlOutput,
height: u32, height: u32,
pub motion: Option<motion::PopupMotion>,
pub effects: Option<EffectsBinding>,
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 { impl FereseShell {
pub(super) fn toggle_notification_history(&mut self) -> Task<Message> {
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<Message> {
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<Message> {
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<Message> { pub(super) fn sync_notification_surface(&mut self) -> Task<Message> {
if self.notifications.ready { if self.notifications.ready {
self.status.notifications = Some(status::Notifications { self.status.notifications = Some(status::Notifications {
@@ -117,17 +261,13 @@ impl FereseShell {
return Task::batch(tasks); return Task::batch(tasks);
}; };
let desired_height = if self.notifications.history_open { let desired_height = if self.notifications.history_open {
520 history_height(&self.notifications)
} else { } else {
self.notifications self.notifications
.popup_groups() .popup_groups()
.iter() .iter()
.map(|(notice, _, count)| { .map(|(notice, _, count)| {
popup_height( popup_height(notice, *count) + if *count > 1 { 12 } else { 0 }
notice,
self.notifications.hovered == Some(notice.id),
*count,
) + if *count > 1 { 12 } else { 0 }
}) })
.sum::<u32>() .sum::<u32>()
+ count.saturating_sub(1) as u32 * 8 + count.saturating_sub(1) as u32 * 8
@@ -152,6 +292,10 @@ impl FereseShell {
id, id,
output: output.clone(), output: output.clone(),
height, 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 top = self.config.theme.bar_height + self.config.theme.bar_margin_top as f32 + 12.0;
let action = cosmic::surface::action::app_layer_shell::<Self>( let action = cosmic::surface::action::app_layer_shell::<Self>(
@@ -177,6 +321,19 @@ impl FereseShell {
); );
tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); 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) Task::batch(tasks)
} }
@@ -200,7 +357,6 @@ impl FereseShell {
let divider = tint(palette.border); let divider = tint(palette.border);
let well = blend(base, foreground, 0.07); let well = blend(base, foreground, 0.07);
let id = notice.id; let id = notice.id;
let hovered = self.notifications.hovered == Some(id);
let mut bold = *SHELL_FONT.get().unwrap().read().unwrap(); let mut bold = *SHELL_FONT.get().unwrap().read().unwrap();
bold.weight = cosmic::iced::font::Weight::Bold; bold.weight = cosmic::iced::font::Weight::Bold;
let app_icon = if notice.app.to_lowercase().starts_with("ferese") || notice.icon.is_empty() 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())) text(age_label(notice.received_at.elapsed()))
.size(11) .size(11)
.class(theme::Text::Color(muted)), .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( let mut content = column([]).spacing(4).push(header).push(
container( container(
text(notice.title.clone()) text(notice.title.clone())
@@ -311,7 +481,7 @@ impl FereseShell {
if !body.is_empty() { if !body.is_empty() {
content = content.push( content = content.push(
container(text(body).size(13).class(theme::Text::Color(muted))) container(text(body).size(13).class(theme::Text::Color(muted)))
.height(if history { .height(if history && count == 1 {
Length::Shrink Length::Shrink
} else { } else {
Length::Fixed(18.0) Length::Fixed(18.0)
@@ -320,7 +490,7 @@ impl FereseShell {
); );
} }
let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill)); let mut face = column([]).push(container(content).padding([12, 14]).width(Length::Fill));
if hovered && notice.live { if notice.live {
let available: Vec<_> = notice let available: Vec<_> = notice
.actions .actions
.iter() .iter()
@@ -340,13 +510,19 @@ impl FereseShell {
); );
} }
actions = actions.push(card_button( actions = actions.push(card_button(
container(text(label.clone()).font(bold).size(13)) container(
text(label.clone())
.font(bold)
.size(13)
.class(theme::Text::Color(accent)),
)
.center_x(Length::Fill) .center_x(Length::Fill)
.center_y(38), .center_y(38),
Message::InvokeNotification(id, key.clone()), Message::InvokeNotification(id, key.clone()),
accent, accent,
well, well,
false, false,
label.clone(),
)); ));
} }
face = face face = face
@@ -377,31 +553,9 @@ impl FereseShell {
}, },
..Default::default() ..Default::default()
})); }));
let card: Element<'_, cosmic::Action<Message>> = if hovered { let card: Element<'_, cosmic::Action<Message>> = card.into();
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<Message>> = if count > 1 { let card: Element<'_, cosmic::Action<Message>> = 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| { let back = move |inset: f32, offset: f32| {
container( container(
container( container(
@@ -436,7 +590,11 @@ impl FereseShell {
.on_enter(cosmic::Action::App(Message::HoverNotification(id, true))) .on_enter(cosmic::Action::App(Message::HoverNotification(id, true)))
.on_exit(cosmic::Action::App(Message::HoverNotification(id, false))); .on_exit(cosmic::Action::App(Message::HoverNotification(id, false)));
if count > 1 { 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") { } else if notice.live && notice.actions.iter().any(|(key, _)| key == "default") {
area = area.on_press(cosmic::Action::App(Message::InvokeNotification( area = area.on_press(cosmic::Action::App(Message::InvokeNotification(
id, id,
@@ -449,60 +607,120 @@ impl FereseShell {
fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> { fn view_notifications(&self) -> Element<'_, cosmic::Action<Message>> {
if self.notifications.history_open { if self.notifications.history_open {
let palette = self.config.theme; 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 primary = color(palette.text_primary);
let muted = color(palette.text_muted); let muted = color(palette.text_muted);
let accent = color(palette.accent); let accent = color(palette.accent);
let base = color(palette.surface_base); let base = color(palette.surface_base);
let header = row([]) let panel = blend(base, primary, 0.035);
.spacing(8) let well = blend(base, primary, 0.07);
.align_y(alignment::Vertical::Center) 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( .push(
text("Notifications") text("Notifications")
.size(16) .size(16)
.class(theme::Text::Color(primary)) .font(bold)
.width(Length::Fill), .class(theme::Text::Color(primary)),
) )
.push(motion::button( .push(text(subtitle).size(11).class(theme::Text::Color(muted)));
button::custom(text("Clear all").size(11)) let header = row([])
.on_press(cosmic::Action::App(Message::ClearNotifications)) .spacing(10)
.padding([4, 6]), .align_y(alignment::Vertical::Center)
muted, .push(container(heading).width(Length::Fill))
false, .push(icon_control(
1.0, include_bytes!("../assets/icons/status/close.svg"),
)) "Close notification center",
.push(card_button(
container(text("×").size(14)).center_x(18).center_y(18),
Message::ToggleNotificationHistory, Message::ToggleNotificationHistory,
muted, muted,
blend(base, primary, 0.07), well,
true, 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([]) let dnd = row([])
.spacing(10)
.align_y(alignment::Vertical::Center) .align_y(alignment::Vertical::Center)
.push(accented_icon(dnd_icon, 20, dnd_color, dnd_color))
.push(
container(
column([])
.spacing(2)
.push( .push(
text("Do Not Disturb") text("Do Not Disturb")
.size(12) .size(12)
.class(theme::Text::Color(muted)) .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), .width(Length::Fill),
) )
.push(motion::button( .push(motion::button(
button::custom( 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( .on_press(cosmic::Action::App(Message::Control(status::Action::Dnd(
!self.notifications.dnd, !self.notifications.dnd,
)))) ))))
.padding([4, 8]), .padding(0),
if self.notifications.dnd { dnd_color,
accent
} else {
muted
},
self.notifications.dnd, self.notifications.dnd,
1.0, 1.0,
)); ));
let controls = container(column([]).spacing(8).push(header).push(dnd)) let controls =
.padding(14) container(dnd)
.padding(10)
.width(Length::Fill) .width(Length::Fill)
.class(theme::Container::custom(move |_| container::Style { .class(theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(base)), background: Some(Background::Color(base)),
@@ -512,36 +730,152 @@ impl FereseShell {
}, },
..Default::default() ..Default::default()
})); }));
let mut entries = column([]).spacing(8); let mut content = column([]).spacing(10).push(header).push(controls);
if self.notifications.entries.is_empty() { if count == 0 {
entries = entries.push( let empty = column([])
container( .spacing(10)
text("You're all caught up") .align_x(alignment::Horizontal::Center)
.size(14) .push(
.class(theme::Text::Color(muted)), 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) .push(
.center_x(Length::Fill), 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() { let panel = container(content)
entries = entries.push(self.notification_card(notice, 1.0, true, 1)); .padding(14)
}
let content = column([])
.spacing(8)
.push(controls)
.push(scrollable(entries).height(Length::Fill));
container(content)
.padding(4)
.height(Length::Fill) .height(Length::Fill)
.width(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 { } else {
let mut cards = column([]).spacing(8); let mut cards = column([]).spacing(8);
for (notice, opacity, count) in self.notifications.popup_groups() { for (notice, opacity, count) in self.notifications.popup_groups() {
cards = cards.push(self.notification_card(notice, opacity, false, count)); cards = cards.push(self.notification_card(notice, opacity, false, count));
} }
container(scrollable(cards).height(Length::Fill)) container(
scrollable(cards)
.direction(Direction::Vertical(Scrollbar::hidden()))
.height(Length::Fill),
)
.padding(4) .padding(4)
.into() .into()
} }
+74
View File
@@ -284,6 +284,7 @@ pub struct Center {
pub dnd: bool, pub dnd: bool,
pub history_open: bool, pub history_open: bool,
pub hovered: Option<u32>, pub hovered: Option<u32>,
pub expanded_apps: HashSet<String>,
pub entries: VecDeque<Notice>, pub entries: VecDeque<Notice>,
toasts: VecDeque<Toast>, toasts: VecDeque<Toast>,
last_tick: Instant, last_tick: Instant,
@@ -298,6 +299,7 @@ impl Center {
config, config,
history_open: false, history_open: false,
hovered: None, hovered: None,
expanded_apps: HashSet::new(),
entries: VecDeque::new(), entries: VecDeque::new(),
toasts: VecDeque::new(), toasts: VecDeque::new(),
last_tick: Instant::now(), last_tick: Instant::now(),
@@ -363,6 +365,39 @@ impl Center {
groups groups
} }
pub fn history_groups(&self) -> Vec<Vec<&Notice>> {
let mut groups: Vec<Vec<&Notice>> = 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) { pub fn hover(&mut self, id: u32, hovered: bool) {
if hovered { if hovered {
self.hovered = Some(id); self.hovered = Some(id);
@@ -445,6 +480,8 @@ impl Center {
} }
self.entries.clear(); self.entries.clear();
self.toasts.clear(); self.toasts.clear();
self.expanded_apps.clear();
self.hovered = None;
} }
fn receive(&mut self, mut notice: Notice) { fn receive(&mut self, mut notice: Notice) {
@@ -583,6 +620,7 @@ mod tests {
dnd: false, dnd: false,
history_open: false, history_open: false,
hovered: None, hovered: None,
expanded_apps: HashSet::new(),
entries: VecDeque::new(), entries: VecDeque::new(),
toasts: VecDeque::new(), toasts: VecDeque::new(),
last_tick: Instant::now(), 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::<Vec<_>>())
.collect::<Vec<_>>(),
[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::<Vec<_>>(), [2]);
assert_eq!(center.toasts.iter().map(|n| n.id).collect::<Vec<_>>(), [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] #[test]
fn groups_are_newest_first_and_hover_keeps_the_whole_stack_alive() { fn groups_are_newest_first_and_hover_keeps_the_whole_stack_alive() {
let (mut center, _) = fixture(); let (mut center, _) = fixture();