Skip to content

Commit 7d3cbad

Browse files
gajopclaude
andcommitted
Add the top-left project status bar
Reproduce the chiliui top-left menu natively, always available and no longer needing the launcher: the project location plus Open Project, Data Dir, Upload Log and Exit. Folder actions open the system file manager (as the launcher's shell.openPath did); Upload Log posts the infolog to logs.springrts.com off-thread and copies the returned URL. The feature stays self-contained under project/status_bar: the panel document owns one `#project-status-root` div and the manager only renders the caption and forwards clicks. A golden covers the bar; note this bar now appears behind full-screen dialog goldens, which will need re-approval. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f799ece commit 7d3cbad

11 files changed

Lines changed: 435 additions & 0 deletions

File tree

‎native/src/sbc/panels/manager.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::sbc::panels::modal_stack::ModalStack;
2525
use crate::sbc::panels::view::{PanelView, ShellEvent};
2626
use crate::sbc::port_flags::{self, UiImpl};
2727
use crate::sbc::project::new_project_dialog::NewProjectDialog;
28+
use crate::sbc::project::ProjectStatusBar;
2829
use crate::sbc::states::{StateManager, StateRequest};
2930

3031
inventory::submit! {
@@ -48,6 +49,8 @@ pub(crate) struct PanelManager {
4849
brush: BrushSync,
4950
/// The useful native replacement for the engine's "No tooltip defined" box.
5051
cursor_tip: CursorTip,
52+
/// Top-left project location + always-available launcher actions.
53+
status_bar: ProjectStatusBar,
5154
/// A field whose edit just opened: focus + select it next tick, after the
5255
/// RmlUi update has processed the input's unhide (same-frame focus on a
5356
/// just-unhidden element is rejected).
@@ -98,6 +101,7 @@ impl PanelManager {
98101
hotkeys: ActionDispatcher::default(),
99102
brush: BrushSync::default(),
100103
cursor_tip: CursorTip::default(),
104+
status_bar: ProjectStatusBar::default(),
101105
pending_select: None,
102106
pending_commands: Vec::new(),
103107
}
@@ -115,6 +119,7 @@ impl PanelManager {
115119
self.session.reset();
116120
self.modals.forget_bindings();
117121
self.hotkeys.clear();
122+
self.status_bar.forget();
118123
self.pending_select = None;
119124
self.input.reset();
120125
self.view.set_active_editor(&self.interface, None)?;
@@ -203,7 +208,10 @@ impl PanelManager {
203208
self.slot
204209
.sync_state_selection(&self.interface, &self.view, models);
205210
self.update_cursor_tip(models.get::<ChonsoleManager>().visible())?;
211+
let status_commands = self.status_bar.process(&self.interface, models);
212+
self.pending_commands.extend(status_commands);
206213
if let Some(doc) = self.view.document_handle() {
214+
self.status_bar.render(&self.interface, doc, models)?;
207215
if models.get::<NotificationManager>().tick() {
208216
models
209217
.get::<NotificationManager>()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* Top-left project status bar: the project location plus the always-available
2+
Open Project / Data Dir / Upload Log / Exit actions. A screen-edge surface
3+
pinned to the top-left corner, over the map. */
4+
.project-status-float {
5+
position: absolute;
6+
left: 8dp;
7+
top: 8dp;
8+
width: 420dp;
9+
background-color: #2a2a2ae0;
10+
border: 1dp #555555;
11+
border-radius: 4dp;
12+
padding: 6dp 8dp;
13+
/* Only the buttons take clicks; the rest of the corner passes through to
14+
the map underneath, the way the notifications strip does. */
15+
pointer-events: none;
16+
}
17+
18+
.project-status-label {
19+
display: block;
20+
color: #c8d0e0;
21+
font-size: 13dp;
22+
margin-bottom: 6dp;
23+
}
24+
25+
.project-status-buttons {
26+
display: block;
27+
}
28+
29+
.project-status-btn {
30+
display: inline-block;
31+
margin-right: 5dp;
32+
padding: 4dp 9dp;
33+
color: #ffffff;
34+
font-size: 12dp;
35+
background-color: #3a3f4b;
36+
border: 1dp #555555;
37+
border-radius: 3dp;
38+
pointer-events: auto;
39+
}
40+
41+
.project-status-btn:hover {
42+
background-color: #4a5160;
43+
}
44+
45+
.project-status-btn.danger {
46+
background-color: #6a2a2a;
47+
}
48+
49+
.project-status-btn.danger:hover {
50+
background-color: #8a3535;
51+
}

‎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="project-status-root" class="project-status-float"></div>
1112
<div id="notifications-root" class="notifications-float"></div>
1213
<!-- Panel-control and map-object tips have independent lifecycles. Sharing one
1314
element lets the map picker hide a grid tooltip after its hover event. -->

‎native/src/sbc/panels/view.rs‎

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

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

‎native/src/sbc/project/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ pub(crate) mod model;
55
pub(crate) mod new_project_dialog;
66
pub(crate) mod ops;
77
pub(crate) mod paths;
8+
pub(crate) mod status_bar;
89
mod tests;
910
mod ui;
1011

1112
pub(crate) use model::project_manager::{ProjectData, ProjectManager};
1213
pub(crate) use model::scenario_info_manager::{ScenarioInfo, ScenarioInfoManager};
1314
pub(crate) use model::screenshot_manager::ScreenshotManager;
15+
pub(crate) use status_bar::ProjectStatusBar;
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
//! The top-left project status bar: it shows where the current project lives
2+
//! and exposes the always-available actions the launcher used to own (Exit,
3+
//! open the project folder, open the write-data dir, upload the log).
4+
//!
5+
//! Modularity: the higher level owns a single `#project-status-root` div in the
6+
//! panel document; everything else — markup, wiring, and the effects — lives
7+
//! here. The panel manager only renders the caption each frame and forwards any
8+
//! queued actions, so this feature stays self-contained.
9+
10+
mod upload;
11+
12+
use std::cell::RefCell;
13+
use std::path::{Path, PathBuf};
14+
use std::rc::Rc;
15+
16+
use spring_native::prelude::{Error, NativeInterfaceRef};
17+
18+
use crate::sbc::command_system::command::Command;
19+
use crate::sbc::command_system::model::Models;
20+
use crate::sbc::notifications::NotificationManager;
21+
use crate::sbc::project::ProjectManager;
22+
use crate::sbc::rml::{element_by_id, escape_rml};
23+
24+
pub(crate) use upload::UploadLogCommand;
25+
26+
const ROOT_ID: &str = "project-status-root";
27+
const LABEL_ID: &str = "project-status-label";
28+
29+
#[derive(Clone, Copy)]
30+
enum StatusBarAction {
31+
Exit,
32+
OpenProject,
33+
DataDir,
34+
UploadLog,
35+
}
36+
37+
/// One button in the bar: its element id and the action it queues.
38+
const BUTTONS: [(&str, &str, StatusBarAction); 4] = [
39+
(
40+
"project-status-open",
41+
"Open Project",
42+
StatusBarAction::OpenProject,
43+
),
44+
("project-status-data", "Data Dir", StatusBarAction::DataDir),
45+
(
46+
"project-status-upload",
47+
"Upload Log",
48+
StatusBarAction::UploadLog,
49+
),
50+
("project-status-exit", "Exit", StatusBarAction::Exit),
51+
];
52+
53+
#[derive(Default)]
54+
pub(crate) struct ProjectStatusBar {
55+
actions: Rc<RefCell<Vec<StatusBarAction>>>,
56+
/// Set once the markup and listeners are installed in the live document.
57+
/// Cleared by `forget` when a reload throws that document away.
58+
bound: bool,
59+
last_caption: Option<String>,
60+
}
61+
62+
impl ProjectStatusBar {
63+
/// The document was destroyed (reload): drop every cached bit of state so
64+
/// the next `render` rebuilds against the fresh document.
65+
pub(crate) fn forget(&mut self) {
66+
self.bound = false;
67+
self.last_caption = None;
68+
self.actions.borrow_mut().clear();
69+
}
70+
71+
/// Install the bar the first time, then keep its caption in sync.
72+
pub(crate) fn render(
73+
&mut self,
74+
interface: &NativeInterfaceRef,
75+
document: u64,
76+
models: &mut Models,
77+
) -> Result<(), Error> {
78+
if !self.bound {
79+
self.build(interface, document)?;
80+
self.bound = true;
81+
}
82+
let caption = caption(models);
83+
if self.last_caption.as_deref() != Some(caption.as_str()) {
84+
if let Some(label) = element_by_id(interface, document, LABEL_ID) {
85+
interface
86+
.rml_ui()
87+
.element_set_inner_rml(label, &escape_rml(&caption))?;
88+
}
89+
self.last_caption = Some(caption);
90+
}
91+
Ok(())
92+
}
93+
94+
/// Run whatever the user clicked. Synchronous effects happen here; the log
95+
/// upload is returned as a command so it can queue background IO.
96+
pub(crate) fn process(
97+
&mut self,
98+
interface: &NativeInterfaceRef,
99+
models: &mut Models,
100+
) -> Vec<Box<dyn Command>> {
101+
let mut commands: Vec<Box<dyn Command>> = Vec::new();
102+
for action in self.actions.borrow_mut().drain(..) {
103+
match action {
104+
StatusBarAction::Exit => {
105+
let _ = interface.messages().send_commands("quitforce", "");
106+
}
107+
StatusBarAction::OpenProject => match models.get::<ProjectManager>().path() {
108+
Some(path) => open_in_file_manager(Path::new(path)),
109+
None => models.get::<NotificationManager>().warn(
110+
"project-folder",
111+
"Save the project first to open its folder",
112+
),
113+
},
114+
StatusBarAction::DataDir => open_in_file_manager(&write_dir()),
115+
StatusBarAction::UploadLog => {
116+
models.get::<NotificationManager>().progress(
117+
"upload-log",
118+
0.1,
119+
"Uploading log...",
120+
);
121+
commands.push(Box::new(UploadLogCommand::new(log_path())));
122+
}
123+
}
124+
}
125+
commands
126+
}
127+
128+
fn build(&self, interface: &NativeInterfaceRef, document: u64) -> Result<(), Error> {
129+
let Some(root) = element_by_id(interface, document, ROOT_ID) else {
130+
return Ok(());
131+
};
132+
let mut html = format!(r#"<div class="project-status-label" id="{LABEL_ID}"></div>"#);
133+
html.push_str(r#"<div class="project-status-buttons">"#);
134+
for (id, caption, _) in BUTTONS {
135+
let danger = if id == "project-status-exit" {
136+
" danger"
137+
} else {
138+
""
139+
};
140+
html.push_str(&format!(
141+
r#"<button id="{id}" class="project-status-btn{danger}">{caption}</button>"#
142+
));
143+
}
144+
html.push_str("</div>");
145+
interface.rml_ui().element_set_inner_rml(root, &html)?;
146+
147+
for (id, _, action) in BUTTONS {
148+
let Some(button) = element_by_id(interface, document, id) else {
149+
continue;
150+
};
151+
let queue = self.actions.clone();
152+
interface
153+
.rml_ui()
154+
.element_add_event_listener(button, "click", false, move || {
155+
queue.borrow_mut().push(action);
156+
})?;
157+
}
158+
Ok(())
159+
}
160+
}
161+
162+
/// The engine's write-data dir, taken from its own `--write-dir` argv (the
163+
/// plugin shares the engine process). Falls back to the working directory.
164+
pub(crate) fn write_dir() -> PathBuf {
165+
let mut args = std::env::args();
166+
while let Some(arg) = args.next() {
167+
if let Some(rest) = arg.strip_prefix("--write-dir=") {
168+
return PathBuf::from(rest);
169+
}
170+
if arg == "--write-dir" {
171+
if let Some(next) = args.next() {
172+
return PathBuf::from(next);
173+
}
174+
}
175+
}
176+
std::env::current_dir().unwrap_or_default()
177+
}
178+
179+
fn caption(models: &mut Models) -> String {
180+
match models.get::<ProjectManager>().path() {
181+
Some(path) => format!("Project: {path}"),
182+
None => "Project not saved".to_string(),
183+
}
184+
}
185+
186+
fn log_path() -> PathBuf {
187+
write_dir().join("infolog.txt")
188+
}
189+
190+
fn open_in_file_manager(path: &Path) {
191+
let opener = if cfg!(target_os = "macos") {
192+
"open"
193+
} else if cfg!(target_os = "windows") {
194+
"explorer"
195+
} else {
196+
"xdg-open"
197+
};
198+
if let Err(err) = std::process::Command::new(opener).arg(path).spawn() {
199+
log::warn!("could not open {}: {err}", path.display());
200+
}
201+
}

0 commit comments

Comments
 (0)