Skip to content

Commit aeade45

Browse files
gajopclaude
andcommitted
Native toast notifications, replacing Chotify
The rust UI had no way to surface warnings or progress, so pressing Export with no project failed silently. Add a NotificationManager model that commands post to (warn / progress); the panel ticks it each frame and renders live toasts into a floating #notifications-root strip over the map, matching the Lua RmlUiNotifications look. Timed toasts expire on the wall clock. Wired to the export pipeline, the one place Chotify was used: - Export with no saved project posts a warning up front (from the Export action, before opening the dialog, as Lua's SB.NotifyWarn does). - Export posts progress on start and, from the archive job's completion, a "Finished" or a failure warning. E2E: export_warning asserts the toast appears on the Export click and expires. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 36d56ec commit aeade45

10 files changed

Lines changed: 272 additions & 1 deletion

File tree

native/src/sbc/actions/run.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::sbc::command_system::command::{Command, CompoundCommand};
1818
use crate::sbc::command_system::model::Models;
1919
use crate::sbc::command_system::{RedoCommand, UndoCommand};
2020
use crate::sbc::heightmap::commands::{ExportHeightmapCommand, ImportHeightmapCommand};
21+
use crate::sbc::notifications::NotificationManager;
2122
use crate::sbc::objects::{ObjectKind, ObjectManager, RemoveObjectCommand, SelectionManager};
2223
use crate::sbc::project::commands::{
2324
ExportMapInfoCommand, ExportMapsCommand, ExportS11NCommand, ExportSpringArchiveCommand,
@@ -113,6 +114,15 @@ pub fn execute(
113114
}
114115

115116
Action::Export => {
117+
// Nothing to export without a saved project; warn up front rather
118+
// than opening the dialog and failing on OK, as Lua's export_action
119+
// does with SB.NotifyWarn.
120+
if models.get::<ProjectManager>().path().is_none() {
121+
models
122+
.get::<NotificationManager>()
123+
.warn("export", "The project must be saved before exporting");
124+
return ActionResult::None;
125+
}
116126
let config = FileDialogConfig {
117127
title: "Export".to_string(),
118128
root_dir: EXPORTS_DIR.to_string(),

native/src/sbc/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub(crate) mod grass;
1010
pub(crate) mod heightmap;
1111
mod map_settings;
1212
pub(crate) mod metal;
13+
mod notifications;
1314
mod objects;
1415
mod panels;
1516
mod port_flags;
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
}

native/src/sbc/panels/manager.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::sbc::chonsole::ChonsoleManager;
77
use crate::sbc::command_system::command::Command;
88
use crate::sbc::command_system::history::HistoryEvent;
99
use crate::sbc::command_system::model::{Model, ModelFactory, Models};
10+
use crate::sbc::notifications::NotificationManager;
1011
use crate::sbc::panels::action_dispatcher::ActionDispatcher;
1112
use crate::sbc::panels::brush_sync::BrushSync;
1213
use crate::sbc::panels::controls::asset_picker::AssetPicker;
@@ -202,6 +203,13 @@ impl PanelManager {
202203
self.slot
203204
.sync_state_selection(&self.interface, &self.view, models);
204205
self.update_cursor_tip(models.get::<ChonsoleManager>().visible())?;
206+
if let Some(doc) = self.view.document_handle() {
207+
if models.get::<NotificationManager>().tick() {
208+
models
209+
.get::<NotificationManager>()
210+
.render(&self.interface, doc)?;
211+
}
212+
}
205213
self.view.update(&self.interface)
206214
}
207215

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/* Toast notifications: the native replacement for Chotify. A floating strip
2+
just below the top controls, over the map; pointer-events pass through so the
3+
map stays clickable underneath. */
4+
.notifications-float {
5+
position: absolute;
6+
left: 25%;
7+
top: 70dp;
8+
width: 40%;
9+
height: 40%;
10+
padding: 0px;
11+
background-color: transparent;
12+
pointer-events: none;
13+
}
14+
15+
.notification {
16+
display: block;
17+
margin-bottom: 6dp;
18+
padding: 8dp 10dp;
19+
background-color: #2a2a2ae0;
20+
border: 1dp #555555;
21+
border-radius: 4dp;
22+
}
23+
24+
.notification-title {
25+
display: block;
26+
color: #ffffff;
27+
font-size: 15dp;
28+
margin-bottom: 4dp;
29+
}
30+
31+
.notification-title.warning {
32+
color: #fff501;
33+
}
34+
35+
.notification-body {
36+
display: block;
37+
color: #c8d0e0;
38+
font-size: 13dp;
39+
}
40+
41+
.notification-progress {
42+
display: block;
43+
height: 8dp;
44+
margin-top: 6dp;
45+
background-color: #1a1a1a;
46+
border: 1dp #555555;
47+
}
48+
49+
.notification-progress-fill {
50+
display: block;
51+
height: 100%;
52+
background-color: #01b414;
53+
}

native/src/sbc/panels/ui.rml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<div id="map-shading-modal"></div>
99
<div id="team-edit-modal"></div>
1010
<div id="modal-root"></div>
11+
<div id="notifications-root" class="notifications-float"></div>
1112
<!-- Panel-control and map-object tips have independent lifecycles. Sharing one
1213
element lets the map picker hide a grid tooltip after its hover event. -->
1314
<div id="native-panel-tooltip" class="native-tooltip hidden"></div>

native/src/sbc/panels/view.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const UI_STYLE: &str = concat!(
2121
include_str!("theme/buttons.rcss"),
2222
include_str!("theme/modals.rcss"),
2323
include_str!("theme/grid.rcss"),
24+
include_str!("theme/notifications.rcss"),
2425
);
2526

2627
/// A shell-level click, queued by an RmlUi event listener and drained by the

native/src/sbc/project/commands/export_spring_archive_command.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::sbc::command_system::context::Context;
77
use crate::sbc::command_system::io_completion;
88
use crate::sbc::command_system::registry::register_command;
99
use crate::sbc::compile::ops::compiler_path;
10+
use crate::sbc::notifications::NotificationManager;
1011
use crate::sbc::project::io_registries::export::{self, MapExportOptions};
1112
use crate::sbc::project::jobs::archive_export::ExportSpringArchiveJob;
1213
use crate::sbc::project::ops::{archive_assets, lua_writer, map_info, model_codec};
@@ -41,11 +42,15 @@ impl Command for ExportSpringArchiveCommand {
4142
fn execute(&mut self, ctx: &mut Context) {
4243
let Some(project_path) = self.project_path(ctx) else {
4344
log::error!("ExportSpringArchiveCommand: missing project path");
45+
ctx.model::<NotificationManager>()
46+
.warn("export", "The project must be saved before exporting");
4447
io_completion::submit_native_command_completed(ctx);
4548
return;
4649
};
4750
let project_path = path_under_write(self.write_path.as_deref(), &project_path);
4851
let project_name = self.project_name(ctx);
52+
ctx.model::<NotificationManager>()
53+
.progress("export", 0.1, "Exporting archive...");
4954
let build_dir = std::env::temp_dir().join(format!(
5055
"sbc-export-{}-{}",
5156
std::process::id(),

native/src/sbc/project/jobs/archive_export.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::path::PathBuf;
33
use log::{error, info};
44

55
use crate::sbc::io::io_api::{IoJob, IoOutcome};
6+
use crate::sbc::notifications::NotificationManager;
67
use crate::sbc::project::ops::spring_archive::{self, Spec};
78
use crate::sbc::sbc::SBC;
89

@@ -51,13 +52,17 @@ enum ExportSpringArchiveOutcome {
5152
}
5253

5354
impl IoOutcome for ExportSpringArchiveOutcome {
54-
fn apply(self: Box<Self>, _sbc: &mut SBC) {
55+
fn apply(self: Box<Self>, sbc: &mut SBC) {
5556
match *self {
5657
ExportSpringArchiveOutcome::Done { output_path } => {
5758
info!("spring archive exported: {}", output_path.display());
59+
sbc.model::<NotificationManager>()
60+
.progress("export", 1.0, "Export finished");
5861
}
5962
ExportSpringArchiveOutcome::Failed { reason } => {
6063
error!("spring archive export failed: {reason}");
64+
sbc.model::<NotificationManager>()
65+
.warn("export", &format!("Export failed: {reason}"));
6166
}
6267
}
6368
}

tools/e2e/scenarios/shell.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,27 @@ def notifications(run_state: E2ERun) -> None:
172172
# expiry cannot be driven off game seconds).
173173
run_state.move(left - 200, 400, delay=4.0)
174174
run_state.screenshot("expired")
175+
176+
177+
@scenario()
178+
def export_warning(run_state: E2ERun) -> None:
179+
"""Export with no saved project posts a warning toast in the native UI.
180+
181+
The native replacement for Chotify: the toast appears immediately on the
182+
Export click (no dialog), floats over the map, and expires on its own.
183+
"""
184+
run_state.focus()
185+
left = panel_left(run_state)
186+
width, height = window_size(run_state)
187+
# The toast strip: top-centre, over the map (left of the 500-wide panel).
188+
strip = (width // 4, 55, width // 2, 220)
189+
190+
before = run_state.screenshot("before")
191+
run_state.click(*panel_point(left, TOOLBAR["export"]), delay=0.6)
192+
warning = run_state.screenshot("warning")
193+
run_state.assert_region_pixels(before, warning, strip, min_changed=300)
194+
195+
# It carries its own timer (~4s) and clears without any interaction.
196+
run_state.move(left - 200, height // 2, delay=5.0)
197+
expired = run_state.screenshot("expired")
198+
run_state.assert_region_pixels(warning, expired, strip, min_changed=300)

0 commit comments

Comments
 (0)