|
| 1 | +//! Toast notifications: the native replacement for Chotify / RmlUiNotifications. |
| 2 | +//! |
| 3 | +//! Commands post warnings and progress here (`warn` / `progress`); the panel |
| 4 | +//! ticks the manager each frame and renders the live toasts into |
| 5 | +//! `#notifications-root`, a floating strip in the panel document. Timed toasts |
| 6 | +//! expire on the wall clock, since the editor runs paused. |
| 7 | +
|
| 8 | +use std::any::Any; |
| 9 | +use std::time::{Duration, Instant}; |
| 10 | + |
| 11 | +use spring_native::prelude::{Error, NativeInterfaceRef}; |
| 12 | + |
| 13 | +use crate::sbc::command_system::model::{Model, ModelFactory}; |
| 14 | +use crate::sbc::rml::{element_by_id, escape_rml}; |
| 15 | + |
| 16 | +inventory::submit! { ModelFactory { make: |_iface| Box::new(NotificationManager::default()) } } |
| 17 | + |
| 18 | +struct Notification { |
| 19 | + /// Dedup key: posting the same name updates the existing toast in place. |
| 20 | + name: String, |
| 21 | + title: String, |
| 22 | + body: String, |
| 23 | + warning: bool, |
| 24 | + progress: Option<f32>, |
| 25 | + expires: Option<Instant>, |
| 26 | +} |
| 27 | + |
| 28 | +#[derive(Default)] |
| 29 | +pub(crate) struct NotificationManager { |
| 30 | + items: Vec<Notification>, |
| 31 | + dirty: bool, |
| 32 | +} |
| 33 | + |
| 34 | +impl Model for NotificationManager { |
| 35 | + fn as_any_mut(&mut self) -> &mut dyn Any { |
| 36 | + self |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +impl NotificationManager { |
| 41 | + /// Post or update a warning toast; it auto-expires after a few seconds. |
| 42 | + pub(crate) fn warn(&mut self, name: &str, body: &str) { |
| 43 | + self.upsert( |
| 44 | + name, |
| 45 | + "Warning", |
| 46 | + body, |
| 47 | + true, |
| 48 | + None, |
| 49 | + Some(Duration::from_secs(4)), |
| 50 | + ); |
| 51 | + } |
| 52 | + |
| 53 | + /// Post or update a progress toast (0.0..1.0). At >= 1.0 the progress toast |
| 54 | + /// is replaced by a brief "Finished" one, mirroring `SB.ActionProgress`. |
| 55 | + pub(crate) fn progress(&mut self, name: &str, value: f32, body: &str) { |
| 56 | + if value >= 1.0 { |
| 57 | + self.remove(name); |
| 58 | + self.upsert( |
| 59 | + &format!("{name}-done"), |
| 60 | + "Finished", |
| 61 | + body, |
| 62 | + false, |
| 63 | + None, |
| 64 | + Some(Duration::from_secs(3)), |
| 65 | + ); |
| 66 | + } else { |
| 67 | + self.upsert( |
| 68 | + name, |
| 69 | + "Progress", |
| 70 | + body, |
| 71 | + false, |
| 72 | + Some(value.clamp(0.0, 1.0)), |
| 73 | + None, |
| 74 | + ); |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + fn upsert( |
| 79 | + &mut self, |
| 80 | + name: &str, |
| 81 | + title: &str, |
| 82 | + body: &str, |
| 83 | + warning: bool, |
| 84 | + progress: Option<f32>, |
| 85 | + ttl: Option<Duration>, |
| 86 | + ) { |
| 87 | + let expires = ttl.map(|d| Instant::now() + d); |
| 88 | + if let Some(existing) = self.items.iter_mut().find(|item| item.name == name) { |
| 89 | + existing.title = title.to_string(); |
| 90 | + existing.body = body.to_string(); |
| 91 | + existing.warning = warning; |
| 92 | + existing.progress = progress; |
| 93 | + existing.expires = expires; |
| 94 | + } else { |
| 95 | + self.items.push(Notification { |
| 96 | + name: name.to_string(), |
| 97 | + title: title.to_string(), |
| 98 | + body: body.to_string(), |
| 99 | + warning, |
| 100 | + progress, |
| 101 | + expires, |
| 102 | + }); |
| 103 | + } |
| 104 | + self.dirty = true; |
| 105 | + } |
| 106 | + |
| 107 | + fn remove(&mut self, name: &str) { |
| 108 | + let before = self.items.len(); |
| 109 | + self.items.retain(|item| item.name != name); |
| 110 | + if self.items.len() != before { |
| 111 | + self.dirty = true; |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + /// Expire timed-out toasts and report whether the toast strip changed since |
| 116 | + /// the last render (posts, updates, and expiries all count). |
| 117 | + pub(crate) fn tick(&mut self) -> bool { |
| 118 | + let now = Instant::now(); |
| 119 | + let before = self.items.len(); |
| 120 | + self.items |
| 121 | + .retain(|item| item.expires.is_none_or(|expiry| expiry > now)); |
| 122 | + if self.items.len() != before { |
| 123 | + self.dirty = true; |
| 124 | + } |
| 125 | + std::mem::take(&mut self.dirty) |
| 126 | + } |
| 127 | + |
| 128 | + pub(crate) fn render( |
| 129 | + &self, |
| 130 | + interface: &NativeInterfaceRef, |
| 131 | + document: u64, |
| 132 | + ) -> Result<(), Error> { |
| 133 | + let Some(root) = element_by_id(interface, document, "notifications-root") else { |
| 134 | + return Ok(()); |
| 135 | + }; |
| 136 | + let mut html = String::new(); |
| 137 | + for item in &self.items { |
| 138 | + let warning = if item.warning { " warning" } else { "" }; |
| 139 | + html.push_str(&format!( |
| 140 | + concat!( |
| 141 | + r#"<div class="notification">"#, |
| 142 | + r#"<div class="notification-title{warning}">{title}</div>"#, |
| 143 | + r#"<div class="notification-body">{body}</div>"#, |
| 144 | + ), |
| 145 | + warning = warning, |
| 146 | + title = escape_rml(&item.title), |
| 147 | + body = escape_rml(&item.body), |
| 148 | + )); |
| 149 | + if let Some(progress) = item.progress { |
| 150 | + html.push_str(&format!( |
| 151 | + concat!( |
| 152 | + r#"<div class="notification-progress">"#, |
| 153 | + r#"<div class="notification-progress-fill" style="width: {pct}%;"></div></div>"#, |
| 154 | + ), |
| 155 | + pct = (progress * 100.0).round() as i32, |
| 156 | + )); |
| 157 | + } |
| 158 | + html.push_str("</div>"); |
| 159 | + } |
| 160 | + interface.rml_ui().element_set_inner_rml(root, &html)?; |
| 161 | + Ok(()) |
| 162 | + } |
| 163 | +} |
0 commit comments