Skip to content

Commit f9837b8

Browse files
thewrzclaude
andauthored
feat(ui): add in-app notices (#185)
* feat: add in-app notices * fix(ui): bound the notice queue and make the stack scrollable Persistent error notices never auto-expire, and the queue had no cap, no dedup, and rendered in a plain Column. A re-emitting fault (a broken sink honked repeatedly, device churn) grew the queue and the on-screen stack without limit, pushing notices and their close buttons off-screen with no way to clear them. - Coalesce identical notices (same level/title/body), refreshing the expiry in place instead of stacking duplicates. - Cap the queue at NoticeQueue::MAX_NOTICES, evicting the oldest beyond it. - Wrap the toast stack in a height-bounded scrollable so overflow stays reachable and dismissable. Addresses the CRITICAL flagged in the #185 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(app): treat coalesced notices as freshest for cap eviction A re-emitted notice refreshed its expiry in place but kept its original deque position, so oldest-first cap eviction could victimize the most actively re-emitting notice while stale ones survived. Coalescing now moves the refreshed notice to the back, aligning recency with eviction. Also applies the cargo fmt cleanup that was failing the CI lint job. Addresses CodeRabbit review on PR #185. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(deny): ignore quick-xml RUSTSEC-2026-0194/0195 pending upstream bump Both advisories are DoS-via-untrusted-XML in quick-xml < 0.41. The sole consumer is wayland-scanner, a build-time proc-macro parsing vendored Wayland protocol XML; no untrusted XML is parsed at runtime, so neither issue is reachable in the shipped binary. wayland-scanner 0.31.10 (latest) still pins quick-xml ^0.39 — drop these ignores when it moves to 0.41. Closes #192. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump anyhow to 1.0.103 (RUSTSEC-2026-0190) anyhow 1.0.102 is flagged unsound (Error::downcast_mut UB after Error::context); 1.0.103 is the patched release already on main. Regenerates the Flatpak cargo-sources.json for the lock change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger checks (pull_request events for previous pushes were dropped) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger checks (pull_request events for previous pushes were dropped) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 18b6979 commit f9837b8

5 files changed

Lines changed: 544 additions & 39 deletions

File tree

src/app/mod.rs

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::collections::{HashMap, HashSet};
22
use std::sync::mpsc::Receiver;
33
use std::sync::{Arc, Mutex};
4-
use std::time::Instant;
4+
use std::time::{Duration, Instant};
55

66
use iced::widget::{button, container, row, scrollable, space, text};
77
use iced::{Element, Length, Point, Subscription, Task, Theme};
@@ -18,10 +18,14 @@ use crate::ui::side_panel::{PanelAnim, PanelFlourish};
1818
use crate::ui::sound_grid;
1919
use crate::ui::theme::{self, Hh};
2020
use crate::ui::{now_playing, search_bar, slot_manager};
21+
use notices::{Notice, NoticeId, NoticeQueue};
2122

2223
/// Play-dispatch coordination (`request_play` / `handle_decoded` /
2324
/// `start_playback`), extracted to keep this file from growing (#151).
2425
mod macros;
26+
#[cfg(test)]
27+
mod notice_tests;
28+
pub(crate) mod notices;
2529
/// Panel animation state transitions extracted from the Iced update loop (#144).
2630
mod panels;
2731
mod playback;
@@ -55,6 +59,9 @@ pub enum Message {
5559
TrayEvent(TrayEvent),
5660
TrayPoll,
5761
AudioEvent(AudioEvent),
62+
RaiseNotice(Notice),
63+
DismissNotice(NoticeId),
64+
NoticeTick(Instant),
5865
PlaySound(String),
5966
StopAll,
6067
StartRecording,
@@ -210,9 +217,8 @@ pub struct HonkHonk {
210217
pub monitor_devices: Vec<(String, String)>,
211218
pub input_devices: Vec<(String, String)>,
212219
shortcut_config: crate::shortcuts::config_ui::ShortcutConfigService,
213-
/// One-time notice surfaced on first run when the persistent virtual mic was
214-
/// created programmatically (issue #49). `None` until `SourceFirstRun` fires.
215-
source_notice: Option<String>,
220+
/// User-visible in-window notices raised from app/audio events.
221+
notices: NoticeQueue,
216222
/// Per-sound metadata: favorites, per-sound volume, display names.
217223
pub(crate) sound_meta: SoundMetaStore,
218224
/// Master persistence switch. When `false`, every disk write —
@@ -431,7 +437,7 @@ impl HonkHonk {
431437
monitor_devices: Vec::new(),
432438
input_devices: Vec::new(),
433439
shortcut_config: crate::shortcuts::config_ui::ShortcutConfigService::new(),
434-
source_notice: None,
440+
notices: NoticeQueue::new(),
435441
sound_meta: SoundMetaStore::load(),
436442
persist: true,
437443
config_load_failed: false,
@@ -493,7 +499,7 @@ impl HonkHonk {
493499
monitor_devices: Vec::new(),
494500
input_devices: Vec::new(),
495501
shortcut_config: crate::shortcuts::config_ui::ShortcutConfigService::new(),
496-
source_notice: None,
502+
notices: NoticeQueue::new(),
497503
sound_meta: SoundMetaStore::default(),
498504
persist: false,
499505
config_load_failed: false,
@@ -610,11 +616,8 @@ impl HonkHonk {
610616
self.shortcuts_warning_dismissed
611617
}
612618

613-
/// First-run persistent-mic notice text, if one was surfaced this session
614-
/// (issue #49). Returns `None` until a `SourceFirstRun` event fires. A UI
615-
/// banner can render this; rendering is intentionally out of scope here.
616-
pub fn source_notice(&self) -> Option<&str> {
617-
self.source_notice.as_deref()
619+
pub(crate) fn notices(&self) -> &NoticeQueue {
620+
&self.notices
618621
}
619622

620623
pub fn sound_meta(&self) -> &SoundMetaStore {
@@ -748,11 +751,14 @@ impl HonkHonk {
748751
}
749752
AudioEvent::Error(e) => {
750753
tracing::error!(error = %e, "audio error");
754+
self.notices
755+
.push(Notice::error("Audio error", e.to_string()), Instant::now());
751756
}
752757
AudioEvent::SourceFirstRun { confd_written } => {
753-
let notice = source_first_run_notice(confd_written);
754-
tracing::info!(notice = %notice, "source first-run notice");
755-
self.source_notice = Some(notice);
758+
let body = source_first_run_notice(confd_written);
759+
tracing::info!(notice = %body, "source first-run notice");
760+
self.notices
761+
.push(Notice::info("HonkHonk Mic created", body), Instant::now());
756762
}
757763
AudioEvent::OutputDevicesChanged(devices) => {
758764
if let Some(ref target) = self.config.monitor_device.clone() {
@@ -796,6 +802,18 @@ impl HonkHonk {
796802
}
797803
Task::none()
798804
}
805+
Message::RaiseNotice(notice) => {
806+
self.notices.push(notice, Instant::now());
807+
Task::none()
808+
}
809+
Message::DismissNotice(id) => {
810+
self.notices.dismiss(id);
811+
Task::none()
812+
}
813+
Message::NoticeTick(now) => {
814+
self.notices.expire(now);
815+
Task::none()
816+
}
799817
Message::PlaySound(sound_id) => {
800818
if let Some(sound) = self.sounds.iter().find(|s| s.id == sound_id).cloned() {
801819
self.request_play(&sound, false)
@@ -1451,8 +1469,7 @@ impl HonkHonk {
14511469
pub fn subscription(&self) -> Subscription<Message> {
14521470
let shortcuts = Subscription::run(shortcuts_stream_sub_none);
14531471

1454-
let tray_poll =
1455-
iced::time::every(std::time::Duration::from_millis(100)).map(|_| Message::TrayPoll);
1472+
let tray_poll = iced::time::every(Duration::from_millis(100)).map(|_| Message::TrayPoll);
14561473

14571474
let events = iced::event::listen_with(|event, _, _window_id| match event {
14581475
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
@@ -1492,6 +1509,10 @@ impl HonkHonk {
14921509
subs.push(iced::window::frames().map(Message::Frame));
14931510
}
14941511

1512+
if self.notices.has_expiring() {
1513+
subs.push(iced::time::every(Duration::from_millis(250)).map(Message::NoticeTick));
1514+
}
1515+
14951516
Subscription::batch(subs)
14961517
}
14971518

@@ -1669,7 +1690,7 @@ impl HonkHonk {
16691690
}
16701691

16711692
pub fn view(&self) -> Element<'_, Message> {
1672-
match self.view_mode {
1693+
let base = match self.view_mode {
16731694
ViewMode::Main => self.view_main(),
16741695
ViewMode::SlotManager => {
16751696
let t = self.config.theme;
@@ -1685,7 +1706,19 @@ impl HonkHonk {
16851706
)
16861707
}
16871708
ViewMode::Settings => crate::ui::settings::view_settings(self, self.config.theme),
1709+
};
1710+
1711+
let mut layers = vec![base];
1712+
if let Some(notice_layer) =
1713+
crate::ui::notice::view_notice_layer(self.notices(), self.config.theme)
1714+
{
1715+
layers.push(notice_layer);
16881716
}
1717+
1718+
iced::widget::Stack::with_children(layers)
1719+
.width(Length::Fill)
1720+
.height(Length::Fill)
1721+
.into()
16891722
}
16901723
}
16911724

@@ -1854,28 +1887,6 @@ mod tests {
18541887
assert!(!app.now_playing.has_playhead());
18551888
}
18561889

1857-
#[test]
1858-
fn source_first_run_written_sets_persistent_notice() {
1859-
let mut app = HonkHonk::new_for_test();
1860-
assert!(app.source_notice().is_none());
1861-
let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
1862-
confd_written: true,
1863-
}));
1864-
let notice = app.source_notice().expect("notice set");
1865-
assert!(notice.contains("persist"));
1866-
assert!(notice.contains("HonkHonk Mic"));
1867-
}
1868-
1869-
#[test]
1870-
fn source_first_run_not_written_sets_session_notice() {
1871-
let mut app = HonkHonk::new_for_test();
1872-
let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
1873-
confd_written: false,
1874-
}));
1875-
let notice = app.source_notice().expect("notice set");
1876-
assert!(notice.contains("this session"));
1877-
}
1878-
18791890
#[test]
18801891
fn audio_event_playback_started_sets_playing() {
18811892
let mut app = HonkHonk::new_for_test();

src/app/notice_tests.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
use std::time::Instant;
2+
3+
use super::notices::{Notice, NoticeLevel};
4+
use super::{HonkHonk, Message};
5+
use crate::audio::{AudioEvent, EngineErrorEvent};
6+
7+
#[test]
8+
fn source_first_run_written_queues_persistent_notice() {
9+
let mut app = HonkHonk::new_for_test();
10+
assert!(app.notices().is_empty());
11+
12+
let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
13+
confd_written: true,
14+
}));
15+
16+
let notice = app.notices().front().expect("notice set");
17+
assert_eq!(notice.notice.level, NoticeLevel::Info);
18+
assert!(notice.notice.body.contains("persist"));
19+
assert!(notice.notice.body.contains("HonkHonk Mic"));
20+
}
21+
22+
#[test]
23+
fn source_first_run_not_written_queues_session_notice() {
24+
let mut app = HonkHonk::new_for_test();
25+
26+
let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
27+
confd_written: false,
28+
}));
29+
30+
let notice = app.notices().front().expect("notice set");
31+
assert_eq!(notice.notice.level, NoticeLevel::Info);
32+
assert!(notice.notice.body.contains("this session"));
33+
}
34+
35+
#[test]
36+
fn raise_notice_message_queues_notice() {
37+
let mut app = HonkHonk::new_for_test();
38+
39+
let _ = app.update(Message::RaiseNotice(Notice::warning(
40+
"Shortcut unavailable",
41+
"The portal is not running.",
42+
)));
43+
44+
let notice = app.notices().front().expect("notice queued");
45+
assert_eq!(notice.notice.level, NoticeLevel::Warning);
46+
assert_eq!(notice.notice.title, "Shortcut unavailable");
47+
}
48+
49+
#[test]
50+
fn audio_error_event_queues_persistent_error_notice() {
51+
let mut app = HonkHonk::new_for_test();
52+
53+
let _ = app.update(Message::AudioEvent(AudioEvent::Error(
54+
EngineErrorEvent::VirtualSinkNotRegistered,
55+
)));
56+
57+
let notice = app.notices().front().expect("notice queued");
58+
assert_eq!(notice.notice.level, NoticeLevel::Error);
59+
assert_eq!(notice.notice.title, "Audio error");
60+
assert!(notice.notice.body.contains("virtual sink"));
61+
let id = notice.id;
62+
63+
let _ = app.update(Message::NoticeTick(
64+
Instant::now() + Notice::DEFAULT_TIMEOUT * 4,
65+
));
66+
assert!(app.notices().front().is_some());
67+
68+
let _ = app.update(Message::DismissNotice(id));
69+
assert!(app.notices().is_empty());
70+
}

0 commit comments

Comments
 (0)