Skip to content

Commit b8ea7df

Browse files
gajopclaude
andcommitted
Keep mod.rs declaration-only; lint it
mod.rs should wire submodules and re-export, never carry code. Move the notification manager to notifications/manager.rs and the status bar to project/status_bar/bar.rs, leaving both mod.rs files as `mod`/`pub use` only. Add lint-mod-only-declares (the `fd mod.rs -x grep -ln fn` idea as a gate) and include it in `just lint`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6a15103 commit b8ea7df

6 files changed

Lines changed: 508 additions & 464 deletions

File tree

justfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,14 @@ lint-py-step-down:
4040
lint-no-dead-commands:
4141
python3 tools/lint/no_dead_commands.py --fail
4242

43+
# Fail when a mod.rs defines functions instead of only wiring submodules.
44+
[group('lint')]
45+
lint-mod-only-declares:
46+
python3 tools/lint/mod_only_declares.py
47+
4348
# Run all lints + native type-check (the one command to run before review).
4449
[group('lint')]
45-
lint: fmt clippy lint-lua lint-rust-step-down lint-py-step-down lint-no-dead-commands check
50+
lint: fmt clippy lint-lua lint-rust-step-down lint-py-step-down lint-no-dead-commands lint-mod-only-declares check
4651

4752
# Type-check the native crate.
4853
[group('build')]
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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 plain informational toast that auto-expires. Used for
42+
/// reassurance ("Project saved to …") rather than warnings or progress.
43+
pub(crate) fn info(&mut self, name: &str, title: &str, body: &str) {
44+
self.upsert(name, title, body, false, None, Some(Duration::from_secs(4)));
45+
}
46+
47+
/// Post or update a warning toast; it auto-expires after a few seconds.
48+
pub(crate) fn warn(&mut self, name: &str, body: &str) {
49+
self.upsert(
50+
name,
51+
"Warning",
52+
body,
53+
true,
54+
None,
55+
Some(Duration::from_secs(4)),
56+
);
57+
}
58+
59+
/// Post or update a progress toast (0.0..1.0). At >= 1.0 the progress toast
60+
/// is replaced by a brief "Finished" one, mirroring `SB.ActionProgress`.
61+
pub(crate) fn progress(&mut self, name: &str, value: f32, body: &str) {
62+
if value >= 1.0 {
63+
self.remove(name);
64+
self.upsert(
65+
&format!("{name}-done"),
66+
"Finished",
67+
body,
68+
false,
69+
None,
70+
Some(Duration::from_secs(3)),
71+
);
72+
} else {
73+
self.upsert(
74+
name,
75+
"Progress",
76+
body,
77+
false,
78+
Some(value.clamp(0.0, 1.0)),
79+
None,
80+
);
81+
}
82+
}
83+
84+
/// Expire timed-out toasts and report whether the toast strip changed since
85+
/// the last render (posts, updates, and expiries all count).
86+
pub(crate) fn tick(&mut self) -> bool {
87+
let now = Instant::now();
88+
let before = self.items.len();
89+
self.items
90+
.retain(|item| item.expires.is_none_or(|expiry| expiry > now));
91+
if self.items.len() != before {
92+
self.dirty = true;
93+
}
94+
std::mem::take(&mut self.dirty)
95+
}
96+
97+
pub(crate) fn render(
98+
&self,
99+
interface: &NativeInterfaceRef,
100+
document: u64,
101+
) -> Result<(), Error> {
102+
let Some(root) = element_by_id(interface, document, "notifications-root") else {
103+
return Ok(());
104+
};
105+
let mut html = String::new();
106+
for item in &self.items {
107+
let warning = if item.warning { " warning" } else { "" };
108+
html.push_str(&format!(
109+
concat!(
110+
r#"<div class="notification">"#,
111+
r#"<div class="notification-title{warning}">{title}</div>"#,
112+
r#"<div class="notification-body">{body}</div>"#,
113+
),
114+
warning = warning,
115+
title = escape_rml(&item.title),
116+
body = escape_rml(&item.body),
117+
));
118+
if let Some(progress) = item.progress {
119+
html.push_str(&format!(
120+
concat!(
121+
r#"<div class="notification-progress">"#,
122+
r#"<div class="notification-progress-fill" style="width: {pct}%;"></div></div>"#,
123+
),
124+
pct = (progress * 100.0).round() as i32,
125+
));
126+
}
127+
html.push_str("</div>");
128+
}
129+
interface.rml_ui().element_set_inner_rml(root, &html)?;
130+
Ok(())
131+
}
132+
133+
fn upsert(
134+
&mut self,
135+
name: &str,
136+
title: &str,
137+
body: &str,
138+
warning: bool,
139+
progress: Option<f32>,
140+
ttl: Option<Duration>,
141+
) {
142+
let expires = ttl.map(|d| Instant::now() + d);
143+
if let Some(existing) = self.items.iter_mut().find(|item| item.name == name) {
144+
existing.title = title.to_string();
145+
existing.body = body.to_string();
146+
existing.warning = warning;
147+
existing.progress = progress;
148+
existing.expires = expires;
149+
} else {
150+
self.items.push(Notification {
151+
name: name.to_string(),
152+
title: title.to_string(),
153+
body: body.to_string(),
154+
warning,
155+
progress,
156+
expires,
157+
});
158+
}
159+
self.dirty = true;
160+
}
161+
162+
fn remove(&mut self, name: &str) {
163+
let before = self.items.len();
164+
self.items.retain(|item| item.name != name);
165+
if self.items.len() != before {
166+
self.dirty = true;
167+
}
168+
}
169+
}
Lines changed: 2 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -1,169 +1,3 @@
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.
1+
mod manager;
72

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 plain informational toast that auto-expires. Used for
42-
/// reassurance ("Project saved to …") rather than warnings or progress.
43-
pub(crate) fn info(&mut self, name: &str, title: &str, body: &str) {
44-
self.upsert(name, title, body, false, None, Some(Duration::from_secs(4)));
45-
}
46-
47-
/// Post or update a warning toast; it auto-expires after a few seconds.
48-
pub(crate) fn warn(&mut self, name: &str, body: &str) {
49-
self.upsert(
50-
name,
51-
"Warning",
52-
body,
53-
true,
54-
None,
55-
Some(Duration::from_secs(4)),
56-
);
57-
}
58-
59-
/// Post or update a progress toast (0.0..1.0). At >= 1.0 the progress toast
60-
/// is replaced by a brief "Finished" one, mirroring `SB.ActionProgress`.
61-
pub(crate) fn progress(&mut self, name: &str, value: f32, body: &str) {
62-
if value >= 1.0 {
63-
self.remove(name);
64-
self.upsert(
65-
&format!("{name}-done"),
66-
"Finished",
67-
body,
68-
false,
69-
None,
70-
Some(Duration::from_secs(3)),
71-
);
72-
} else {
73-
self.upsert(
74-
name,
75-
"Progress",
76-
body,
77-
false,
78-
Some(value.clamp(0.0, 1.0)),
79-
None,
80-
);
81-
}
82-
}
83-
84-
fn upsert(
85-
&mut self,
86-
name: &str,
87-
title: &str,
88-
body: &str,
89-
warning: bool,
90-
progress: Option<f32>,
91-
ttl: Option<Duration>,
92-
) {
93-
let expires = ttl.map(|d| Instant::now() + d);
94-
if let Some(existing) = self.items.iter_mut().find(|item| item.name == name) {
95-
existing.title = title.to_string();
96-
existing.body = body.to_string();
97-
existing.warning = warning;
98-
existing.progress = progress;
99-
existing.expires = expires;
100-
} else {
101-
self.items.push(Notification {
102-
name: name.to_string(),
103-
title: title.to_string(),
104-
body: body.to_string(),
105-
warning,
106-
progress,
107-
expires,
108-
});
109-
}
110-
self.dirty = true;
111-
}
112-
113-
fn remove(&mut self, name: &str) {
114-
let before = self.items.len();
115-
self.items.retain(|item| item.name != name);
116-
if self.items.len() != before {
117-
self.dirty = true;
118-
}
119-
}
120-
121-
/// Expire timed-out toasts and report whether the toast strip changed since
122-
/// the last render (posts, updates, and expiries all count).
123-
pub(crate) fn tick(&mut self) -> bool {
124-
let now = Instant::now();
125-
let before = self.items.len();
126-
self.items
127-
.retain(|item| item.expires.is_none_or(|expiry| expiry > now));
128-
if self.items.len() != before {
129-
self.dirty = true;
130-
}
131-
std::mem::take(&mut self.dirty)
132-
}
133-
134-
pub(crate) fn render(
135-
&self,
136-
interface: &NativeInterfaceRef,
137-
document: u64,
138-
) -> Result<(), Error> {
139-
let Some(root) = element_by_id(interface, document, "notifications-root") else {
140-
return Ok(());
141-
};
142-
let mut html = String::new();
143-
for item in &self.items {
144-
let warning = if item.warning { " warning" } else { "" };
145-
html.push_str(&format!(
146-
concat!(
147-
r#"<div class="notification">"#,
148-
r#"<div class="notification-title{warning}">{title}</div>"#,
149-
r#"<div class="notification-body">{body}</div>"#,
150-
),
151-
warning = warning,
152-
title = escape_rml(&item.title),
153-
body = escape_rml(&item.body),
154-
));
155-
if let Some(progress) = item.progress {
156-
html.push_str(&format!(
157-
concat!(
158-
r#"<div class="notification-progress">"#,
159-
r#"<div class="notification-progress-fill" style="width: {pct}%;"></div></div>"#,
160-
),
161-
pct = (progress * 100.0).round() as i32,
162-
));
163-
}
164-
html.push_str("</div>");
165-
}
166-
interface.rml_ui().element_set_inner_rml(root, &html)?;
167-
Ok(())
168-
}
169-
}
3+
pub(crate) use manager::NotificationManager;

0 commit comments

Comments
 (0)