Skip to content

Commit 2f9090a

Browse files
gajopclaude
andcommitted
Show a map thumbnail for each project in the Open dialog
Saving now grabs a screenshot of the map and stores it in the project, and the Open dialog renders it as the project's grid image, so projects are recognisable instead of bare folders (as Lua's screenshot.jpg does). SaveCommand records a screenshot request (ScreenshotManager); the next DrawScreen reads the framebuffer -- the only place it is valid -- and writes the map area (left of the panel) to <project>/sb_project_files/screenshot.jpg via gfx.save_image. list_assets sets a project directory's image to that file when it exists. E2E: project_thumbnail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ca4538d commit 2f9090a

7 files changed

Lines changed: 106 additions & 2 deletions

File tree

native/src/sbc/panels/controls/grid.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,10 +403,19 @@ pub(crate) fn list_assets(
403403
.into_iter()
404404
.map(|name| {
405405
let path = join_entry(dir, &name);
406+
// A project folder carries a map thumbnail saved on its last save;
407+
// show it so projects are recognisable in the Open dialog. Other
408+
// directories simply have no such file.
409+
let thumb = format!("{path}/sb_project_files/screenshot.jpg");
410+
let image = interface
411+
.vfs()
412+
.file_exists(&thumb)
413+
.unwrap_or(false)
414+
.then_some(thumb);
406415
GridItem {
407416
id: path,
408417
caption: name,
409-
image: None,
418+
image,
410419
is_directory: true,
411420
tooltip: None,
412421
tooltip_markup: None,

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ use crate::sbc::command_system::command::Command;
66
use crate::sbc::command_system::context::Context;
77
use crate::sbc::command_system::registry::register_command;
88
use crate::sbc::project::io_registries::save;
9+
use crate::sbc::project::paths::ProjectPaths;
10+
use crate::sbc::project::ScreenshotManager;
11+
12+
const SCREENSHOT_FILE: &str = "screenshot.jpg";
913

1014
#[derive(Deserialize, Debug)]
1115
pub struct SaveCommand {
@@ -25,7 +29,13 @@ impl SaveCommand {
2529

2630
impl Command for SaveCommand {
2731
fn execute(&mut self, ctx: &mut Context) {
28-
save::save_project(ctx, &PathBuf::from(&self.path), self.is_new_project);
32+
let root = PathBuf::from(&self.path);
33+
save::save_project(ctx, &root, self.is_new_project);
34+
// Grab a fresh map thumbnail for the Open dialog. The framebuffer can
35+
// only be read on the draw thread, so record the request and let the
36+
// next DrawScreen write it.
37+
let path = ProjectPaths::new(&root).file(SCREENSHOT_FILE);
38+
ctx.model::<ScreenshotManager>().request(path);
2939
}
3040

3141
fn undoable(&self) -> bool {

native/src/sbc/project/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ mod ui;
1010

1111
pub(crate) use model::project_manager::{ProjectData, ProjectManager};
1212
pub(crate) use model::scenario_info_manager::{ScenarioInfo, ScenarioInfoManager};
13+
pub(crate) use model::screenshot_manager::ScreenshotManager;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod project_manager;
22
pub mod scenario_info_manager;
3+
pub mod screenshot_manager;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
use std::any::Any;
2+
use std::path::PathBuf;
3+
4+
use crate::sbc::command_system::model::{Model, ModelFactory};
5+
6+
inventory::submit! { ModelFactory { make: |_iface| Box::new(ScreenshotManager::default()) } }
7+
8+
/// Carries a requested project screenshot from the save command (sim phase) to
9+
/// the draw phase, where the framebuffer can actually be read. A save asks for
10+
/// one; the next `DrawScreen` grabs the map and writes it, then clears the
11+
/// request. Mirrors Lua's `SB.RequestScreenshotPath` consumed in `PerformDraw`.
12+
#[derive(Default)]
13+
pub(crate) struct ScreenshotManager {
14+
pending: Option<PathBuf>,
15+
}
16+
17+
impl Model for ScreenshotManager {
18+
fn as_any_mut(&mut self) -> &mut dyn Any {
19+
self
20+
}
21+
}
22+
23+
impl ScreenshotManager {
24+
pub(crate) fn request(&mut self, path: PathBuf) {
25+
self.pending = Some(path);
26+
}
27+
28+
pub(crate) fn take(&mut self) -> Option<PathBuf> {
29+
self.pending.take()
30+
}
31+
}

native/src/sbc/sbc.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::sbc::devconsole::DevConsoleManager;
1212
use crate::sbc::io::io_api::IoWorker;
1313
use crate::sbc::objects::{event_bridge, ObjectKind, ObjectManager, SelectionManager};
1414
use crate::sbc::panels::PanelManager;
15+
use crate::sbc::project::ScreenshotManager;
1516
use crate::sbc::states::StateManager;
1617

1718
const MAX_UNDO_SIZE: usize = 100;
@@ -78,6 +79,7 @@ impl NativeModule for SBC {
7879
}
7980

8081
fn draw_screen(&mut self) -> Result<(), Error> {
82+
self.capture_pending_screenshot();
8183
self.model::<StateManager>().draw_screen();
8284
self.model::<ChonsoleManager>().draw_screen()?;
8385
self.model::<PanelManager>().draw_screen()
@@ -238,6 +240,36 @@ impl SBC {
238240
&self.interface
239241
}
240242

243+
/// Write a requested project thumbnail: the map area (left of the panel) of
244+
/// the current frame. Read at the top of DrawScreen, before the panel or any
245+
/// overlay is drawn over the map, since the framebuffer is only readable on
246+
/// the draw thread.
247+
fn capture_pending_screenshot(&mut self) {
248+
let Some(path) = self.model::<ScreenshotManager>().take() else {
249+
return;
250+
};
251+
let Ok(geom) = self.interface.display().get_view_geometry() else {
252+
return;
253+
};
254+
const PANEL_WIDTH: i32 = 500;
255+
let width = (geom.viewSizeX - PANEL_WIDTH).max(1);
256+
if let Some(parent) = path.parent() {
257+
let _ = std::fs::create_dir_all(parent);
258+
}
259+
// yflip: glReadPixels is bottom-origin; flip to top-origin image space.
260+
let _ = self.interface.gfx().save_image(
261+
0,
262+
0,
263+
width,
264+
geom.viewSizeY,
265+
&path.to_string_lossy(),
266+
false,
267+
true,
268+
false,
269+
0,
270+
);
271+
}
272+
241273
/// Apply any finished background-IO outcomes on the engine thread. Called
242274
/// each tick; also driven by the in-engine tests while they block the tick.
243275
pub(crate) fn drain_io(&mut self) {

tools/e2e/scenarios/gallery.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,26 @@ def project_save_as(run_state: E2ERun) -> None:
426426
run_state.screenshot("after-reload")
427427

428428

429+
@scenario()
430+
def project_thumbnail(run_state: E2ERun) -> None:
431+
"""Saving captures a map thumbnail that the Open dialog shows.
432+
433+
Save As saves the project (grabbing a screenshot of the map on the draw
434+
after the save) and reloads into it; the Open dialog then renders that
435+
screenshot as the project's grid image instead of a bare folder.
436+
"""
437+
left = panel_left(run_state)
438+
run_state.focus()
439+
run_state.click(*panel_point(left, TOOLBAR["save_as"]), delay=1.0)
440+
run_state.click(*dialog_point(run_state, DIALOG["file_name"]), delay=0.3)
441+
run_state.type_text("ShotProj")
442+
run_state.key("Return", delay=0.3)
443+
run_state.click(*dialog_point(run_state, DIALOG["file_ok_name"]), delay=6.0)
444+
# Open the project list; the saved project shows its map thumbnail.
445+
run_state.click(*panel_point(left, TOOLBAR["load"]), delay=2.0)
446+
run_state.screenshot("open-with-thumbnail")
447+
448+
429449
@scenario()
430450
def large_map_create(run_state: E2ERun) -> None:
431451
"""Create the largest map the dialog allows (32x32) and boot into it.

0 commit comments

Comments
 (0)