diff --git a/Makefile b/Makefile
index 691fb072..5703167d 100644
--- a/Makefile
+++ b/Makefile
@@ -22,13 +22,12 @@
extract-dialogue-text extract-weapons \
patch-dry-run patch-in-place patch validate validate-verbose \
list list-json list-filtered schema template template-pretty \
- map-help map-tiles map-atlas map-render map-render-full \
+ map-help map-tiles map-atlas map-render \
map-render-transparent map-render-collisions map-render-events \
map-render-draw-items map-render-waypoints \
map-render-noground map-render-nobuildings map-render-noroofs \
map-render-nomonsters map-render-nonpcs map-render-noobjects \
map-extract-sprites map-to-json map-to-json-pretty map-to-db \
- map-from-db map-from-db-with-sprite \
map-atlas-btl map-atlas-gtl \
map-all-gtl-tiles map-all-btl-tiles zip-map-tiles \
database-import database-dialog-texts database-maps \
@@ -62,7 +61,7 @@ cargo_test:
cargo test --workspace --all-features --quiet
iced_test:
- ICED_TEST_BACKEND=software cargo test -p dispel-gui --features "iced_test app::tests"
+ ICED_TEST_BACKEND=software cargo test -p dispel-gui --features "iced_test"
# GUI simulations require Iced's CPU renderer when no GPU/window
# server is available (for example, in CI or headless development shells).
@@ -275,7 +274,6 @@ template-pretty:
# map tiles – Extract every tile as a separate image
# map atlas – Pack tiles into a single atlas PNG
# map render – Render map with layers, overlays, and sprites
-# map from-db – Render map from SQLite + atlas PNGs
# map to-db – Import .MAP file into SQLite
# map sprites – Extract map-internal sprites to PNGs
# map to-json – Export map data as JSON
@@ -295,7 +293,6 @@ map-atlas:
# Optional flags:
# --game-path
Enable entity overlay (NPCs, monsters, extras)
# --save-sprites Export sub-sprites from the map file
-# --full-map Render full canvas (no occlusion viewport)
# --transparent RGBA PNG with alpha channel
# --collisions Show collision overlay
# --events Show event overlay
@@ -317,15 +314,6 @@ map-render:
--output="map.png" \
--game-path="$(game_path)"
-map-render-full:
- cargo run -- map render \
- --map="$(game_path)/Map/$(map_id).map" \
- --btl="$(game_path)/Map/$(map_id).btl" \
- --gtl="$(game_path)/Map/$(map_id).gtl" \
- --output="map.png" \
- --game-path="$(game_path)" \
- --full-map
-
map-render-transparent:
cargo run -- map render \
--map="$(game_path)/Map/$(map_id).map" \
@@ -452,25 +440,6 @@ map-to-json-pretty:
map-to-db:
cargo run -- map to-db --database "$(db_path)" --map "$(INPUT)"
-# map from-db --database --map-id --gtl-atlas --btl-atlas --output
-# [--game-path ] [--atlas-columns ]
-map-from-db:
- cargo run -- map from-db \
- --database "$(db_path)" \
- --map-id "$(map_id)" \
- --gtl-atlas "$(map_id).gtl.png" \
- --btl-atlas "$(map_id).btl.png" \
- -o "$(out)_$(map_id).png"
-
-map-from-db-with-sprite:
- cargo run -- map from-db \
- --database "$(db_path)" \
- --map-id "$(map_id)" \
- --gtl-atlas "$(map_id).gtl.png" \
- --btl-atlas "$(map_id).btl.png" \
- -o "$(out)_$(map_id).png" \
- --game-path "$(game_path)"
-
# map atlas convenience (shortcut: make atlas from game path + map_id)
map-atlas-btl:
cargo run -- map atlas \
diff --git a/dispel-gui/src/app.rs b/dispel-gui/src/app.rs
index 7859e6dd..47f46380 100644
--- a/dispel-gui/src/app.rs
+++ b/dispel-gui/src/app.rs
@@ -1,6 +1,7 @@
use crate::components::command_palette::CommandPalette;
use crate::components::edit_history::EditHistory;
use crate::components::file_tree::FileTree;
+use crate::editors::fog_data::FogDataEditorState;
use crate::editors::snf_editor::SnfEditorState;
use crate::editors::sprite_editor::SpriteViewerState;
use crate::editors::tileset::TilesetEditorState;
@@ -314,6 +315,16 @@ impl App {
}
Task::none()
}
+ EditorType::FogDataEditor => {
+ if let Some(tab_id) = self.active_tab_id() {
+ self.state
+ .editors
+ .fog_editors
+ .entry(tab_id)
+ .or_insert_with(|| FogDataEditorState::load_from_path(path));
+ }
+ Task::none()
+ }
EditorType::HexEditor => {
let scripts_dir = self
.state
@@ -356,6 +367,69 @@ impl App {
),
))
}
+ EditorType::ChestSpawnEditor => {
+ let path_buf = path.to_path_buf();
+ Task::perform(
+ async move {
+ dispel_core::ChestSpawn::read_file(&path_buf).map_err(|e| e.to_string())
+ },
+ |result| {
+ crate::message::Message::chest_spawn(
+ crate::editors::chest_spawn::ChestSpawnEditorMessage::CatalogLoaded(
+ result,
+ ),
+ )
+ },
+ )
+ }
+ EditorType::MonsterSpawnEditor => {
+ let path_buf = path.to_path_buf();
+ Task::perform(
+ async move {
+ dispel_core::MonsterSpawn::read_file(&path_buf).map_err(|e| e.to_string())
+ },
+ |result| {
+ crate::message::Message::monster_spawn(
+ crate::editors::monster_spawn::MonsterSpawnEditorMessage::CatalogLoaded(
+ result,
+ ),
+ )
+ },
+ )
+ }
+ EditorType::MapInfoMultiEditor => {
+ let path_buf = path.to_path_buf();
+ Task::perform(
+ async move {
+ dispel_core::MapInfoMulti::read_file(&path_buf).map_err(|e| e.to_string())
+ },
+ |result| {
+ crate::message::Message::map_info_multi(
+ crate::editors::map_info_multi::MapInfoMultiEditorMessage::CatalogLoaded(result),
+ )
+ },
+ )
+ }
+ EditorType::TileGridEditor => {
+ let path_buf = path.to_path_buf();
+ // Chain: (1) load the file, (2) after the catalog is ready,
+ // dispatch FitToWindow so the grid auto-fits the canvas pane
+ // without requiring the user to click "Fit" or resize.
+ let load_task = Task::perform(
+ async move {
+ dispel_core::TileGrid::read_file(&path_buf).map_err(|e| e.to_string())
+ },
+ |result| {
+ crate::message::Message::tile_grid(
+ crate::editors::tile_grid::TileGridEditorMessage::CatalogLoaded(result),
+ )
+ },
+ );
+ let fit_task = Task::done(crate::message::Message::tile_grid_canvas(
+ crate::editors::tile_grid::TileGridCanvasMessage::FitToWindow,
+ ));
+ load_task.chain(fit_task)
+ }
EditorType::SaveFileViewer => {
let Some(tab_id) = self.active_tab_id() else {
return Task::none();
diff --git a/dispel-gui/src/components/editable.rs b/dispel-gui/src/components/editable.rs
index efc61f9a..ad3f75d1 100644
--- a/dispel-gui/src/components/editable.rs
+++ b/dispel-gui/src/components/editable.rs
@@ -298,6 +298,25 @@ pub fn parse_hex_array(s: &str) -> Option<[u8; N]> {
}
}
+/// Serialize a `&[u32]` as a comma-separated string (e.g. `"0,1,1,0,..."`).
+#[inline]
+pub fn format_vec_u32(values: &[u32]) -> String {
+ values
+ .iter()
+ .map(u32::to_string)
+ .collect::>()
+ .join(",")
+}
+
+/// Parse a comma-separated list of `u32` values into a `Vec`.
+/// Returns `None` if any value fails to parse.
+#[inline]
+pub fn parse_vec_u32(s: &str) -> Option> {
+ s.split(',')
+ .map(|part| part.trim().parse::().ok())
+ .collect()
+}
+
// ── editable_record_fields! macro ─────────────────────────────────────────────
/// Helper: generate descriptor-kind from kind + args (bracket-delimited).
@@ -329,6 +348,7 @@ macro_rules! __er_kind {
};
(DispEnum, [$ty:ty, Shared($expr:expr)]) => { $expr };
(HexArray, [$n:literal]) => { $crate::components::editable::FieldKind::String };
+ (VecU32, []) => { $crate::components::editable::FieldKind::TextArea };
(Opti32Enum, [$ty:ty, [$($v:literal),* $(,)?]]) => {
$crate::components::editable::FieldKind::Enum { variants: &[$($v),*] }
};
@@ -392,6 +412,9 @@ macro_rules! __er_get {
(HexArray, [$n:literal], $this:ident, $field:ident) => {
$crate::components::editable::hex_string(&$this.$field)
};
+ (VecU32, [], $this:ident, $field:ident) => {
+ $crate::components::editable::format_vec_u32(&$this.$field)
+ };
(Opti32Enum, [$ty:ty, $($rest:tt)*], $this:ident, $field:ident) => {
$crate::components::editable::get_opt_val($this.$field, |v| v.value().to_string())
};
@@ -473,6 +496,12 @@ macro_rules! __er_set {
true
})
};
+ (VecU32, [], $this:ident, $field:ident, $value:ident) => {
+ $crate::components::editable::parse_vec_u32(&$value).is_some_and(|data| {
+ $this.$field = data;
+ true
+ })
+ };
(Opti32Enum, [$ty:ty, $($rest:tt)*], $this:ident, $field:ident, $value:ident) => {
$crate::components::editable::set_opt_i32_enum(&mut $this.$field, $value, <$ty>::from_i32)
};
diff --git a/dispel-gui/src/components/file_tree/data.rs b/dispel-gui/src/components/file_tree/data.rs
index 90c5701f..4a96d409 100644
--- a/dispel-gui/src/components/file_tree/data.rs
+++ b/dispel-gui/src/components/file_tree/data.rs
@@ -402,7 +402,7 @@ fn render_node<'a>(
icon_char(Icon::ChevronRight)
};
- button(
+ let dir_btn = button(
row![
text(caret_char)
.font(LUCIDE_FONT)
@@ -421,8 +421,25 @@ fn render_node<'a>(
right: 4.0,
bottom: 3.0,
left: 6.0,
- })
- .into()
+ });
+
+ let entries = vec![
+ Entry::item(
+ "Show in File Manager",
+ FileTreeMessage::ShowInFileManager(node.path.clone()),
+ ),
+ Entry::separator(),
+ Entry::item(
+ "Copy Absolute Path",
+ FileTreeMessage::CopyAbsolutePath(node.path.clone()),
+ ),
+ Entry::item(
+ "Copy Relative Path",
+ FileTreeMessage::CopyRelativePath(node.path.clone()),
+ ),
+ ];
+
+ ContextMenu::new(dir_btn, entries).into()
} else {
let name_element = create_highlighted_text(&node.name, tree_filter.search_query());
@@ -456,6 +473,15 @@ fn render_node<'a>(
"Show in File Manager",
FileTreeMessage::ShowInFileManager(node.path.clone()),
),
+ Entry::separator(),
+ Entry::item(
+ "Copy Absolute Path",
+ FileTreeMessage::CopyAbsolutePath(node.path.clone()),
+ ),
+ Entry::item(
+ "Copy Relative Path",
+ FileTreeMessage::CopyRelativePath(node.path.clone()),
+ ),
];
ContextMenu::new(file_btn, entries).into()
diff --git a/dispel-gui/src/components/file_tree/message.rs b/dispel-gui/src/components/file_tree/message.rs
index eac46027..e46d8491 100644
--- a/dispel-gui/src/components/file_tree/message.rs
+++ b/dispel-gui/src/components/file_tree/message.rs
@@ -18,4 +18,8 @@ pub enum FileTreeMessage {
ExtractToJson(PathBuf),
ValidateFile(PathBuf),
ShowInFileManager(PathBuf),
+ CopyAbsolutePath(PathBuf),
+ CopyRelativePath(PathBuf),
+ /// Acknowledgement for a completed clipboard write (no-op).
+ ClipboardWriteComplete,
}
diff --git a/dispel-gui/src/components/map_render/render_tiles.rs b/dispel-gui/src/components/map_render/render_tiles.rs
index c35a643f..713e9c91 100644
--- a/dispel-gui/src/components/map_render/render_tiles.rs
+++ b/dispel-gui/src/components/map_render/render_tiles.rs
@@ -95,25 +95,42 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye
let mut items: Vec<(i32, i32, i32, Item)> = Vec::new();
if view.show_buildings {
+ // Buildings draw as single units ordered by their stack bottom.
for (i, info) in map_data.tiled_infos.iter().enumerate() {
- let pos = info.y + info.ids.len() as i32 * TILE_H as i32;
- items.push((pos, 0, i as i32, Item::TiledObject(i)));
+ let pos = dispel_core::map::types::tiled_object_sort_key(
+ info.y,
+ info.ids.len(),
+ );
+ items.push((pos, 0, info.x, Item::TiledObject(i)));
}
}
if view.show_internal_sprites {
for (i, spr) in self.state.internal_sprite_handles().iter().enumerate() {
- items.push((spr.sort_y, 1, 0, Item::Sprite(i)));
+ // Half-tile window: props lose near-ties to characters sitting/standing
+ // on them (see internal_sprite_sort_key).
+ items.push((
+ dispel_core::map::types::internal_sprite_sort_key(spr.sort_y),
+ 1,
+ spr.x,
+ Item::Sprite(i),
+ ));
}
}
- // External entity sort key
+ // External entity sort key — kind rung breaks ties between entities sharing a
+ // tile (NPC must draw over an Extra).
for i in 0..self.state.entity_count() {
if let Some(ed) = self.state.entity_data(i) {
if !ed.visible {
continue;
}
- items.push((ed.sort_key, 2, ed.tile_x, Item::Entity(i)));
+ items.push((
+ ed.sort_key,
+ ed.kind.type_order(),
+ ed.tile_x,
+ Item::Entity(i),
+ ));
}
}
@@ -121,13 +138,13 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye
for (_, _, _, item) in &items {
match item {
- Item::TiledObject(obj_i) => {
- let info = &map_data.tiled_infos[*obj_i];
+ Item::TiledObject(i) => {
+ let info = &map_data.tiled_infos[*i];
let base_x = (info.x as f32 + nox_f) * zoom + pan_x;
let base_y = (info.y as f32 + noy_f) * zoom + pan_y;
let w = TILE_W * zoom;
let h = TILE_H * zoom;
- for (i, &btl_id) in info.ids.iter().enumerate() {
+ for (j, &btl_id) in info.ids.iter().enumerate() {
if btl_id <= 0 {
continue;
}
@@ -137,7 +154,7 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye
continue;
};
let px = base_x;
- let py = base_y + i as f32 * h;
+ let py = base_y + j as f32 * h;
if !is_visible(px, py, w, h, bounds) {
continue;
}
@@ -229,6 +246,28 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye
bounds,
);
}
+
+ // ── Shadow/lighting pass (observed behavior) ─────────────────────
+ // On Dark maps, level-0 tiles black out and lit tiles fade
+ // through the fogdata.dat tables. Drawn after world pixels;
+ // entities on the overlay canvas above stay fully visible,
+ // like the player character. The GUI fades all channels
+ // uniformly (canvas has no per-channel multiply); the CLI
+ // renderer is the pixel-exact reference.
+ if view.show_shadows
+ && let Some(fog) = self.state.shadow_data()
+ {
+ draw_shadow_layer(
+ frame,
+ &map_data.access_ref_words,
+ fog,
+ diagonal,
+ pan_x,
+ pan_y,
+ zoom,
+ bounds,
+ );
+ }
});
vec![geometry]
@@ -247,3 +286,78 @@ impl<'a, S: MapRenderSource, M: 'static> canvas::Program for GenericTilesLaye
}
}
}
+
+// ── Shadow/lighting pass ─────────────────────────────────────────────────────
+
+/// Draws the observed per-tile lighting pass over the world pixels.
+///
+/// Level-0 tiles (fog of war) black out; tiles with a light level fade
+/// through the fogdata tables, one factor byte per 2×1-pixel pair walking
+/// the span mask. The GUI fades all channels uniformly via alpha (canvas
+/// has no per-channel multiply) — the CLI renderer is the pixel-exact
+/// reference.
+#[allow(clippy::too_many_arguments)]
+fn draw_shadow_layer(
+ frame: &mut canvas::Frame,
+ words: &std::collections::HashMap<(i32, i32), u32>,
+ fog: &dispel_core::map::render::FogData,
+ diagonal: i32,
+ pan_x: f32,
+ pan_y: f32,
+ zoom: f32,
+ bounds: Rectangle,
+) {
+ use dispel_core::map::render::SHADOW_SPANS;
+
+ let tile_w = TILE_W * zoom;
+ let tile_h = TILE_H * zoom;
+ let pair_w = 2.0 * zoom;
+ let row_h = zoom;
+
+ for (&(tx, ty), &word) in words {
+ let level = (word >> 15) & 0x7FFF;
+ if level >= 200 {
+ continue;
+ }
+
+ let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom);
+ if !is_visible(px, py, tile_w, tile_h, bounds) {
+ continue;
+ }
+
+ if level == 0 {
+ // Fog of war: solid black diamond covering the tile.
+ let cx = px + tile_w * 0.5;
+ let cy = py + tile_h * 0.5;
+ let hw = tile_w * 0.5;
+ let hh = tile_h * 0.5;
+ let path = canvas::Path::new(|b| {
+ b.move_to(Point::new(cx, cy - hh));
+ b.line_to(Point::new(cx + hw, cy));
+ b.line_to(Point::new(cx, cy + hh));
+ b.line_to(Point::new(cx - hw, cy));
+ b.close();
+ });
+ frame.fill(&path, Color::BLACK);
+ continue;
+ }
+
+ // Lit tile: one overlay rect per pixel pair, alpha = 1 − f/32.
+ let mut pair = 0usize;
+ for (row, &(start, width)) in SHADOW_SPANS.iter().enumerate() {
+ let y = py + row as f32 * row_h;
+ for p in 0..(width / 2) {
+ let x = px + (start + p * 2) as f32 * zoom;
+ if is_visible(x, y, pair_w, row_h, bounds) {
+ let f = f32::from(fog.factor(level, pair)) / 32.0;
+ frame.fill_rectangle(
+ Point::new(x, y),
+ Size::new(pair_w, row_h),
+ Color::from_rgba(0.0, 0.0, 0.0, 1.0 - f),
+ );
+ }
+ pair += 1;
+ }
+ }
+ }
+}
diff --git a/dispel-gui/src/components/map_render/traits.rs b/dispel-gui/src/components/map_render/traits.rs
index e9336246..ad917cac 100644
--- a/dispel-gui/src/components/map_render/traits.rs
+++ b/dispel-gui/src/components/map_render/traits.rs
@@ -11,6 +11,25 @@ pub enum EntityKind {
DrawItem,
}
+impl EntityKind {
+ /// Category tiebreaker for the interlaced Y-sort (lower draws first).
+ ///
+ /// Rungs: buildings (0) < internal sprites (1) < extras/props (2) <
+ /// draw items (3) < monsters (4) < NPCs (5).
+ ///
+ /// When entities share a tile their Y keys and tile X are equal, so this
+ /// rung decides: e.g. an NPC sitting on a chair draws over it, and a drop
+ /// item lying on a prop draws over the prop.
+ pub fn type_order(self) -> i32 {
+ match self {
+ EntityKind::Extra => 2,
+ EntityKind::DrawItem => 3,
+ EntityKind::Monster => 4,
+ EntityKind::Npc => 5,
+ }
+ }
+}
+
pub struct EntityRenderData<'a> {
pub tile_x: i32,
pub tile_y: i32,
@@ -21,6 +40,11 @@ pub struct EntityRenderData<'a> {
}
pub trait MapRenderSource {
+ /// Lighting fade tables for the shadow pass; `None` disables shadows.
+ fn shadow_data(&self) -> Option<&std::sync::Arc> {
+ None
+ }
+
fn map_data(&self) -> Option<&MapDataHandle>;
fn gtl_handles(&self) -> &HashMap;
fn btl_handles(&self) -> &HashMap;
@@ -30,3 +54,41 @@ pub trait MapRenderSource {
fn entity_count(&self) -> usize;
fn entity_data(&self, idx: usize) -> Option>;
}
+
+#[cfg(test)]
+mod tests {
+ use super::EntityKind;
+
+ /// Regression: an NPC placed on an Extra (sitting on a chair) must render
+ /// over it. Both tie on Y key and tile X, so the kind rung decides.
+ #[test]
+ fn test_entity_type_order_npc_draws_over_extra() {
+ assert!(EntityKind::Npc.type_order() > EntityKind::Extra.type_order());
+ }
+
+ /// Regression: a drop item lying on a prop must render over the prop.
+ #[test]
+ fn test_entity_type_order_draw_item_draws_over_extra() {
+ assert!(EntityKind::DrawItem.type_order() > EntityKind::Extra.type_order());
+ }
+
+ #[test]
+ fn test_entity_type_order_ladder_is_total() {
+ let order = [
+ EntityKind::Extra,
+ EntityKind::DrawItem,
+ EntityKind::Monster,
+ EntityKind::Npc,
+ ];
+ for pair in order.windows(2) {
+ assert!(
+ pair[0].type_order() < pair[1].type_order(),
+ "{:?} must draw before {:?}",
+ pair[0],
+ pair[1]
+ );
+ }
+ // Entities stay above buildings (0) and internal sprites (1).
+ assert!(order[0].type_order() > 1);
+ }
+}
diff --git a/dispel-gui/src/components/map_render/view_state.rs b/dispel-gui/src/components/map_render/view_state.rs
index bcec022f..a3fb4a61 100644
--- a/dispel-gui/src/components/map_render/view_state.rs
+++ b/dispel-gui/src/components/map_render/view_state.rs
@@ -1,4 +1,4 @@
-use crate::editors::map_editor::message::{MapViewMode, SelectedEntity};
+use crate::editors::map_editor::message::{MapTool, MapViewMode, ObjectBrushMode, SelectedEntity};
use iced::widget::canvas;
/// Viewport, layer-visibility, and cursor state for the map canvas.
@@ -15,14 +15,23 @@ pub struct MapViewState {
pub show_ground: bool,
pub show_buildings: bool,
pub show_roofs: bool,
+ /// Observed shadow/lighting pass (fog-of-war on Dark maps).
+ pub show_shadows: bool,
pub show_internal_sprites: bool,
pub show_collisions: bool,
pub show_events: bool,
+ /// Tiles whose event word marks the player as being under a roof.
+ pub show_under_roof_markers: bool,
+ /// Player/camera anchors where a native visible-area scan blocks a spell cast.
+ pub show_no_spell_cast_zones: bool,
+ /// Roof overlays that can draw on dark maps.
+ pub show_btl_overlays_in_dark_maps: bool,
pub show_monsters: bool,
pub show_npcs: bool,
pub show_npc_waypoints: bool,
pub show_objects: bool,
pub show_draw_items: bool,
+ pub show_object_ids: bool,
/// Last known cursor position in canvas-local pixel coordinates.
/// Set to f32::NAN when the cursor is not over the canvas.
pub cursor_canvas_x: f32,
@@ -33,6 +42,13 @@ pub struct MapViewState {
pub last_canvas_h: f32,
/// Which top-level view is shown (map canvas or sprite browser).
pub view_mode: MapViewMode,
+ /// Active editing tool. Editing a layer requires selecting its tool;
+ /// layer visibility alone no longer enables editing.
+ pub active_tool: MapTool,
+ /// How the object-id brush is applied when the ObjectId tool clicks a tile.
+ pub object_brush_mode: ObjectBrushMode,
+ /// Whether the layers dropdown popover is open.
+ pub layers_popover_open: bool,
/// Selected sprite sequence index in the Sprites browser.
pub selected_sprite_sequence: Option,
/// Currently selected entity in the inspector panel.
@@ -61,19 +77,27 @@ impl Default for MapViewState {
show_ground: true,
show_buildings: true,
show_roofs: true,
+ show_shadows: true,
show_internal_sprites: true,
show_collisions: false,
show_events: false,
+ show_under_roof_markers: false,
+ show_no_spell_cast_zones: false,
+ show_btl_overlays_in_dark_maps: false,
show_monsters: true,
show_npcs: true,
show_npc_waypoints: false,
show_objects: true,
show_draw_items: true,
+ show_object_ids: false,
cursor_canvas_x: f32::NAN,
cursor_canvas_y: f32::NAN,
last_canvas_w: 1200.0,
last_canvas_h: 800.0,
view_mode: MapViewMode::Map,
+ active_tool: MapTool::Pan,
+ object_brush_mode: ObjectBrushMode::Paint,
+ layers_popover_open: false,
selected_sprite_sequence: None,
selected_entity: None,
tile_layer_cache: canvas::Cache::new(),
diff --git a/dispel-gui/src/dispatch_table.rs b/dispel-gui/src/dispatch_table.rs
index 85f8251b..8d5fd9bc 100644
--- a/dispel-gui/src/dispatch_table.rs
+++ b/dispel-gui/src/dispatch_table.rs
@@ -1,6 +1,6 @@
//! Generated editor dispatch table.
//!
-//! Collapses the 37-arm message dispatch and the 35-arm view dispatch
+//! Collapses the 39-arm message dispatch and the 38-arm view dispatch
//! into a single macro declaration. Two editors are handled specially
//! outside this table:
//! - `DbViewer` — no EditorMessage variant; view is `App::view_db_viewer()`
@@ -82,6 +82,13 @@ define_update_dispatch! {
(Localization, localization_manager),
(HexEditor, hex_wrapper),
(SaveFileViewer, save_file_viewer),
+ (SaveIfo, save_ifo),
+ (FogData, fog_data),
+ (ChestSpawn, chest_spawn),
+ (MonsterSpawn, monster_spawn),
+ (MapInfoMulti, map_info_multi),
+ (TileGrid, tile_grid),
+ (TileGridCanvas, tile_grid_canvas),
}
define_view_dispatch! {
@@ -122,6 +129,12 @@ define_view_dispatch! {
(LocalizationManager, localization_manager),
(HexEditor, hex_wrapper),
(SaveFileViewer, save_file_viewer),
+ (SaveIfoEditor, save_ifo),
+ (FogDataEditor, fog_data),
+ (ChestSpawnEditor, chest_spawn),
+ (MonsterSpawnEditor, monster_spawn),
+ (MapInfoMultiEditor, map_info_multi),
+ (TileGridEditor, tile_grid_canvas),
}
/// Return a `LoadCatalog` task for editors that load from the configured game
@@ -169,9 +182,16 @@ pub fn load_catalog_task(et: EditorType) -> Option> {
StoreEditor => Some(iced::Task::done(Message::store(
crate::editors::store::StoreEditorMessage::LoadCatalog,
))),
+ SaveIfoEditor => Some(iced::Task::done(Message::save_ifo(
+ crate::editors::save_ifo::SaveIfoEditorMessage::LoadCatalog,
+ ))),
PartyLevelDbEditor => Some(iced::Task::done(Message::party_level_db(
crate::editors::party_level_db::PartyLevelDbEditorMessage::LoadCatalog,
))),
+ ChestSpawnEditor => load!(Message::chest_spawn),
+ MonsterSpawnEditor => load!(Message::monster_spawn),
+ MapInfoMultiEditor => load!(Message::map_info_multi),
+ TileGridEditor => load!(Message::tile_grid),
_ => None,
}
}
@@ -198,6 +218,7 @@ pub fn spreadsheet_nav_msg(
EventItemEditor => Message::event_item(event_item::EventItemEditorMessage::Spreadsheet(sm)),
MagicEditor => Message::magic(magic::MagicEditorMessage::Spreadsheet(sm)),
StoreEditor => return None, // Store editor has a custom layout, no generic spreadsheet
+ SaveIfoEditor => return None, // SaveIfo editor has a custom layout, no generic spreadsheet
NpcIniEditor => Message::npc_ini(npc_ini::NpcIniEditorMessage::Spreadsheet(sm)),
NpcRefEditor => Message::npc_ref(npc_ref::NpcRefEditorMessage::Spreadsheet(sm)),
MonsterRefEditor => {
@@ -231,6 +252,16 @@ pub fn spreadsheet_nav_msg(
PartyLevelDbEditor => {
Message::party_level_db(party_level_db::PartyLevelDbEditorMessage::Spreadsheet(sm))
}
+ ChestSpawnEditor => {
+ Message::chest_spawn(chest_spawn::ChestSpawnEditorMessage::Spreadsheet(sm))
+ }
+ MonsterSpawnEditor => {
+ Message::monster_spawn(monster_spawn::MonsterSpawnEditorMessage::Spreadsheet(sm))
+ }
+ MapInfoMultiEditor => {
+ Message::map_info_multi(map_info_multi::MapInfoMultiEditorMessage::Spreadsheet(sm))
+ }
+ TileGridEditor => Message::tile_grid(tile_grid::TileGridEditorMessage::Spreadsheet(sm)),
_ => return None,
})
}
diff --git a/dispel-gui/src/editor_registry.rs b/dispel-gui/src/editor_registry.rs
index a3daca08..c6240fb1 100644
--- a/dispel-gui/src/editor_registry.rs
+++ b/dispel-gui/src/editor_registry.rs
@@ -12,6 +12,7 @@ use crate::editors::event_ini::EventIniEditorState;
use crate::editors::event_npc_ref::EventNpcRefEditorState;
use crate::editors::event_scr::EventScriptEditorState;
use crate::editors::extra_ini::ExtraIniEditorState;
+use crate::editors::fog_data::FogDataEditorState;
use crate::editors::magic::MagicEditorState;
use crate::editors::map_editor::MapEditorState;
use crate::editors::map_ini::MapIniEditorState;
@@ -22,11 +23,13 @@ use crate::editors::party_ini::PartyIniEditorState;
use crate::editors::party_level_db::PartyLevelDbEditorState;
use crate::editors::quest_scr::QuestScrEditorState;
use crate::editors::save_file_viewer::SaveFileViewerState;
+use crate::editors::save_ifo::SaveIfoEditorState;
use crate::editors::snf_editor::SnfEditorState;
use crate::editors::sprite_editor::SpriteViewerState;
use crate::editors::store::StoreEditorState;
use crate::editors::tileset::TilesetEditorState;
use crate::editors::wave_ini::WaveIniEditorState;
+
use crate::editors::{localization_manager, mod_packager};
use crate::workspace::EditorType;
use dispel_core::{
@@ -74,6 +77,7 @@ pub struct EditorRegistry {
pub event_scr_editor: Box,
pub wave_ini_editor: Box,
pub chdata_editor: Box,
+ pub save_ifo_editor: Box,
pub map_editors: HashMap,
pub tileset_editors: HashMap,
pub snf_editors: HashMap,
@@ -81,6 +85,12 @@ pub struct EditorRegistry {
pub mod_packager_editor: mod_packager::ModPackagerState,
pub localization_manager: localization_manager::LocalizationManagerState,
pub save_file_viewers: HashMap,
+ pub fog_editors: HashMap,
+ pub chest_spawn_editor: Box>,
+ pub monster_spawn_editor: Box>,
+ pub map_info_multi_editor: Box>,
+ pub tile_grid_editor: Box>,
+ pub tile_grid_view: crate::editors::tile_grid::TileGridViewState,
}
/// Macro: dispatch `undo` or `redo` to the correct editor field.
@@ -111,10 +121,15 @@ macro_rules! undo_redo_dispatch {
EditorType::QuestScrEditor => $self.quest_scr_editor.$action($lookups),
EditorType::WaveIniEditor => $self.wave_ini_editor.$action($lookups),
EditorType::ChDataEditor => $self.chdata_editor.$action($lookups),
+ EditorType::ChestSpawnEditor => $self.chest_spawn_editor.$action($lookups),
+ EditorType::MonsterSpawnEditor => $self.monster_spawn_editor.$action($lookups),
+ EditorType::MapInfoMultiEditor => $self.map_info_multi_editor.$action($lookups),
+ EditorType::TileGridEditor => $self.tile_grid_editor.$action($lookups),
EditorType::PartyLevelDbEditor => $self.party_level_db_level_editor.$action($lookups),
// Custom-layout editor (undo/redo without lookups)
EditorType::StoreEditor => $self.store_editor.$action(),
+ EditorType::SaveIfoEditor => $self.save_ifo_editor.$action(),
// Sprite editor — uses its own snapshot-based undo/redo
EditorType::SpriteViewer => $self.sprite_viewers.get_mut(&$tab_id).map(|viewer| {
@@ -122,6 +137,16 @@ macro_rules! undo_redo_dispatch {
String::new() // no status message needed
}),
+ // Fog data editor — snapshot-based undo/redo; reports whether the
+ // operation did anything so the status bar can say "Nothing to undo".
+ EditorType::FogDataEditor => $self.fog_editors.get_mut(&$tab_id).and_then(|editor| {
+ if editor.$action() {
+ Some(String::new())
+ } else {
+ None
+ }
+ }),
+
// Tab-based editors (MultiFileEditorState via TabbedEditor)
EditorType::MonsterRefEditor => $self
.monster_ref_editor
@@ -174,6 +199,7 @@ impl EditorRegistry {
self.map_editors.remove(&tab_id);
self.hex_editors.remove(&tab_id);
self.save_file_viewers.remove(&tab_id);
+ self.fog_editors.remove(&tab_id);
}
/// Clear editors for every tab. Use when the workspace is about to lose
@@ -195,6 +221,7 @@ impl EditorRegistry {
self.map_editors.clear();
self.hex_editors.clear();
self.save_file_viewers.clear();
+ self.fog_editors.clear();
}
/// Stop SNF audio playback on every open SNF editor.
@@ -327,6 +354,11 @@ impl EditorRegistry {
EditorType::PartyRefEditor => Some(self.party_ref_editor.edit_history()),
EditorType::PartyIniEditor => Some(self.party_ini_editor.edit_history()),
EditorType::StoreEditor => Some(self.store_editor.edit_history()),
+ EditorType::SaveIfoEditor => Some(self.save_ifo_editor.edit_history()),
+ EditorType::ChestSpawnEditor => Some(self.chest_spawn_editor.edit_history()),
+ EditorType::MonsterSpawnEditor => Some(self.monster_spawn_editor.edit_history()),
+ EditorType::MapInfoMultiEditor => Some(self.map_info_multi_editor.edit_history()),
+ EditorType::TileGridEditor => Some(self.tile_grid_editor.edit_history()),
// Tab-based editors — need a HashMap lookup
EditorType::MonsterRefEditor => self
@@ -380,6 +412,7 @@ impl EditorRegistry {
self.snf_editors.clear();
self.hex_editors.clear();
self.save_file_viewers.clear();
+ self.fog_editors.clear();
// Boxed editors — reset to default
*self.weapon_editor = Default::default();
@@ -404,6 +437,12 @@ impl EditorRegistry {
*self.wave_ini_editor = Default::default();
*self.chdata_editor = Default::default();
*self.event_scr_editor = Default::default();
+ *self.save_ifo_editor = Default::default();
+ *self.chest_spawn_editor = Default::default();
+ *self.monster_spawn_editor = Default::default();
+ *self.map_info_multi_editor = Default::default();
+ *self.tile_grid_editor = Default::default();
+ self.tile_grid_view = Default::default();
// Boxed editors that were missing from reset — bugs found by tests
*self.monster_ini_editor = Default::default();
diff --git a/dispel-gui/src/editors/chest_spawn/component.rs b/dispel-gui/src/editors/chest_spawn/component.rs
new file mode 100644
index 00000000..f6910fe9
--- /dev/null
+++ b/dispel-gui/src/editors/chest_spawn/component.rs
@@ -0,0 +1,31 @@
+use crate::components::editable::EditableRecord;
+use crate::editable_record_fields;
+use dispel_core::ChestSpawn;
+
+editable_record_fields!(ChestSpawn, {
+ { serial = Integer / "Serial:" },
+ { x = Integer / "X:" },
+ { y = Integer / "Y:" },
+ { direction = Integer / "Direction:" },
+ { item_group = Integer / "Item Group:" },
+ { gold_class = Integer / "Gold Class:" },
+});
+
+impl EditableRecord for ChestSpawn {
+ crate::editable_record_delegate!();
+ fn list_label(&self) -> String {
+ format!(
+ "Chest spawn (serial={}, x={}, y={}, dir={}, group={}, gold={})",
+ self.serial, self.x, self.y, self.direction, self.item_group, self.gold_class
+ )
+ }
+ fn detail_title() -> &'static str {
+ "Chest Spawn Details"
+ }
+ fn empty_selection_text() -> &'static str {
+ "No chest spawn selected"
+ }
+ fn save_button_label() -> &'static str {
+ "Save Chest Spawns"
+ }
+}
diff --git a/dispel-gui/src/editors/chest_spawn/mod.rs b/dispel-gui/src/editors/chest_spawn/mod.rs
new file mode 100644
index 00000000..e90beb31
--- /dev/null
+++ b/dispel-gui/src/editors/chest_spawn/mod.rs
@@ -0,0 +1,9 @@
+mod component;
+
+crate::define_standard_editor! {
+ name: chest_spawn,
+ name_pascal: ChestSpawn,
+ record: dispel_core::ChestSpawn,
+ field: chest_spawn_editor,
+ file: "Map/Multi/*.cst",
+}
diff --git a/dispel-gui/src/editors/fog_data/curve_canvas.rs b/dispel-gui/src/editors/fog_data/curve_canvas.rs
new file mode 100644
index 00000000..0250dbf9
--- /dev/null
+++ b/dispel-gui/src/editors/fog_data/curve_canvas.rs
@@ -0,0 +1,334 @@
+//! The fog-curve canvas: draws one level's 512-sample flicker curve over a
+//! subtle grid and turns pointer input into pair selection / factor painting.
+//!
+//! Mapping: x → pixel-pair index (`0..512`), y → brightness factor
+//! (`0..=31`, inverted so brighter is up).
+
+use crate::editors::fog_data::message::FogDataMessage;
+use crate::message::MessageExt;
+use dispel_core::map::fogdata::{MAX_FACTOR, ROW_LEN};
+use iced::widget::canvas::{self, Action, Frame, Geometry, Path, Stroke, Text};
+use iced::{Color, Element, Event, Point, Rectangle, Renderer, Size, mouse};
+
+// ── Palette ───────────────────────────────────────────────────────────────────
+
+/// Canvas backdrop — darker than the app background, consistent with the
+/// map/hex canvases.
+const BG: Color = Color::from_rgb(0.063, 0.063, 0.078);
+/// Horizontal grid lines at factor levels.
+const GRID_VALUE: Color = Color {
+ a: 0.14,
+ ..Color::from_rgb(0.85, 0.78, 0.60)
+};
+/// The 0 and MAX_FACTOR boundary lines are slightly stronger.
+const GRID_EDGE: Color = Color {
+ a: 0.28,
+ ..Color::from_rgb(0.85, 0.78, 0.60)
+};
+/// Vertical grid lines every 64 pairs.
+const GRID_PAIR: Color = Color {
+ a: 0.08,
+ ..Color::from_rgb(0.85, 0.78, 0.60)
+};
+/// The flicker curve itself — warm gold, echoing the Medieval accent.
+const CURVE: Color = Color::from_rgb(0.891, 0.769, 0.412);
+/// Translucent area under the curve.
+const CURVE_FILL: Color = Color { a: 0.10, ..CURVE };
+/// Selected-pair marker + hairline.
+const SELECTION: Color = Color::from_rgb(0.918, 0.878, 0.784);
+const SELECTION_HAIRLINE: Color = Color {
+ a: 0.30,
+ ..SELECTION
+};
+/// Hover crosshair (cursor position only — costs no messages).
+const HOVER: Color = Color {
+ a: 0.35,
+ ..Color::from_rgb(1.0, 1.0, 1.0)
+};
+const LABEL: Color = Color {
+ a: 0.45,
+ ..Color::from_rgb(0.85, 0.78, 0.60)
+};
+
+/// Canvas program state: whether the left button is currently held inside
+/// this widget (i.e. a paint stroke is in progress).
+#[derive(Debug, Default, Clone, Copy)]
+pub struct CurveCanvasState {
+ painting: bool,
+}
+
+/// Borrowed view of the editor data, used as the canvas [`canvas::Program`].
+pub struct FogCurveCanvas<'a> {
+ pub tab_id: usize,
+ /// The selected level's 512 samples.
+ pub row: &'a [u8],
+ pub selected_pair: usize,
+}
+
+impl<'a> FogCurveCanvas<'a> {
+ pub fn into_element(self) -> Element<'a, crate::message::Message> {
+ iced::widget::Canvas::new(self)
+ .width(iced::Fill)
+ .height(iced::Fill)
+ .into()
+ }
+}
+
+/// Pixel x → pair index (`0..ROW_LEN`).
+fn pair_from_x(x: f32, width: f32) -> usize {
+ if width <= 0.0 {
+ return 0;
+ }
+ let fraction = (x / width).clamp(0.0, 1.0);
+ ((fraction * (ROW_LEN - 1) as f32).round() as usize).min(ROW_LEN - 1)
+}
+
+/// Pixel y → quantized factor (`0..=MAX_FACTOR`, y grows downward).
+fn value_from_y(y: f32, height: f32) -> u8 {
+ if height <= 0.0 {
+ return 0;
+ }
+ let fraction = 1.0 - (y / height).clamp(0.0, 1.0);
+ ((fraction * MAX_FACTOR as f32).round() as u8).min(MAX_FACTOR)
+}
+
+/// Pair index → sample x coordinate.
+fn x_of_pair(pair: usize, width: f32) -> f32 {
+ (pair as f32 / (ROW_LEN - 1) as f32) * width
+}
+
+/// Factor value → sample y coordinate (brighter is up).
+fn y_of_value(value: u8, height: f32) -> f32 {
+ (1.0 - value as f32 / MAX_FACTOR as f32) * height
+}
+
+impl<'a> canvas::Program for FogCurveCanvas<'a> {
+ type State = CurveCanvasState;
+
+ fn update(
+ &self,
+ interaction: &mut Self::State,
+ event: &Event,
+ bounds: Rectangle,
+ cursor: mouse::Cursor,
+ ) -> Option> {
+ use mouse::{Button, Event as MouseEvent};
+
+ match event {
+ // Stroke start: select the pair and paint it immediately.
+ // (Painting implies selection — one message, no `and_publish`.)
+ Event::Mouse(MouseEvent::ButtonPressed(Button::Left)) => {
+ let pos = cursor.position_in(bounds)?;
+ interaction.painting = true;
+ let pair = pair_from_x(pos.x, bounds.width);
+ let value = value_from_y(pos.y, bounds.height);
+ Some(
+ Action::publish(crate::message::Message::fog_data(
+ FogDataMessage::FactorPainted(self.tab_id, pair, value),
+ ))
+ .and_capture(),
+ )
+ }
+ // Dragging paints along the path.
+ Event::Mouse(MouseEvent::CursorMoved { .. }) if interaction.painting => {
+ let pos = cursor.position_in(bounds)?;
+ let pair = pair_from_x(pos.x, bounds.width);
+ let value = value_from_y(pos.y, bounds.height);
+ Some(
+ Action::publish(crate::message::Message::fog_data(
+ FogDataMessage::FactorPainted(self.tab_id, pair, value),
+ ))
+ .and_capture(),
+ )
+ }
+ // Stroke end: commit the undo snapshot.
+ Event::Mouse(MouseEvent::ButtonReleased(Button::Left)) => {
+ if !interaction.painting {
+ return None;
+ }
+ interaction.painting = false;
+ Some(
+ Action::publish(crate::message::Message::fog_data(
+ FogDataMessage::StrokeEnded(self.tab_id),
+ ))
+ .and_capture(),
+ )
+ }
+ _ => None,
+ }
+ }
+
+ fn draw(
+ &self,
+ _state: &Self::State,
+ renderer: &Renderer,
+ _theme: &iced::Theme,
+ bounds: Rectangle,
+ cursor: mouse::Cursor,
+ ) -> Vec {
+ let width = bounds.width;
+ let height = bounds.height;
+ let mut frame = Frame::new(renderer, bounds.size());
+
+ // Backdrop
+ frame.fill_rectangle(Point::ORIGIN, Size::new(width, height), BG);
+
+ // Horizontal grid at meaningful factor levels (0/8/16/24/31).
+ for &level in &[0u8, 8, 16, 24] {
+ let y = y_of_value(level, height);
+ stroke_line(
+ &mut frame,
+ Point::new(0.0, y),
+ Point::new(width, y),
+ GRID_VALUE,
+ 1.0,
+ );
+ }
+ let top_y = y_of_value(MAX_FACTOR, height);
+ stroke_line(
+ &mut frame,
+ Point::new(0.0, top_y),
+ Point::new(width, top_y),
+ GRID_EDGE,
+ 1.0,
+ );
+ let bottom_y = y_of_value(0, height);
+ stroke_line(
+ &mut frame,
+ Point::new(0.0, bottom_y),
+ Point::new(width, bottom_y),
+ GRID_EDGE,
+ 1.0,
+ );
+
+ // Vertical grid every 64 pairs.
+ for pair in (0..ROW_LEN).step_by(64) {
+ let x = x_of_pair(pair, width);
+ stroke_line(
+ &mut frame,
+ Point::new(x, 0.0),
+ Point::new(x, height),
+ GRID_PAIR,
+ 1.0,
+ );
+ }
+
+ // Axis labels — tiny, unobtrusive.
+ for &level in &[0u8, 8, 16, 24, 31] {
+ frame.fill_text(Text {
+ content: level.to_string(),
+ position: Point::new(3.0, y_of_value(level, height) + 2.0),
+ color: LABEL,
+ size: 9.0.into(),
+ ..Default::default()
+ });
+ }
+ for &pair in &[0usize, 128, 256, 384, 511] {
+ frame.fill_text(Text {
+ content: pair.to_string(),
+ position: Point::new((x_of_pair(pair, width) - 7.0).max(1.0), height - 12.0),
+ color: LABEL,
+ size: 9.0.into(),
+ ..Default::default()
+ });
+ }
+
+ if self.row.len() == ROW_LEN && width > 0.0 && height > 0.0 {
+ // Area fill under the curve.
+ let area = Path::new(|p| {
+ p.move_to(Point::new(0.0, bottom_y));
+ for (pair, &value) in self.row.iter().enumerate() {
+ p.line_to(Point::new(
+ x_of_pair(pair, width),
+ y_of_value(value, height),
+ ));
+ }
+ p.line_to(Point::new(width, bottom_y));
+ p.close();
+ });
+ frame.fill(&area, CURVE_FILL);
+
+ // The curve itself.
+ let line = Path::new(|p| {
+ let mut first = true;
+ for (pair, &value) in self.row.iter().enumerate() {
+ let point = Point::new(x_of_pair(pair, width), y_of_value(value, height));
+ if first {
+ p.move_to(point);
+ first = false;
+ } else {
+ p.line_to(point);
+ }
+ }
+ });
+ frame.stroke(&line, Stroke::default().with_color(CURVE).with_width(1.5));
+ }
+
+ // Selected pair: hairline + filled dot with ring.
+ let sel_x = x_of_pair(self.selected_pair.min(ROW_LEN - 1), width);
+ stroke_line(
+ &mut frame,
+ Point::new(sel_x, 0.0),
+ Point::new(sel_x, height),
+ SELECTION_HAIRLINE,
+ 1.0,
+ );
+ let sel_value = self
+ .row
+ .get(self.selected_pair.min(ROW_LEN - 1))
+ .copied()
+ .unwrap_or(0);
+ let center = Point::new(sel_x, y_of_value(sel_value, height));
+ frame.fill(&Path::circle(center, 4.0), SELECTION);
+ frame.stroke(
+ &Path::circle(center, 6.5),
+ Stroke::default().with_color(SELECTION).with_width(1.0),
+ );
+
+ // Hover crosshair — derived straight from the cursor, no messages.
+ if let Some(pos) = cursor.position_in(bounds) {
+ let hover_pair = pair_from_x(pos.x, width);
+ let hover_value = value_from_y(pos.y, height);
+ let hx = x_of_pair(hover_pair, width);
+ let hy = y_of_value(hover_value, height);
+ stroke_line(
+ &mut frame,
+ Point::new(hx, 0.0),
+ Point::new(hx, height),
+ Color { a: 0.35, ..HOVER },
+ 1.0,
+ );
+ stroke_line(
+ &mut frame,
+ Point::new(0.0, hy),
+ Point::new(width, hy),
+ Color { a: 0.20, ..HOVER },
+ 1.0,
+ );
+ frame.stroke(
+ &Path::circle(Point::new(hx, hy), 3.0),
+ Stroke::default().with_color(HOVER).with_width(1.0),
+ );
+ }
+
+ vec![frame.into_geometry()]
+ }
+
+ fn mouse_interaction(
+ &self,
+ _state: &Self::State,
+ bounds: Rectangle,
+ cursor: mouse::Cursor,
+ ) -> mouse::Interaction {
+ if cursor.is_over(bounds) {
+ mouse::Interaction::Crosshair
+ } else {
+ mouse::Interaction::Idle
+ }
+ }
+}
+
+fn stroke_line(frame: &mut Frame, from: Point, to: Point, color: Color, width: f32) {
+ let path = Path::line(from, to);
+ frame.stroke(&path, Stroke::default().with_color(color).with_width(width));
+}
diff --git a/dispel-gui/src/editors/fog_data/message.rs b/dispel-gui/src/editors/fog_data/message.rs
new file mode 100644
index 00000000..20e393f6
--- /dev/null
+++ b/dispel-gui/src/editors/fog_data/message.rs
@@ -0,0 +1,41 @@
+/// Messages for the `ExtraInGame/fogdata.dat` editor.
+///
+/// Every variant carries the owning `tab_id` so handlers always mutate the
+/// correct per-tab state, even when several fog tabs are open at once.
+#[derive(Debug, Clone)]
+pub enum FogDataMessage {
+ /// Select a light level (1-based, `1..=123`) in a tab.
+ LevelSelected(usize, u32),
+ /// Select a pixel-pair index (`0..512`) in a tab.
+ PairSelected(usize, usize),
+ /// Live paint while dragging across the curve canvas.
+ /// `(tab_id, pair, quantized factor value)`
+ FactorPainted(usize, usize, u8),
+ /// A paint stroke finished (mouse released) — commits the undo snapshot
+ /// taken at stroke start, if anything actually changed.
+ StrokeEnded(usize),
+ /// Commit a single factor edit from the inspector field / steppers.
+ /// Pushes an undo snapshot before applying. `(tab_id, pair, value)`
+ FactorCommitted(usize, usize, u8),
+ /// The numeric edit field changed — validated live, committed on submit.
+ ValueInputChanged(usize, String),
+ /// Enter pressed in the numeric field — commit if valid.
+ ValueSubmitted(usize),
+ /// Save the fade tables back to disk (async).
+ Save(usize),
+ /// Save finished. `(tab_id, save generation the run was based on, result)`
+ /// The completion only clears `dirty` when its generation still matches
+ /// the editor's current one (no edit landed while saving). `Ok` carries
+ /// `(status, optional mod-recording failure)` — the file was written.
+ SaveComplete(usize, u64, Result<(String, Option), String>),
+ /// Reload from disk; asks for confirmation when dirty.
+ Revert(usize),
+ /// User confirmed discarding unsaved edits.
+ RevertConfirmed(usize),
+ /// User cancelled the revert confirmation.
+ RevertCancelled(usize),
+ /// Undo the last paint stroke / committed edit.
+ Undo(usize),
+ /// Redo the last undone edit.
+ Redo(usize),
+}
diff --git a/dispel-gui/src/editors/fog_data/mod.rs b/dispel-gui/src/editors/fog_data/mod.rs
new file mode 100644
index 00000000..b2f9749a
--- /dev/null
+++ b/dispel-gui/src/editors/fog_data/mod.rs
@@ -0,0 +1,15 @@
+// fog_data editor module
+// Editor for `ExtraInGame/fogdata.dat` — observed map-lighting fade tables
+// (123 levels × 512 brightness factors, each byte a flicker-curve sample).
+
+mod curve_canvas;
+mod message;
+mod state;
+mod update;
+mod view;
+
+pub use curve_canvas::FogCurveCanvas;
+pub use message::FogDataMessage;
+pub use state::FogDataEditorState;
+pub use update::{RecordingParams, handle, save_fog_data, save_to_disk};
+pub use view::view;
diff --git a/dispel-gui/src/editors/fog_data/state.rs b/dispel-gui/src/editors/fog_data/state.rs
new file mode 100644
index 00000000..92525815
--- /dev/null
+++ b/dispel-gui/src/editors/fog_data/state.rs
@@ -0,0 +1,325 @@
+use dispel_core::map::fogdata::{FogData, MAX_FACTOR, ROW_LEN, ROWS};
+use std::fs::File;
+use std::path::{Path, PathBuf};
+
+/// Maximum undo snapshots kept per tab.
+const UNDO_CAP: usize = 50;
+
+/// Per-tab editor state for `ExtraInGame/fogdata.dat` — observed
+/// map-lighting fade tables (123 levels × 512 brightness factors).
+///
+/// Undo/redo is snapshot-based (whole `FogData` clones): a snapshot is taken
+/// at paint-stroke start and pushed when the stroke ends, or pushed directly
+/// before a single-value commit from the inspector.
+#[derive(Debug, Clone)]
+pub struct FogDataEditorState {
+ pub path: PathBuf,
+ pub save_path: PathBuf,
+ pub name: String,
+ /// Parsed fade tables; `None` while unloaded.
+ pub fog: Option,
+ /// Load failure — replaces the whole editor surface. Never used for
+ /// transient save errors (those go to the status bar).
+ pub error: Option,
+ /// Selected light level (1-based, `1..=[ROWS]`).
+ pub selected_level: u32,
+ /// Selected pixel-pair index (`0..[ROW_LEN]`).
+ pub selected_pair: usize,
+ pub dirty: bool,
+ pub undo_stack: Vec,
+ pub redo_stack: Vec,
+ /// Curve snapshot captured when a paint stroke begins; pushed onto the
+ /// undo stack when the stroke ends — but only if data actually changed.
+ pending_stroke_base: Option,
+ /// Raw contents of the inspector numeric field.
+ pub value_input: String,
+ /// Inline validation error for [`Self::value_input`], if invalid.
+ pub input_error: Option,
+ /// Revert confirmation dialog is open (shown only when dirty).
+ pub confirm_revert: bool,
+ /// Bumped on every mutating action and on each `Save` dispatch. A
+ /// `SaveComplete` carrying a stale generation must not clear `dirty`
+ /// (an edit may have landed while the save was in flight).
+ pub save_generation: u64,
+}
+
+impl Default for FogDataEditorState {
+ fn default() -> Self {
+ Self {
+ path: PathBuf::new(),
+ save_path: PathBuf::new(),
+ name: String::new(),
+ fog: None,
+ error: None,
+ selected_level: 1,
+ selected_pair: 0,
+ dirty: false,
+ undo_stack: Vec::new(),
+ redo_stack: Vec::new(),
+ pending_stroke_base: None,
+ value_input: String::new(),
+ input_error: None,
+ confirm_revert: false,
+ save_generation: 0,
+ }
+ }
+}
+
+impl FogDataEditorState {
+ /// Opens and parses a `fogdata.dat` synchronously (the file is 63 KB).
+ /// On failure the state is still usable and surfaces [`Self::error`].
+ pub fn load_from_path(path: &Path) -> Self {
+ let name = path
+ .file_name()
+ .map(|s| s.to_string_lossy().to_string())
+ .unwrap_or_default();
+
+ match File::open(path).and_then(|mut f| FogData::parse(&mut f)) {
+ Ok(fog) => {
+ let mut state = Self {
+ path: path.to_path_buf(),
+ save_path: path.to_path_buf(),
+ name,
+ fog: Some(fog),
+ ..Default::default()
+ };
+ state.sync_input_from_selection();
+ state
+ }
+ Err(e) => Self {
+ path: path.to_path_buf(),
+ save_path: path.to_path_buf(),
+ name,
+ error: Some(e.to_string()),
+ ..Default::default()
+ },
+ }
+ }
+
+ /// Number of light levels in the table (`ROWS`).
+ pub fn level_count(&self) -> usize {
+ ROWS
+ }
+
+ /// The full 512-sample flicker curve of the selected level.
+ /// Empty slice when no data is loaded.
+ pub fn current_row(&self) -> &[u8] {
+ static EMPTY: [u8; 0] = [];
+ match &self.fog {
+ Some(fog) if self.selected_level >= 1 && (self.selected_level as usize) <= ROWS => {
+ fog.row(self.selected_level)
+ }
+ _ => &EMPTY,
+ }
+ }
+
+ /// Factor of the currently selected pair, if data is loaded.
+ pub fn selected_factor(&self) -> Option {
+ self.fog
+ .as_ref()
+ .and_then(|f| f.get_factor(self.selected_level, self.selected_pair))
+ }
+
+ // ── Editing ───────────────────────────────────────────────────────────
+
+ /// Single funnel into `FogData::set_factor`.
+ ///
+ /// Kept isolated so the core's error type can change under us
+ /// (`io::Error` today, typed `SetFactorError` later) without touching
+ /// any call site — everything here speaks plain strings.
+ fn apply_factor(&mut self, pair: usize, value: u8) -> Result<(), String> {
+ let Some(ref mut fog) = self.fog else {
+ return Err("No fog data loaded".to_string());
+ };
+ // Guard rails before hitting the core so an out-of-range value can
+ // never panic or wrap: values >31 wrap silently when consumed.
+ if value > MAX_FACTOR {
+ return Err(format!("Value must be 0–{MAX_FACTOR}"));
+ }
+ if pair >= ROW_LEN || self.selected_level == 0 || self.selected_level as usize > ROWS {
+ return Err("Pair index out of range".to_string());
+ }
+ fog.set_factor(self.selected_level, pair, value)
+ .map_err(|e| e.to_string())
+ }
+
+ /// Capture the pre-stroke curve snapshot (idempotent within a stroke).
+ pub fn begin_stroke_if_needed(&mut self) {
+ if self.pending_stroke_base.is_none() {
+ self.pending_stroke_base = self.fog.clone();
+ }
+ }
+
+ /// Close the current paint stroke: pushes the stroke-start snapshot onto
+ /// the undo stack only when the curve actually changed during the stroke.
+ pub fn end_stroke(&mut self) {
+ if let Some(base) = self.pending_stroke_base.take()
+ && let Some(ref current) = self.fog
+ && base != *current
+ {
+ self.undo_stack.push(base);
+ self.redo_stack.clear();
+ if self.undo_stack.len() > UNDO_CAP {
+ self.undo_stack.remove(0);
+ }
+ }
+ }
+
+ /// Paint one sample during a drag. Returns `true` when the value changed.
+ pub fn paint_factor(&mut self, pair: usize, value: u8) -> bool {
+ let before = self
+ .fog
+ .as_ref()
+ .and_then(|f| f.get_factor(self.selected_level, pair));
+ if self.apply_factor(pair, value).is_err() {
+ return false;
+ }
+ before != Some(value)
+ }
+
+ /// Commit a single factor edit with its own undo snapshot.
+ /// Returns whether the curve actually changed; state is untouched on
+ /// rejection.
+ pub fn commit_factor(&mut self, pair: usize, value: u8) -> Result {
+ let current = self
+ .fog
+ .as_ref()
+ .and_then(|f| f.get_factor(self.selected_level, pair));
+ if current == Some(value) {
+ return Ok(false); // No-op edit: no dirty flag, no undo entry.
+ }
+ let snapshot = self.fog.clone();
+ self.apply_factor(pair, value)?;
+ if let Some(snapshot) = snapshot {
+ self.undo_stack.push(snapshot);
+ self.redo_stack.clear();
+ if self.undo_stack.len() > UNDO_CAP {
+ self.undo_stack.remove(0);
+ }
+ }
+ Ok(true)
+ }
+
+ // ── Inspector field ───────────────────────────────────────────────────
+
+ /// Refresh the numeric buffer from the current selection.
+ pub fn sync_input_from_selection(&mut self) {
+ if let Some(v) = self.selected_factor() {
+ self.value_input = v.to_string();
+ self.input_error = None;
+ }
+ }
+
+ /// Live-validate the numeric field: `0..=MAX_FACTOR`, integers only.
+ pub fn set_value_input(&mut self, raw: String) {
+ self.input_error = validate_value_text(&raw);
+ self.value_input = raw;
+ }
+
+ /// Commit whatever is in the numeric field to the selected pair.
+ /// Returns `Some(value)` on success; on failure the reason is already
+ /// stored in [`Self::input_error`].
+ pub fn submit_value_input(&mut self) -> Option {
+ if let Some(msg) = validate_value_text(&self.value_input) {
+ self.input_error = Some(msg);
+ return None;
+ }
+ let value: u8 = self.value_input.parse().unwrap_or(u8::MAX);
+ match self.commit_factor(self.selected_pair, value) {
+ Ok(_changed) => {
+ self.input_error = None;
+ Some(value)
+ }
+ Err(e) => {
+ self.input_error = Some(e);
+ None
+ }
+ }
+ }
+
+ // ── Undo / redo ───────────────────────────────────────────────────────
+
+ pub fn can_undo(&self) -> bool {
+ !self.undo_stack.is_empty()
+ }
+
+ pub fn can_redo(&self) -> bool {
+ !self.redo_stack.is_empty()
+ }
+
+ /// Restores the previous curve. Returns `true` when something was undone.
+ pub fn undo(&mut self) -> bool {
+ let Some(previous) = self.undo_stack.pop() else {
+ return false;
+ };
+ if let Some(current) = self.fog.take() {
+ self.redo_stack.push(current);
+ }
+ self.fog = Some(previous);
+ self.dirty = true;
+ self.sync_input_from_selection();
+ true
+ }
+
+ /// Re-applies the last undone edit. Returns `true` when it did anything.
+ pub fn redo(&mut self) -> bool {
+ let Some(next) = self.redo_stack.pop() else {
+ return false;
+ };
+ if let Some(current) = self.fog.take() {
+ self.undo_stack.push(current);
+ }
+ self.fog = Some(next);
+ self.dirty = true;
+ self.sync_input_from_selection();
+ true
+ }
+
+ // ── Dirty / revert ────────────────────────────────────────────────────
+
+ pub fn mark_clean(&mut self) {
+ self.dirty = false;
+ }
+
+ /// Reload the table from disk, discarding all edits and history.
+ pub fn reload_from_disk(&mut self) -> Result<(), String> {
+ let file = File::open(&self.save_path).map_err(|e| e.to_string())?;
+ let mut file = file;
+ let fog = FogData::parse(&mut file).map_err(|e| e.to_string())?;
+ self.fog = Some(fog);
+ self.dirty = false;
+ self.undo_stack.clear();
+ self.redo_stack.clear();
+ self.pending_stroke_base = None;
+ self.confirm_revert = false;
+ self.sync_input_from_selection();
+ Ok(())
+ }
+}
+
+/// Validate raw text as a brightness factor: unsigned integer `0..=31`.
+fn validate_value_text(raw: &str) -> Option {
+ let trimmed = raw.trim();
+ match trimmed.parse::() {
+ Ok(v) if v <= MAX_FACTOR => None,
+ Ok(_) => Some(format!("Must be 0–{MAX_FACTOR}")),
+ Err(_) if trimmed.is_empty() => Some("Enter a number".to_string()),
+ Err(_) => Some("Not a number".to_string()),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_validate_value_text_accepts_range_and_rejects_rest() {
+ assert_eq!(validate_value_text("0"), None);
+ assert_eq!(validate_value_text("31"), None);
+ assert_eq!(validate_value_text(" 7 "), None);
+ assert!(validate_value_text("32").is_some());
+ assert!(validate_value_text("-1").is_some());
+ assert!(validate_value_text("abc").is_some());
+ assert!(validate_value_text("").is_some());
+ }
+}
diff --git a/dispel-gui/src/editors/fog_data/update.rs b/dispel-gui/src/editors/fog_data/update.rs
new file mode 100644
index 00000000..202b4586
--- /dev/null
+++ b/dispel-gui/src/editors/fog_data/update.rs
@@ -0,0 +1,297 @@
+use crate::app::App;
+use crate::editors::fog_data::message::FogDataMessage;
+use crate::message::{Message, MessageExt};
+use dispel_core::map::fogdata::{MAX_FACTOR, ROW_LEN, ROWS};
+use iced::Task;
+use std::path::PathBuf;
+
+/// Parameters for recording a fogdata save into an active mod session.
+#[derive(Debug, Clone)]
+pub struct RecordingParams {
+ pub workspace_root: PathBuf,
+ pub game_path: PathBuf,
+ pub mod_slug: String,
+ pub relative_path: String,
+}
+
+/// Mark the workspace tab's modified flag.
+///
+/// Inlined at call sites (rather than taking `&mut App`) so the borrow
+/// checker can split it from the outstanding `fog_editors` borrow.
+macro_rules! mark_tab_modified {
+ ($app:expr, $tab_id:expr, $modified:expr) => {
+ if let Some(tab) = $app
+ .state
+ .workspace
+ .tabs
+ .iter_mut()
+ .find(|t| t.id == $tab_id)
+ {
+ tab.modified = $modified;
+ }
+ };
+}
+
+pub fn handle(message: FogDataMessage, app: &mut App) -> Task {
+ // Every variant names its tab, so we always mutate the right editor.
+ let tab_id = message.tab_id();
+ let Some(editor) = app.state.editors.fog_editors.get_mut(&tab_id) else {
+ return Task::none();
+ };
+
+ match message {
+ // ── Navigation ───────────────────────────────────────────────────────
+ FogDataMessage::LevelSelected(_, level) => {
+ if level >= 1 && (level as usize) <= ROWS {
+ editor.selected_level = level;
+ editor.sync_input_from_selection();
+ return level_list_scroll_task(level);
+ }
+ }
+ FogDataMessage::PairSelected(_, pair) => {
+ if pair < ROW_LEN {
+ editor.selected_pair = pair;
+ editor.sync_input_from_selection();
+ }
+ }
+
+ // ── Curve painting ───────────────────────────────────────────────────
+ FogDataMessage::FactorPainted(_, pair, value) => {
+ if value > MAX_FACTOR || pair >= ROW_LEN {
+ return Task::none(); // Never trust the pointer beyond bounds.
+ }
+ editor.begin_stroke_if_needed();
+ editor.selected_pair = pair;
+ let changed = editor.paint_factor(pair, value);
+ if changed {
+ editor.dirty = true;
+ editor.save_generation += 1;
+ mark_tab_modified!(app, tab_id, true);
+ }
+ editor.sync_input_from_selection();
+ }
+ FogDataMessage::StrokeEnded(_) => {
+ editor.end_stroke();
+ }
+
+ // ── Inspector edits ──────────────────────────────────────────────────
+ FogDataMessage::FactorCommitted(_, pair, value) => {
+ if value > MAX_FACTOR || pair >= ROW_LEN {
+ return Task::none();
+ }
+ match editor.commit_factor(pair, value) {
+ Ok(changed) => {
+ editor.input_error = None;
+ editor.sync_input_from_selection();
+ if changed {
+ editor.dirty = true;
+ editor.save_generation += 1;
+ mark_tab_modified!(app, tab_id, true);
+ }
+ }
+ Err(e) => editor.input_error = Some(e),
+ }
+ }
+ FogDataMessage::ValueInputChanged(_, raw) => {
+ editor.set_value_input(raw);
+ }
+ FogDataMessage::ValueSubmitted(_) => {
+ if editor.submit_value_input().is_some() {
+ editor.dirty = true;
+ editor.save_generation += 1;
+ mark_tab_modified!(app, tab_id, true);
+ }
+ }
+
+ // ── Save ─────────────────────────────────────────────────────────────
+ FogDataMessage::Save(_) => {
+ // Capture the generation this save is based on; any edit (or
+ // another save) bumps it and invalidates the completion.
+ editor.save_generation += 1;
+ let save_generation = editor.save_generation;
+ let path = editor.save_path.clone();
+ let fog = editor.fog.clone();
+ let recording = mod_recording_params(app, &path);
+ app.state.status_msg = "Saving fogdata.dat…".to_string();
+ return Task::perform(
+ async move { save_fog_data(path, fog, recording).await },
+ move |result| {
+ Message::fog_data(FogDataMessage::SaveComplete(
+ tab_id,
+ save_generation,
+ result,
+ ))
+ },
+ );
+ }
+ FogDataMessage::SaveComplete(_, generation, result) => match result {
+ Ok((msg, recording_error)) => {
+ // Only mark clean when no edit landed while the save was in
+ // flight — a stale completion must keep the tab dirty.
+ if generation == editor.save_generation {
+ editor.mark_clean();
+ mark_tab_modified!(app, tab_id, false);
+ }
+ // The file on disk IS current even when only the mod
+ // recording failed — report both facts separately.
+ app.state.status_msg = match recording_error {
+ Some(err) => format!("{msg} — Mod recording failed: {err}"),
+ None => msg,
+ };
+ }
+ Err(e) => {
+ // Transient failure: keep the editor dirty and report via the
+ // status bar; the load-error surface stays reserved for parse
+ // failures.
+ app.state.status_msg = format!("Save failed: {e}");
+ }
+ },
+
+ // ── Revert ───────────────────────────────────────────────────────────
+ FogDataMessage::Revert(_) => {
+ if !editor.dirty {
+ // Nothing to lose — reload straight away.
+ match editor.reload_from_disk() {
+ Ok(()) => app.state.status_msg = "Reverted fogdata.dat".to_string(),
+ Err(e) => app.state.status_msg = format!("Revert failed: {e}"),
+ }
+ } else {
+ editor.confirm_revert = true;
+ }
+ }
+ FogDataMessage::RevertConfirmed(_) => match editor.reload_from_disk() {
+ Ok(()) => {
+ mark_tab_modified!(app, tab_id, false);
+ app.state.status_msg = "Reverted fogdata.dat".to_string();
+ }
+ Err(e) => {
+ editor.confirm_revert = false;
+ app.state.status_msg = format!("Revert failed: {e}");
+ }
+ },
+ FogDataMessage::RevertCancelled(_) => {
+ editor.confirm_revert = false;
+ }
+
+ // ── Undo / redo (also reachable via the global Ctrl+Z/Ctrl+Y path) ───
+ FogDataMessage::Undo(_) => {
+ if editor.undo() {
+ editor.save_generation += 1;
+ mark_tab_modified!(app, tab_id, editor.dirty);
+ } else {
+ app.state.status_msg = "Nothing to undo".to_string();
+ }
+ }
+ FogDataMessage::Redo(_) => {
+ if editor.redo() {
+ editor.save_generation += 1;
+ mark_tab_modified!(app, tab_id, true);
+ } else {
+ app.state.status_msg = "Nothing to redo".to_string();
+ }
+ }
+ }
+
+ Task::none()
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+fn mod_recording_params(app: &App, save_path: &std::path::Path) -> Option {
+ let session = app.state.recording.as_ref()?;
+ let game_path = PathBuf::from(&app.state.shared_game_path);
+ let relative_path = relative_to(save_path, &game_path).unwrap_or_default();
+ Some(RecordingParams {
+ workspace_root: session.workspace_root.clone(),
+ game_path,
+ mod_slug: session.mod_slug.clone(),
+ relative_path,
+ })
+}
+
+fn relative_to(path: &std::path::Path, base: &std::path::Path) -> Option {
+ path.strip_prefix(base)
+ .ok()
+ .map(|r| r.to_string_lossy().replace('\\', "/"))
+}
+
+/// Keep the selected level visible in the scrollable list.
+fn level_list_scroll_task(level: u32) -> Task {
+ use iced::widget::scrollable::RelativeOffset;
+ let fraction = ((level.saturating_sub(1)) as f32 / ROWS as f32).clamp(0.0, 1.0);
+ iced::widget::operation::snap_to::(
+ iced::widget::Id::new(crate::editors::fog_data::view::LEVEL_LIST_ID),
+ RelativeOffset {
+ x: None,
+ y: Some(fraction),
+ },
+ )
+}
+
+// ── Save logic ────────────────────────────────────────────────────────────────
+
+/// Save outcome: `(status message, optional mod-recording failure)`.
+/// `Ok` always means the file was written to disk; a recording failure is
+/// reported separately so it can't mask the successful save.
+type SaveOutcome = (String, Option);
+
+pub async fn save_fog_data(
+ path: PathBuf,
+ fog: Option,
+ recording: Option,
+) -> Result {
+ let fog = fog.ok_or_else(|| "No fog data loaded".to_string())?;
+ tokio::task::spawn_blocking(move || {
+ save_to_disk(&path, &fog)?;
+
+ let mut recording_error = None;
+ if let Some(params) = recording {
+ let current_bytes =
+ std::fs::read(&path).map_err(|e| format!("Failed to read saved file: {e}"))?;
+ if let Err(e) = crate::editors::mod_packager::recording::record_file_replace(
+ ¶ms.workspace_root,
+ ¶ms.game_path,
+ ¶ms.mod_slug,
+ ¶ms.relative_path,
+ ¤t_bytes,
+ ) {
+ recording_error = Some(e);
+ }
+ }
+
+ Ok((format!("Saved → {}", path.display()), recording_error))
+ })
+ .await
+ .map_err(|e| e.to_string())?
+}
+
+/// Synchronous disk write — the unit of work behind [`save_fog_data`],
+/// exposed for tests.
+pub fn save_to_disk(
+ path: &std::path::Path,
+ fog: &dispel_core::map::fogdata::FogData,
+) -> Result<(), String> {
+ fog.save_file(path).map_err(|e| e.to_string())
+}
+
+impl FogDataMessage {
+ /// The owning tab id carried by every variant.
+ pub fn tab_id(&self) -> usize {
+ match self {
+ Self::LevelSelected(id, _)
+ | Self::PairSelected(id, _)
+ | Self::FactorPainted(id, _, _)
+ | Self::StrokeEnded(id)
+ | Self::FactorCommitted(id, _, _)
+ | Self::ValueInputChanged(id, _)
+ | Self::ValueSubmitted(id)
+ | Self::Save(id)
+ | Self::SaveComplete(id, _, _)
+ | Self::Revert(id)
+ | Self::RevertConfirmed(id)
+ | Self::RevertCancelled(id)
+ | Self::Undo(id)
+ | Self::Redo(id) => *id,
+ }
+ }
+}
diff --git a/dispel-gui/src/editors/fog_data/view.rs b/dispel-gui/src/editors/fog_data/view.rs
new file mode 100644
index 00000000..13e1b23b
--- /dev/null
+++ b/dispel-gui/src/editors/fog_data/view.rs
@@ -0,0 +1,338 @@
+use crate::app::App;
+use crate::components::utils::horizontal_rule;
+use crate::editors::fog_data::message::FogDataMessage;
+use crate::editors::fog_data::state::FogDataEditorState;
+use crate::message::{Message, MessageExt};
+use crate::style;
+use gui_widgets::lucide::{LUCIDE_FONT, icon_char};
+use iced::widget::{button, column, container, row, scrollable, space::Space, text, text_input};
+use iced::{Alignment, Color, Element, Fill, Length};
+use lucide_icons::Icon;
+
+/// Id of the scrollable level list (used to keep the selection on screen).
+pub const LEVEL_LIST_ID: &str = "fog-data-level-list";
+const LEVEL_LIST_WIDTH: f32 = 76.0;
+
+/// Shorthand for wrapping a message into the top-level `Message`.
+fn fog(m: FogDataMessage) -> Message {
+ Message::fog_data(m)
+}
+
+/// Warm danger tone for inline validation errors.
+fn error_text(theme: &iced::Theme) -> text::Style {
+ let _ = theme;
+ text::Style {
+ color: Some(Color::from_rgb(0.90, 0.45, 0.38)),
+ }
+}
+
+pub fn view(app: &App) -> Element<'_, Message> {
+ let tab_id = app
+ .state
+ .workspace
+ .active()
+ .map(|t| t.id)
+ .unwrap_or(usize::MAX);
+
+ let Some(editor) = app.state.editors.fog_editors.get(&tab_id) else {
+ return container(text("Fog data not loaded").size(14))
+ .width(Fill)
+ .height(Fill)
+ .padding(16)
+ .accessible_label("Fog data editor")
+ .into();
+ };
+
+ // Parse failure → dedicated error surface; never panic.
+ if let Some(ref err) = editor.error {
+ return container(
+ column![
+ text("Failed to load fogdata.dat")
+ .size(15)
+ .style(style::primary_text),
+ text(err).size(12).style(style::subtle_text),
+ text("The file is missing or not exactly 62,976 bytes.")
+ .size(12)
+ .style(style::subtle_text),
+ ]
+ .spacing(8),
+ )
+ .width(Fill)
+ .height(Fill)
+ .padding(16)
+ .accessible_label("Fog data editor")
+ .into();
+ }
+
+ let base = column![
+ view_header(tab_id, editor),
+ horizontal_rule(1),
+ row![
+ view_level_list(tab_id, editor),
+ view_curve_and_inspector(tab_id, editor)
+ ]
+ .width(Fill)
+ .height(Fill),
+ ]
+ .width(Fill)
+ .height(Fill);
+
+ // Revert confirmation overlays the editor when dirty.
+ if editor.confirm_revert {
+ use gui_widgets::components::modal::modal;
+ let base_element: Element<'_, Message> = base.into();
+ let tab = tab_id;
+ modal(
+ base_element,
+ view_revert_confirm(tab),
+ move || fog(FogDataMessage::RevertCancelled(tab)),
+ 0.5,
+ )
+ } else {
+ base.into()
+ }
+}
+
+// ── Header / toolbar ──────────────────────────────────────────────────────────
+
+fn view_header(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> {
+ let title: Element<'_, Message> = if editor.dirty {
+ row![
+ text("fogdata.dat").size(13).style(style::primary_text),
+ text(" *").size(13).style(error_text),
+ ]
+ .spacing(0)
+ .align_y(Alignment::Center)
+ .into()
+ } else {
+ text("fogdata.dat")
+ .size(13)
+ .style(style::primary_text)
+ .into()
+ };
+
+ // Level stepping.
+ let level = editor.selected_level;
+ let prev = button(
+ text(icon_char(Icon::ChevronLeft))
+ .font(LUCIDE_FONT)
+ .size(12),
+ )
+ .on_press_maybe((level > 1).then(|| fog(FogDataMessage::LevelSelected(tab_id, level - 1))))
+ .padding([3, 7])
+ .style(style::playback_button);
+
+ let level_label = text(format!("Level {level} / {}", editor.level_count()))
+ .size(12)
+ .style(style::section_header);
+
+ let next = button(
+ text(icon_char(Icon::ChevronRight))
+ .font(LUCIDE_FONT)
+ .size(12),
+ )
+ .on_press_maybe(
+ ((level as usize) < editor.level_count())
+ .then(|| fog(FogDataMessage::LevelSelected(tab_id, level + 1))),
+ )
+ .padding([3, 7])
+ .style(style::playback_button);
+
+ // History controls.
+ let undo_btn = button(text(icon_char(Icon::Undo2)).font(LUCIDE_FONT).size(12))
+ .on_press_maybe(editor.can_undo().then(|| fog(FogDataMessage::Undo(tab_id))))
+ .padding([3, 7])
+ .style(style::playback_button);
+
+ let redo_btn = button(text(icon_char(Icon::Redo2)).font(LUCIDE_FONT).size(12))
+ .on_press_maybe(editor.can_redo().then(|| fog(FogDataMessage::Redo(tab_id))))
+ .padding([3, 7])
+ .style(style::playback_button);
+
+ // File actions.
+ let revert_btn = button(text("Revert").size(12))
+ .on_press(fog(FogDataMessage::Revert(tab_id)))
+ .padding([4, 10])
+ .style(style::chip);
+
+ let save_label = if editor.dirty { "Save *" } else { "Save" };
+ let save_btn = button(text(save_label).size(12))
+ .on_press(fog(FogDataMessage::Save(tab_id)))
+ .padding([4, 10])
+ .style(style::export_button);
+
+ container(
+ row![
+ title,
+ Space::new().width(Fill),
+ prev,
+ level_label,
+ next,
+ Space::new().width(8),
+ undo_btn,
+ redo_btn,
+ Space::new().width(4),
+ revert_btn,
+ save_btn,
+ ]
+ .spacing(6)
+ .align_y(Alignment::Center),
+ )
+ .padding([6, 12])
+ .width(Fill)
+ .into()
+}
+
+// ── Level list ────────────────────────────────────────────────────────────────
+
+fn view_level_list(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> {
+ let selected = editor.selected_level;
+
+ let rows: Vec> = (1..=editor.level_count() as u32)
+ .map(|level| {
+ let label: Element<'_, Message> = text(level.to_string())
+ .size(11)
+ .style(if selected == level {
+ style::section_header
+ } else {
+ style::subtle_text
+ })
+ .into();
+ button(container(label).width(Fill).align_x(Alignment::End))
+ .on_press(fog(FogDataMessage::LevelSelected(tab_id, level)))
+ .padding([3, 10])
+ .width(Fill)
+ .style(if selected == level {
+ style::selected_row_button
+ } else {
+ style::normal_row_button
+ })
+ .into()
+ })
+ .collect();
+
+ container(
+ scrollable(column(rows).spacing(1).padding([4, 4]))
+ .id(iced::widget::Id::new(LEVEL_LIST_ID))
+ .width(Fill)
+ .height(Fill),
+ )
+ .width(Length::Fixed(LEVEL_LIST_WIDTH))
+ .height(Fill)
+ .into()
+}
+
+// ── Curve + inspector strip ───────────────────────────────────────────────────
+
+fn view_curve_and_inspector(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> {
+ let canvas = super::curve_canvas::FogCurveCanvas {
+ tab_id,
+ row: editor.current_row(),
+ selected_pair: editor.selected_pair,
+ };
+
+ column![
+ container(canvas.into_element())
+ .width(Fill)
+ .height(Fill)
+ .padding(8),
+ horizontal_rule(1),
+ view_inspector(tab_id, editor),
+ ]
+ .width(Fill)
+ .height(Fill)
+ .spacing(0)
+ .into()
+}
+
+fn view_inspector(tab_id: usize, editor: &FogDataEditorState) -> Element<'_, Message> {
+ let pair = editor.selected_pair;
+ let value = editor.selected_factor().unwrap_or(0);
+ let max = dispel_core::map::fogdata::MAX_FACTOR;
+
+ let readout = format!(
+ "Level {} · Pair {} · brightness {}/{} · {:.0}% light",
+ editor.selected_level,
+ pair,
+ value,
+ max,
+ value as f32 / 32.0 * 100.0,
+ );
+
+ let value_field = text_input("", &editor.value_input)
+ .width(Length::Fixed(56.0))
+ .on_input(move |s| fog(FogDataMessage::ValueInputChanged(tab_id, s)))
+ .on_submit(fog(FogDataMessage::ValueSubmitted(tab_id)))
+ .size(12);
+
+ // Steppers — clamped at both ends so they can never produce an invalid
+ // value.
+ let minus = button(text(icon_char(Icon::Minus)).font(LUCIDE_FONT).size(11))
+ .on_press_maybe(
+ (value > 0).then(|| fog(FogDataMessage::FactorCommitted(tab_id, pair, value - 1))),
+ )
+ .padding([2, 6])
+ .style(style::chip);
+
+ let plus = button(text(icon_char(Icon::Plus)).font(LUCIDE_FONT).size(11))
+ .on_press_maybe(
+ (value < max).then(|| fog(FogDataMessage::FactorCommitted(tab_id, pair, value + 1))),
+ )
+ .padding([2, 6])
+ .style(style::chip);
+
+ let error_line: Element<'_, Message> = match &editor.input_error {
+ Some(msg) => text(format!("⚠ {msg}")).size(11).style(error_text).into(),
+ None => Space::new().into(),
+ };
+
+ container(
+ row![
+ text(readout).size(11).style(style::subtle_text),
+ Space::new().width(12),
+ minus,
+ value_field,
+ plus,
+ Space::new().width(8),
+ error_line,
+ Space::new().width(Fill),
+ text("drag across the curve to paint · click to select a pair")
+ .size(10)
+ .style(style::shortcut_hint),
+ ]
+ .spacing(5)
+ .padding([6, 12])
+ .align_y(Alignment::Center),
+ )
+ .width(Fill)
+ .into()
+}
+
+// ── Revert confirmation ───────────────────────────────────────────────────────
+
+fn view_revert_confirm(tab_id: usize) -> Element<'static, Message> {
+ let body = column![
+ text("Discard changes?").size(14).style(style::primary_text),
+ text("The fade table has unsaved edits. Reloading from disk will discard every change in this tab.")
+ .size(12)
+ .style(style::subtle_text),
+ row![
+ Space::new().width(Fill),
+ button(text("Cancel").size(12))
+ .on_press(fog(FogDataMessage::RevertCancelled(tab_id)))
+ .padding([5, 12])
+ .style(style::playback_button),
+ button(text("Discard & Reload").size(12))
+ .on_press(fog(FogDataMessage::RevertConfirmed(tab_id)))
+ .padding([5, 12])
+ .style(style::export_button),
+ ]
+ .spacing(8)
+ .align_y(Alignment::Center),
+ ]
+ .spacing(10)
+ .padding(20)
+ .width(Length::Fixed(360.0));
+
+ container(body).style(style::export_dialog_container).into()
+}
diff --git a/dispel-gui/src/editors/localization_manager/view.rs b/dispel-gui/src/editors/localization_manager/view.rs
index 0e7ecd3d..0783d247 100644
--- a/dispel-gui/src/editors/localization_manager/view.rs
+++ b/dispel-gui/src/editors/localization_manager/view.rs
@@ -4,7 +4,7 @@ use crate::editors::localization_manager::LocalizationMessage;
use crate::message::{Message, MessageExt};
use crate::style;
use gui_widgets::lucide::{LUCIDE_FONT, icon_char};
-use gui_widgets::textarea;
+use gui_widgets::textarea_fill;
use iced::widget::{
button, checkbox, column, container, pick_list, progress_bar, row, scrollable, text, text_input,
};
@@ -311,7 +311,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
});
let translation_label = text("Translation").size(11).style(style::subtle_text);
- let translation_editor = textarea(&state.translation_content.0, |action| {
+ let translation_editor = textarea_fill(&state.translation_content.0, |action| {
Message::localization(LocalizationMessage::TranslationAction(action))
});
diff --git a/dispel-gui/src/editors/magic/component.rs b/dispel-gui/src/editors/magic/component.rs
index 2662b5f2..bf03169b 100644
--- a/dispel-gui/src/editors/magic/component.rs
+++ b/dispel-gui/src/editors/magic/component.rs
@@ -20,7 +20,7 @@ editable_record_fields!(MagicSpell, {
{ effect_type = Integer / "Effect Type:" },
{ effect_modifier = Integer / "Effect Modifier:" },
{ reserved_0x3c = Integer / "Reserved (0x3C):" },
- { magic_type = Enum(MagicType, ["Magic", "LightMagic", "BlackMagic"]) / "Magic Type:" },
+ { magic_type = Enum(MagicType, ["Magic", "HolyMagic", "DarkMagic", "Fire", "Water", "Earth", "Air"]) / "Magic Type:" },
{ target_animation_blends_with_background = Boolean / "Target Animation Blends:" },
{ animation_set_id = Integer / "Animation Set ID:" },
{ effect_visual_id = Integer / "Effect Visual ID:" },
diff --git a/dispel-gui/src/editors/map_editor/canvas/hit_test.rs b/dispel-gui/src/editors/map_editor/canvas/hit_test.rs
index bb00fb37..8279ea1f 100644
--- a/dispel-gui/src/editors/map_editor/canvas/hit_test.rs
+++ b/dispel-gui/src/editors/map_editor/canvas/hit_test.rs
@@ -107,19 +107,74 @@ fn find_hovered_event_tile(state: &MapEditorState, cx: f32, cy: f32) -> Option<(
Some((tile_x, tile_y))
}
-/// Find what's under the cursor, with priority: entity > collision tile > event tile.
+/// Find the object-id tile under the cursor (if any). Returns `(tx, ty)`.
+fn find_hovered_object_id_tile(state: &MapEditorState, cx: f32, cy: f32) -> Option<(i32, i32)> {
+ let map_handle = state.map_data()?;
+ let model = &map_handle.0.model;
+ let diagonal = model.tiled_map_width + model.tiled_map_height;
+
+ let (tile_x, tile_y) = screen_to_tile(
+ cx,
+ cy,
+ diagonal,
+ state.view.pan_x,
+ state.view.pan_y,
+ state.view.zoom,
+ model.tiled_map_width,
+ model.tiled_map_height,
+ )?;
+
+ Some((tile_x, tile_y))
+}
+
+/// Entity-only hit test for the Pan tool (selection never mutates).
+pub fn find_entity_at(state: &MapEditorState, cx: f32, cy: f32) -> Option {
+ find_hovered_entity_impl(state, cx, cy)
+}
+
+/// Resolve the raw tile under the cursor via the standard screen_to_tile path,
+/// regardless of layer visibility or entities under the cursor. Used by the
+/// editing tools (Collision / ObjectId / EventInspect).
+pub fn find_tile_at(state: &MapEditorState, cx: f32, cy: f32) -> Option<(i32, i32)> {
+ let map_handle = state.map_data()?;
+ let model = &map_handle.0.model;
+ let diagonal = model.tiled_map_width + model.tiled_map_height;
+
+ screen_to_tile(
+ cx,
+ cy,
+ diagonal,
+ state.view.pan_x,
+ state.view.pan_y,
+ state.view.zoom,
+ model.tiled_map_width,
+ model.tiled_map_height,
+ )
+}
+
+/// Find what's under the cursor, with priority: entity > object-id tile > collision tile > event tile.
+///
+/// Legacy priority chain, kept for the Pan tool's hover/status reporting and
+/// existing tests. Editing tools should use `find_tile_at` instead so that
+/// visibility of a layer doesn't gate editability.
pub fn find_hovered_element(state: &MapEditorState, cx: f32, cy: f32) -> Option {
// 1. Try entities (existing logic)
if let Some(entity) = find_hovered_entity_impl(state, cx, cy) {
return Some(entity);
}
- // 2. Try collision tiles (only when collision layer is visible)
+ // 2. Try object-id tiles (only when object-ids layer is visible)
+ if state.view.show_object_ids
+ && let Some((tx, ty)) = find_hovered_object_id_tile(state, cx, cy)
+ {
+ return Some(SelectedEntity::ObjectIdTile(tx, ty));
+ }
+ // 3. Try collision tiles (only when collision layer is visible)
if state.view.show_collisions
&& let Some((tx, ty)) = find_hovered_collision_tile(state, cx, cy)
{
return Some(SelectedEntity::CollisionTile(tx, ty));
}
- // 3. Try event tiles (only when event layer is visible)
+ // 4. Try event tiles (only when event layer is visible)
if state.view.show_events
&& let Some((tx, ty)) = find_hovered_event_tile(state, cx, cy)
{
@@ -138,7 +193,9 @@ pub fn entity_tile(sel: SelectedEntity, state: &MapEditorState) -> Option<(i32,
}),
SelectedEntity::Extra(i) => state.data.extra_refs.get(i).map(|e| (e.map_x, e.map_y)),
SelectedEntity::DrawItem(i) => state.data.draw_items.get(i).map(|d| (d.x_coord, d.y_coord)),
- SelectedEntity::CollisionTile(tx, ty) | SelectedEntity::EventTile(tx, ty) => Some((tx, ty)),
+ SelectedEntity::CollisionTile(tx, ty)
+ | SelectedEntity::EventTile(tx, ty)
+ | SelectedEntity::ObjectIdTile(tx, ty) => Some((tx, ty)),
}
}
diff --git a/dispel-gui/src/editors/map_editor/canvas/mod.rs b/dispel-gui/src/editors/map_editor/canvas/mod.rs
index d4308c50..8cd5c617 100644
--- a/dispel-gui/src/editors/map_editor/canvas/mod.rs
+++ b/dispel-gui/src/editors/map_editor/canvas/mod.rs
@@ -22,7 +22,7 @@ pub use crate::components::map_render::{
// ── Re-exports for external consumers ─────────────────────────────────────────
-pub use hit_test::find_hovered_element;
+pub use hit_test::{find_entity_at, find_hovered_element, find_tile_at};
pub use render_overlays::MapCanvasOverlaysLayer;
/// Tile layer type alias — delegates to the shared generic implementation.
diff --git a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs
index 584827e9..1cd7ab36 100644
--- a/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs
+++ b/dispel-gui/src/editors/map_editor/canvas/render_overlays.rs
@@ -20,6 +20,36 @@ pub struct MapCanvasOverlaysLayer<'a> {
pub tab_id: usize,
}
+/// Return the full isometric diamond for one map tile.
+fn tile_diamond_path(px: f32, py: f32, zoom: f32) -> canvas::Path {
+ let width = TILE_W * zoom;
+ let height = TILE_H * zoom;
+ let center_x = px + width * 0.5;
+ let center_y = py + height * 0.5;
+ canvas::Path::new(|builder| {
+ builder.move_to(Point::new(center_x, center_y - height * 0.5));
+ builder.line_to(Point::new(center_x + width * 0.5, center_y));
+ builder.line_to(Point::new(center_x, center_y + height * 0.5));
+ builder.line_to(Point::new(center_x - width * 0.5, center_y));
+ builder.close();
+ })
+}
+
+/// True when the native visible-area spell gate blocks a cast from `anchor`.
+/// This inverts the map marker view: it identifies the player/camera anchors
+/// from which at least one no-spell marker is visible.
+fn no_spell_cast_is_blocked_at_anchor(
+ map_data: &dispel_core::map::MapData,
+ anchor: (i32, i32),
+) -> bool {
+ dispel_core::map::no_spell_cast_zone_blocks_at_anchor(anchor, |coords| {
+ map_data
+ .events
+ .get(&coords)
+ .is_some_and(|event| event.is_no_spell_cast_zone())
+ })
+}
+
impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> {
type State = MapCanvasState;
@@ -99,7 +129,7 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> {
// Event overlay
if self.state.view.show_events {
for (&(tx, ty), event) in &map_data.events {
- if event.event_id == 0 {
+ if event.event_id() == 0 {
continue;
}
let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom);
@@ -115,7 +145,7 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> {
);
let label_size = (11.0 * zoom).max(6.0);
frame.fill_text(CanvasText {
- content: event.event_id.to_string(),
+ content: event.event_id().to_string(),
position: Point::new(ecx, ecy - 10.0 * zoom),
color: Color::WHITE,
size: iced::Pixels(label_size),
@@ -131,6 +161,107 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> {
}
}
+ // Player-under-roof marker — amber fill. The renderer uses
+ // this marker at the player tile to hide the BTL pass.
+ if self.state.view.show_under_roof_markers {
+ for (&(tx, ty), event) in &map_data.events {
+ if !event.is_under_roof_marker() {
+ continue;
+ }
+ let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom);
+ if !is_visible(px, py, TILE_W * zoom, TILE_H * zoom, bounds) {
+ continue;
+ }
+ frame.fill(
+ &tile_diamond_path(px, py, zoom),
+ Color::from_rgba(1.0, 0.55, 0.05, 0.32),
+ );
+ }
+ }
+
+ // No-spell-casting range — violet fill. Unlike the amber
+ // source markers, this shows every player/camera anchor
+ // whose native 500-tile visible diamond contains a marker.
+ if self.state.view.show_no_spell_cast_zones {
+ for &(tx, ty) in map_data.events.keys() {
+ let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom);
+ if !is_visible(px, py, TILE_W * zoom, TILE_H * zoom, bounds) {
+ continue;
+ }
+ if !no_spell_cast_is_blocked_at_anchor(map_data, (tx, ty)) {
+ continue;
+ }
+ frame.fill(
+ &tile_diamond_path(px, py, zoom),
+ Color::from_rgba(0.8, 0.15, 0.95, 0.25),
+ );
+ }
+ }
+
+ // Conditional roof overlays — teal outline. These access
+ // references can draw in the dark-map roof pass.
+ if self.state.view.show_btl_overlays_in_dark_maps {
+ for (&(tx, ty), &access_ref_word) in &map_data.access_ref_words {
+ if !dispel_core::map::btl_overlay_draws_in_dark_map(access_ref_word) {
+ continue;
+ }
+ let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom);
+ if !is_visible(px, py, TILE_W * zoom, TILE_H * zoom, bounds) {
+ continue;
+ }
+ frame.stroke(
+ &tile_diamond_path(px, py, zoom),
+ canvas::Stroke::default()
+ .with_color(Color::from_rgba(0.05, 0.85, 0.78, 0.95))
+ .with_width((2.0 * zoom).max(1.0)),
+ );
+ }
+ }
+
+ // Object ID overlay — cyan diamond + label for non-zero object slots
+ if self.state.view.show_object_ids {
+ for (&(tx, ty), &obj_id) in &map_data.object_ids {
+ if obj_id == 0 {
+ continue;
+ }
+ let (px, py) = tile_to_screen(tx, ty, diagonal, pan_x, pan_y, zoom);
+ let w = TILE_W * zoom;
+ let h = TILE_H * zoom;
+ if !is_visible(px, py, w, h, bounds) {
+ continue;
+ }
+ let cx = px + w * 0.5;
+ let cy = py + h * 0.5;
+ let dx = w * 0.5;
+ let dy = h * 0.5;
+ frame.fill(
+ &canvas::Path::new(|b| {
+ b.move_to(Point::new(cx, cy - dy));
+ b.line_to(Point::new(cx + dx, cy));
+ b.line_to(Point::new(cx, cy + dy));
+ b.line_to(Point::new(cx - dx, cy));
+ b.close();
+ }),
+ Color::from_rgba(0.1, 0.7, 0.8, 0.3),
+ );
+ let label_size = (10.0 * zoom).max(6.0);
+ frame.fill_text(CanvasText {
+ content: obj_id.to_string(),
+ position: Point::new(cx, cy - 10.0 * zoom),
+ color: Color::WHITE,
+ size: iced::Pixels(label_size),
+ font: Font::MONOSPACE,
+ align_x: TextAlignment::Center,
+ align_y: alignment::Vertical::Bottom,
+ shaping: iced::widget::text::Shaping::Basic,
+ line_height: iced::widget::text::LineHeight::default(),
+ max_width: f32::INFINITY,
+ ellipsis: iced::widget::text::Ellipsis::None,
+ wrapping: iced::widget::text::Wrapping::None,
+ });
+ }
+ }
+
// Draw items overlay (coloured diamond + item_id label)
if self.state.view.show_draw_items {
for di in &self.state.data.draw_items {
@@ -433,8 +564,31 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> {
)
});
let label = format!("X: {} Y: {}", tile_x, tile_y);
+ // Build a compact tile-context readout from map data.
+ let context_label = {
+ let mut parts = Vec::new();
+ if let Some(>l) = map_data.gtl_tiles.get(&(tile_x, tile_y)) {
+ parts.push(format!("gtl {}", gtl));
+ }
+ if map_data
+ .collisions
+ .get(&(tile_x, tile_y))
+ .copied()
+ .unwrap_or(false)
+ {
+ parts.push("blocked".into());
+ }
+ if let Some(&obj) = map_data.object_ids.get(&(tile_x, tile_y)) {
+ parts.push(format!("obj {}", obj));
+ }
+ if parts.is_empty() {
+ label.clone()
+ } else {
+ format!("{} · {}", label, parts.join(" · "))
+ }
+ };
cursor_frame.fill_text(CanvasText {
- content: label.clone(),
+ content: context_label.clone(),
position: Point::new(11.5, 11.5),
color: Color::from_rgba(0.0, 0.0, 0.0, 0.75),
size: iced::Pixels(13.0),
@@ -448,7 +602,7 @@ impl<'a> canvas::Program for MapCanvasOverlaysLayer<'a> {
wrapping: iced::widget::text::Wrapping::None,
});
cursor_frame.fill_text(CanvasText {
- content: label,
+ content: context_label,
position: Point::new(10.0, 10.0),
color: Color::WHITE,
size: iced::Pixels(13.0),
diff --git a/dispel-gui/src/editors/map_editor/canvas/render_tiles.rs b/dispel-gui/src/editors/map_editor/canvas/render_tiles.rs
deleted file mode 100644
index 70f1f664..00000000
--- a/dispel-gui/src/editors/map_editor/canvas/render_tiles.rs
+++ /dev/null
@@ -1,6 +0,0 @@
-// ── MapCanvasTilesLayer — delegates to shared GenericTilesLayer ───────────────
-
-pub type MapCanvasTilesLayer<'a> = crate::components::map_render::GenericTilesLayer<
- 'a,
- crate::editors::map_editor::state::MapEditorState,
->;
diff --git a/dispel-gui/src/editors/map_editor/canvas/state.rs b/dispel-gui/src/editors/map_editor/canvas/state.rs
deleted file mode 100644
index f7c99a69..00000000
--- a/dispel-gui/src/editors/map_editor/canvas/state.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-// ── Interaction state (re-exported from shared map_render module) ─────────────
-
-pub use crate::components::map_render::MapCanvasState;
diff --git a/dispel-gui/src/editors/map_editor/canvas/tests.rs b/dispel-gui/src/editors/map_editor/canvas/tests.rs
index 392e0eab..4cbd2602 100644
--- a/dispel-gui/src/editors/map_editor/canvas/tests.rs
+++ b/dispel-gui/src/editors/map_editor/canvas/tests.rs
@@ -51,14 +51,20 @@ fn make_state(
};
let map_data = MapData {
+ overlay_modes: Vec::new(),
model,
gtl_tiles: HashMap::new(),
btl_tiles: HashMap::new(),
+ access_ref_words: HashMap::new(),
collisions,
events,
+ object_ids: HashMap::new(),
tiled_infos: vec![],
internal_sprites: vec![],
sprite_blocks: vec![],
+ internal_sprite_stamps: Vec::new(),
+ tiled_object_refs: Vec::new(),
+ tiled_bundles: Vec::new(),
};
let mut state = MapEditorState::default();
@@ -631,8 +637,7 @@ fn test_hovered_element_entity_over_event() {
EventBlock {
x: 10,
y: 10,
- _unknown_value: 0,
- event_id: 1,
+ word: 1,
},
);
let state = make_state(
@@ -692,8 +697,7 @@ fn test_hovered_element_event_when_no_entity_or_collision() {
EventBlock {
x: 15,
y: 25,
- _unknown_value: 0,
- event_id: 42,
+ word: 42,
},
);
let state = make_state(
@@ -740,8 +744,7 @@ fn test_hovered_element_respects_layer_visibility() {
EventBlock {
x: 3,
y: 4,
- _unknown_value: 0,
- event_id: 99,
+ word: 99,
},
);
let state = make_state(
@@ -819,3 +822,107 @@ fn test_diamond_path_creates_valid_path() {
// Canvas Path is opaque — just ensure it doesn't panic and has the right type.
let _ = path; // (lint guard)
}
+
+// ── ObjectIdTile hit-testing ──────────────────────────────────────────────────
+
+#[test]
+fn test_hovered_element_object_id_tile_when_visible() {
+ let mut state = make_state(
+ 50,
+ 50,
+ vec![],
+ vec![],
+ vec![],
+ vec![],
+ HashMap::new(),
+ HashMap::new(),
+ );
+ state.view.show_object_ids = true;
+ // Insert object_id into the loaded MapData.
+ if let LoadingState::Loaded(ref mut handle) = state.data.loading_state {
+ Arc::get_mut(&mut handle.0)
+ .unwrap()
+ .object_ids
+ .insert((10, 10), 42);
+ }
+
+ let (cx, cy) = tile_centre_canvas(&state, 10, 10);
+ assert_eq!(
+ find_hovered_element(&state, cx, cy),
+ Some(SelectedEntity::ObjectIdTile(10, 10)),
+ );
+}
+
+#[test]
+fn test_hovered_element_object_id_tile_not_when_hidden() {
+ let mut state = make_state(
+ 50,
+ 50,
+ vec![],
+ vec![],
+ vec![],
+ vec![],
+ HashMap::new(),
+ HashMap::new(),
+ );
+ state.view.show_object_ids = false;
+ if let LoadingState::Loaded(ref mut handle) = state.data.loading_state {
+ Arc::get_mut(&mut handle.0)
+ .unwrap()
+ .object_ids
+ .insert((10, 10), 42);
+ }
+
+ let (cx, cy) = tile_centre_canvas(&state, 10, 10);
+ // When show_object_ids is false, should return None (no other layer visible).
+ assert_eq!(find_hovered_element(&state, cx, cy), None);
+}
+
+#[test]
+fn test_hovered_element_object_id_tile_has_priority_over_collision() {
+ let mut collisions = HashMap::new();
+ collisions.insert((10, 10), true);
+ let mut state = make_state(
+ 50,
+ 50,
+ vec![],
+ vec![],
+ vec![],
+ vec![],
+ collisions,
+ HashMap::new(),
+ );
+ state.view.show_object_ids = true;
+ state.view.show_collisions = true;
+ if let LoadingState::Loaded(ref mut handle) = state.data.loading_state {
+ Arc::get_mut(&mut handle.0)
+ .unwrap()
+ .object_ids
+ .insert((10, 10), 7);
+ }
+
+ let (cx, cy) = tile_centre_canvas(&state, 10, 10);
+ // Object IDs have priority over collisions.
+ assert_eq!(
+ find_hovered_element(&state, cx, cy),
+ Some(SelectedEntity::ObjectIdTile(10, 10)),
+ );
+}
+
+#[test]
+fn test_entity_tile_object_id_tile() {
+ let state = make_state(
+ 50,
+ 50,
+ vec![],
+ vec![],
+ vec![],
+ vec![],
+ HashMap::new(),
+ HashMap::new(),
+ );
+ assert_eq!(
+ entity_tile(SelectedEntity::ObjectIdTile(5, 9), &state),
+ Some((5, 9)),
+ );
+}
diff --git a/dispel-gui/src/editors/map_editor/message.rs b/dispel-gui/src/editors/map_editor/message.rs
index b9dcbf18..19070e2a 100644
--- a/dispel-gui/src/editors/map_editor/message.rs
+++ b/dispel-gui/src/editors/map_editor/message.rs
@@ -152,8 +152,8 @@ pub enum MapEditorMessage {
ExportImage(usize),
/// Async export completed. Ok carries the output path; Err the error.
ExportComplete(usize, Result),
- /// Clear the status message (auto-dismiss after a delay).
- ClearStatus(usize),
+ /// Dismiss the toast at `index` (close button or timeout).
+ DismissToast(usize, usize),
/// Switch between Map and Sprites view modes.
SwitchViewMode(usize, MapViewMode),
/// Select a sprite sequence in the browser (None = deselect).
@@ -206,6 +206,16 @@ pub enum MapEditorMessage {
ExportTmx(usize),
/// Async TMX export completed. Ok carries the output path; Err the error.
TmxExportComplete(usize, Result),
+ /// Set the object-id brush value for painting (1–511).
+ SetObjectBrush(usize, i32),
+ /// Select the active canvas tool (Pan / Collision / ObjectId / EventInspect).
+ SelectTool(usize, MapTool),
+ /// Set how the object-id brush is applied (Paint / Erase).
+ SetObjectBrushMode(usize, ObjectBrushMode),
+ /// Toggle the layers dropdown popover open/closed.
+ ToggleLayersPopover(usize),
+ /// Close the layers dropdown popover.
+ CloseLayersPopover(usize),
}
/// Which entity or tile is currently selected in the map editor inspector.
@@ -220,6 +230,18 @@ pub enum SelectedEntity {
CollisionTile(i32, i32),
/// An event tile coordinate — carries (tx, ty).
EventTile(i32, i32),
+ /// An object-id tile coordinate — carries (tx, ty).
+ ObjectIdTile(i32, i32),
+}
+
+/// How the object-id brush is applied on click (enum over boolean per conventions).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum ObjectBrushMode {
+ /// Write the brush value, overwriting whatever was there.
+ #[default]
+ Paint,
+ /// Remove the object-id entry regardless of its current value.
+ Erase,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -227,14 +249,46 @@ pub enum MapLayer {
Ground,
Buildings,
Roofs,
+ Shadows,
InternalSprites,
Collisions,
Events,
+ UnderRoofMarkers,
+ NoSpellCastZones,
+ BtlOverlaysInDarkMaps,
Monsters,
Npcs,
NpcWaypoints,
Objects,
DrawItems,
+ ObjectIds,
+}
+
+/// Active canvas tool. Visibility of a layer no longer implies editability:
+/// editing tools must be explicitly selected.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum MapTool {
+ /// Select entities / pan the viewport (default).
+ #[default]
+ Pan,
+ /// Click a tile to toggle its collision flag.
+ Collision,
+ /// Click a tile to paint/erase an object ID with the current brush.
+ ObjectId,
+ /// Click a tile to inspect its event in the inspector (read-only).
+ EventInspect,
+}
+
+impl MapTool {
+ /// The map layer this tool edits, if any.
+ pub fn owning_layer(self) -> Option {
+ match self {
+ MapTool::Pan => None,
+ MapTool::Collision => Some(MapLayer::Collisions),
+ MapTool::ObjectId => Some(MapLayer::ObjectIds),
+ MapTool::EventInspect => Some(MapLayer::Events),
+ }
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
diff --git a/dispel-gui/src/editors/map_editor/state.rs b/dispel-gui/src/editors/map_editor/state.rs
index c7feb86a..1382b2a4 100644
--- a/dispel-gui/src/editors/map_editor/state.rs
+++ b/dispel-gui/src/editors/map_editor/state.rs
@@ -3,6 +3,7 @@ use crate::components::loading_state::LoadingState;
pub use crate::components::map_render::{EntitySpriteHandle, InternalSpriteHandle, MapViewState};
use dispel_core::references::dialogue_paragraph::DialogueParagraph;
use dispel_core::references::dialogue_script::DialogueScript;
+use gui_widgets::components::toast::{Status, Toast};
use iced::widget::image::Handle;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
@@ -89,7 +90,7 @@ pub struct ChoiceOption {
/// State for the interactive conversation display.
///
/// Walks the DLG graph sequentially, checking event gates and following
-/// branches, exactly as the game engine would.
+/// branches.
#[derive(Debug, Clone)]
pub struct ConversationState {
/// Index of the NPC being conversed with.
@@ -164,14 +165,19 @@ pub struct MapDataState {
pub redo_stack: VecDeque,
/// True when there are unsaved entity changes.
pub dirty: bool,
+ /// Lighting fade tables for the observed shadow pass. Loaded only
+ /// when this map is flagged Dark in AllMap.ini and a game path is set.
+ pub shadow_fog: Option>,
/// True while an async entity save is in flight.
pub is_saving: bool,
/// True while an async PNG export is in flight.
pub is_exporting: bool,
- /// Last save/export status message for display in the toolbar.
- pub status_msg: Option,
+ /// Toast notifications (top-right overlay, auto-dismissed).
+ pub toasts: Vec,
/// Sprite export dialog state (None = dialog closed).
pub sprite_export_dialog: Option,
+ /// Object-id brush value for painting (1–511, default 1).
+ pub object_brush: i32,
}
impl Default for MapDataState {
@@ -201,19 +207,72 @@ impl Default for MapDataState {
undo_stack: VecDeque::new(),
redo_stack: VecDeque::new(),
dirty: false,
+ shadow_fog: None,
is_saving: false,
is_exporting: false,
- status_msg: None,
+ toasts: Vec::new(),
sprite_export_dialog: None,
+ object_brush: 1,
}
}
}
+/// Maximum number of toasts kept on screen.
+const MAX_TOASTS: usize = 4;
+
impl MapDataState {
pub fn map_data(&self) -> Option<&MapDataHandle> {
self.loading_state.data()
}
+ /// Push a toast notification.
+ ///
+ /// Skips the push when it is identical (title + body) to the LAST toast,
+ /// deduping rapid repeats; caps the list at [`MAX_TOASTS`] by dropping
+ /// the oldest.
+ pub fn notify(&mut self, status: Status, title: impl Into, body: impl Into) {
+ let title = title.into();
+ let body = body.into();
+ if let Some(last) = self.toasts.last()
+ && last.title == title
+ && last.body == body
+ {
+ return;
+ }
+ self.toasts.push(Toast {
+ title,
+ body,
+ status,
+ });
+ if self.toasts.len() > MAX_TOASTS {
+ self.toasts.remove(0);
+ }
+ }
+
+ /// Push a toast, replacing the previous toast of the same `kind` instead
+ /// of stacking. Kinds are matched by title equality, so pass the same
+ /// `kind` and `title` for all events of one class (e.g. `"Collision"`).
+ /// Keeps the stack clean during paint strokes / rapid repeats.
+ pub fn notify_replace(
+ &mut self,
+ kind: &'static str,
+ status: Status,
+ title: impl Into,
+ body: impl Into,
+ ) {
+ let title = title.into();
+ let body = body.into();
+ if let Some(last) = self.toasts.last_mut()
+ && last.title == kind
+ {
+ last.status = status;
+ last.title = title;
+ last.body = body;
+ return;
+ }
+ self.notify(status, title, body);
+ }
+
/// Returns `true` when the `MapDataHandle` Arc is safe to borrow mutably.
/// During save/export, a clone of the Arc is held by the async task,
/// making `Arc::get_mut` panic.
@@ -358,6 +417,10 @@ impl MapRenderSource for MapEditorState {
self.data.tiles_ready
}
+ fn shadow_data(&self) -> Option<&std::sync::Arc> {
+ self.data.shadow_fog.as_ref()
+ }
+
fn view(&self) -> &MapViewState {
&self.view
}
@@ -436,3 +499,56 @@ impl MapRenderSource for MapEditorState {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gui_widgets::components::toast::Status;
+
+ #[test]
+ fn test_notify_dedupes_identical_consecutive() {
+ let mut s = MapDataState::default();
+ s.notify(Status::Success, "Saved", "cat1.map");
+ s.notify(Status::Success, "Saved", "cat1.map");
+ assert_eq!(s.toasts.len(), 1, "identical consecutive toast is deduped");
+
+ // Same title but different body → new toast.
+ s.notify(Status::Success, "Saved", "dun04.map");
+ assert_eq!(s.toasts.len(), 2);
+ }
+
+ #[test]
+ fn test_notify_caps_at_four_dropping_oldest() {
+ let mut s = MapDataState::default();
+ for i in 0..6 {
+ s.notify(Status::Primary, "Collision", format!("({i},{i})"));
+ }
+ assert_eq!(s.toasts.len(), 4, "list capped at 4");
+ assert_eq!(s.toasts[0].body, "(2,2)", "oldest toasts dropped first");
+ assert_eq!(s.toasts[3].body, "(5,5)");
+ }
+
+ #[test]
+ fn test_notify_replace_replaces_same_kind_last_toast() {
+ let mut s = MapDataState::default();
+ s.notify_replace("Collision", Status::Primary, "Collision", "(1,1)");
+ s.notify_replace("Collision", Status::Primary, "Collision", "(2,2)");
+ assert_eq!(s.toasts.len(), 1, "same kind replaces instead of stacking");
+ assert_eq!(s.toasts[0].body, "(2,2)");
+
+ // Different kind pushes a new toast.
+ s.notify_replace("Object ID", Status::Primary, "Object ID", "Obj 7 → (3,3)");
+ assert_eq!(s.toasts.len(), 2);
+
+ // Replacing only touches the LAST toast.
+ s.notify_replace(
+ "Object ID",
+ Status::Primary,
+ "Object ID",
+ "Erased obj @ (4,4)",
+ );
+ assert_eq!(s.toasts.len(), 2);
+ assert_eq!(s.toasts[0].body, "(2,2)");
+ assert_eq!(s.toasts[1].body, "Erased obj @ (4,4)");
+ }
+}
diff --git a/dispel-gui/src/editors/map_editor/update/conversation.rs b/dispel-gui/src/editors/map_editor/update/conversation.rs
index 46f58dfe..7bc29a87 100644
--- a/dispel-gui/src/editors/map_editor/update/conversation.rs
+++ b/dispel-gui/src/editors/map_editor/update/conversation.rs
@@ -23,7 +23,11 @@ pub fn start(app: &mut App, tab_id: usize, npc_idx: usize) -> Task {
let entry_dialog_id = npc.dialog_id;
if entry_dialog_id == 0 {
if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) {
- editor.data.status_msg = Some("NPC has no dialog".into());
+ editor.data.notify(
+ gui_widgets::components::toast::Status::Warning,
+ "Conversation",
+ "NPC has no dialog",
+ );
}
return Task::none();
}
@@ -122,7 +126,11 @@ pub fn loaded(app: &mut App, tab_id: usize, result: ConversationLoadResult) -> T
editor.view.conversation = Some(conv);
}
Err(err) => {
- editor.data.status_msg = Some(format!("Conversation: {err}"));
+ editor.data.notify(
+ gui_widgets::components::toast::Status::Danger,
+ "Error",
+ format!("Conversation: {err}"),
+ );
}
}
}
diff --git a/dispel-gui/src/editors/map_editor/update/dialog.rs b/dispel-gui/src/editors/map_editor/update/dialog.rs
index 8c3d551d..847f8278 100644
--- a/dispel-gui/src/editors/map_editor/update/dialog.rs
+++ b/dispel-gui/src/editors/map_editor/update/dialog.rs
@@ -84,12 +84,19 @@ pub fn preview_loaded(
dialog_paragraphs: paragraphs,
});
} else {
- state.data.status_msg =
- Some("Dialog preview discarded: NPC selection changed".into());
+ state.data.notify(
+ gui_widgets::components::toast::Status::Warning,
+ "Dialog",
+ "Preview discarded: NPC selection changed",
+ );
}
}
Err(err) => {
- state.data.status_msg = Some(format!("Dialog preview: {err}"));
+ state.data.notify(
+ gui_widgets::components::toast::Status::Danger,
+ "Error",
+ format!("Dialog preview: {err}"),
+ );
}
}
}
diff --git a/dispel-gui/src/editors/map_editor/update/entity.rs b/dispel-gui/src/editors/map_editor/update/entity.rs
index 39f91b24..b955e36b 100644
--- a/dispel-gui/src/editors/map_editor/update/entity.rs
+++ b/dispel-gui/src/editors/map_editor/update/entity.rs
@@ -47,9 +47,10 @@ pub fn field_changed(
SelectedEntity::EventTile(tx, ty) => state
.map_data()
.and_then(|h| h.0.events.get(&(tx, ty)))
- .map(|e| e.event_id.to_string())
+ .map(|e| e.event_id().to_string())
.unwrap_or_default(),
SelectedEntity::CollisionTile(_, _) => String::new(),
+ SelectedEntity::ObjectIdTile(_, _) => String::new(),
};
// Apply the change; track whether the entity was actually found/mutated
// so we don't push an undo action for a no-op (out-of-bounds index).
@@ -85,8 +86,11 @@ pub fn field_changed(
}
SelectedEntity::EventTile(tx, ty) => {
if !state.data.can_mutate_map_data() {
- state.data.status_msg =
- Some("Cannot edit events while save/export is in progress".into());
+ state.data.notify(
+ gui_widgets::components::toast::Status::Warning,
+ "Event",
+ "Cannot edit while save/export is in progress",
+ );
entity_mutated = false;
} else if let LoadingState::Loaded(ref mut handle) = state.data.loading_state {
let map_data = Arc::get_mut(&mut handle.0)
@@ -94,10 +98,9 @@ pub fn field_changed(
let ev = map_data.events.entry((tx, ty)).or_insert(EventBlock {
x: tx,
y: ty,
- _unknown_value: 0,
- event_id: 0,
+ word: 0,
});
- ev.event_id = value.parse::().unwrap_or(0);
+ ev.set_event_id(value.parse::().unwrap_or(0) as u16);
} else {
entity_mutated = false;
}
@@ -105,6 +108,9 @@ pub fn field_changed(
SelectedEntity::CollisionTile(_, _) => {
entity_mutated = false;
}
+ SelectedEntity::ObjectIdTile(_, _) => {
+ entity_mutated = false;
+ }
}
if entity_mutated && old_value != value {
state.push_undo(MapEditAction {
@@ -198,10 +204,23 @@ pub fn undo(app: &mut App, tab_id: usize) -> Task {
.or_insert(dispel_core::map::EventBlock {
x: tx,
y: ty,
- _unknown_value: 0,
- event_id: 0,
+ word: 0,
});
- ev.event_id = val;
+ ev.set_event_id(val as u16);
+ }
+ }
+ SelectedEntity::ObjectIdTile(tx, ty) => {
+ if state.data.can_mutate_map_data()
+ && let LoadingState::Loaded(ref mut handle) = state.data.loading_state
+ {
+ let map_data = Arc::get_mut(&mut handle.0)
+ .expect("MapData Arc has unexpected shared reference");
+ let val = action.old_value.parse::().unwrap_or(0);
+ if val == 0 {
+ map_data.object_ids.remove(&(tx, ty));
+ } else {
+ map_data.object_ids.insert((tx, ty), val);
+ }
}
}
}
@@ -276,7 +295,21 @@ pub fn redo(app: &mut App, tab_id: usize) -> Task {
.expect("MapData Arc has unexpected shared reference");
let val = action.new_value.parse::().unwrap_or(0);
if let Some(ev) = map_data.events.get_mut(&(tx, ty)) {
- ev.event_id = val;
+ ev.set_event_id(val as u16);
+ }
+ }
+ }
+ SelectedEntity::ObjectIdTile(tx, ty) => {
+ if state.data.can_mutate_map_data()
+ && let LoadingState::Loaded(ref mut handle) = state.data.loading_state
+ {
+ let map_data = Arc::get_mut(&mut handle.0)
+ .expect("MapData Arc has unexpected shared reference");
+ let val = action.new_value.parse::().unwrap_or(0);
+ if val == 0 {
+ map_data.object_ids.remove(&(tx, ty));
+ } else {
+ map_data.object_ids.insert((tx, ty), val);
}
}
}
@@ -443,4 +476,141 @@ mod tests {
assert_eq!(state.data.draw_items[0].y_coord, 77);
assert_eq!(state.data.undo_stack.len(), 1);
}
+
+ // ── ObjectIdTile undo/redo ──────────────────────────────────────────────────
+
+ /// Helper: create an App with a map editor tab that has a loaded MapData
+ /// containing one object_id entry at (10, 10) = 42.
+ fn app_with_object_ids() -> (App, usize) {
+ use crate::editors::map_editor::message::MapDataHandle;
+ use dispel_core::map::{MapData, MapModel};
+ use std::collections::HashMap;
+
+ let tab_id = 99;
+ let mut app = App::new().0;
+ let state = app.state.editors.map_editors.entry(tab_id).or_default();
+
+ let model = MapModel {
+ border_count: 2,
+ tiled_map_width: 50,
+ tiled_map_height: 50,
+ map_width_in_pixels: 3200,
+ map_height_in_pixels: 1600,
+ map_non_occluded_start_x: 0,
+ map_non_occluded_start_y: 0,
+ occluded_map_in_pixels_width: 3200,
+ occluded_map_in_pixels_height: 1600,
+ };
+ let mut object_ids = HashMap::new();
+ object_ids.insert((10, 10), 42);
+ let map_data = MapData {
+ overlay_modes: Vec::new(),
+ model,
+ gtl_tiles: HashMap::new(),
+ btl_tiles: HashMap::new(),
+ access_ref_words: HashMap::new(),
+ collisions: HashMap::new(),
+ events: HashMap::new(),
+ object_ids,
+ tiled_infos: vec![],
+ internal_sprites: vec![],
+ sprite_blocks: vec![],
+ internal_sprite_stamps: Vec::new(),
+ tiled_object_refs: Vec::new(),
+ tiled_bundles: Vec::new(),
+ };
+ state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data)));
+
+ app.state
+ .workspace
+ .tabs
+ .push(crate::workspace::WorkspaceTab {
+ id: tab_id,
+ label: "test".into(),
+ path: None,
+ editor_type: crate::workspace::EditorType::MapEditor,
+ modified: false,
+ pinned: false,
+ });
+ app.state.workspace.active_tab = Some(0);
+ (app, tab_id)
+ }
+
+ /// Simulate painting object_id at (10,10) from 42 to 99 via push_undo,
+ /// then undo it.
+ #[test]
+ fn test_object_id_undo_restores_previous_value() {
+ let (mut app, tab_id) = app_with_object_ids();
+
+ // Push an undo action as if we painted from 42→99.
+ {
+ let state = app.state.editors.map_editors.get_mut(&tab_id).unwrap();
+ state.push_undo(MapEditAction {
+ entity: SelectedEntity::ObjectIdTile(10, 10),
+ field: "object_id".into(),
+ old_value: "42".into(),
+ new_value: "99".into(),
+ });
+ }
+
+ let _task = undo(&mut app, tab_id);
+
+ let state = app.state.editors.map_editors.get(&tab_id).unwrap();
+ if let LoadingState::Loaded(ref handle) = state.data.loading_state {
+ assert_eq!(handle.0.object_ids.get(&(10, 10)), Some(&42));
+ } else {
+ panic!("MapData not loaded");
+ }
+ }
+
+ /// Push undo for painting from 0→55 (new tile), then undo should remove it.
+ #[test]
+ fn test_object_id_undo_clears_when_previous_was_zero() {
+ let (mut app, tab_id) = app_with_object_ids();
+
+ {
+ let state = app.state.editors.map_editors.get_mut(&tab_id).unwrap();
+ state.push_undo(MapEditAction {
+ entity: SelectedEntity::ObjectIdTile(20, 20),
+ field: "object_id".into(),
+ old_value: "0".into(),
+ new_value: "55".into(),
+ });
+ }
+
+ let _task = undo(&mut app, tab_id);
+
+ let state = app.state.editors.map_editors.get(&tab_id).unwrap();
+ if let LoadingState::Loaded(ref handle) = state.data.loading_state {
+ assert!(!handle.0.object_ids.contains_key(&(20, 20)));
+ } else {
+ panic!("MapData not loaded");
+ }
+ }
+
+ /// Undo then redo should restore the new value.
+ #[test]
+ fn test_object_id_redo_reapplies_new_value() {
+ let (mut app, tab_id) = app_with_object_ids();
+
+ {
+ let state = app.state.editors.map_editors.get_mut(&tab_id).unwrap();
+ state.push_undo(MapEditAction {
+ entity: SelectedEntity::ObjectIdTile(10, 10),
+ field: "object_id".into(),
+ old_value: "42".into(),
+ new_value: "99".into(),
+ });
+ }
+
+ let _task = undo(&mut app, tab_id);
+ let _task = redo(&mut app, tab_id);
+
+ let state = app.state.editors.map_editors.get(&tab_id).unwrap();
+ if let LoadingState::Loaded(ref handle) = state.data.loading_state {
+ assert_eq!(handle.0.object_ids.get(&(10, 10)), Some(&99));
+ } else {
+ panic!("MapData not loaded");
+ }
+ }
}
diff --git a/dispel-gui/src/editors/map_editor/update/map.rs b/dispel-gui/src/editors/map_editor/update/map.rs
index cb06f5dd..a1049600 100644
--- a/dispel-gui/src/editors/map_editor/update/map.rs
+++ b/dispel-gui/src/editors/map_editor/update/map.rs
@@ -15,6 +15,169 @@ use std::sync::Arc;
// ── Message handlers ─────────────────────────────────────────────────────────
+use crate::components::map_render::view_state::MapViewState;
+use crate::editors::map_editor::message::{MapLayer, MapTool, ObjectBrushMode, SelectedEntity};
+use crate::editors::map_editor::state::MapEditAction;
+
+/// Whether a display layer is currently visible.
+pub fn layer_visible(view: &MapViewState, layer: MapLayer) -> bool {
+ match layer {
+ MapLayer::Ground => view.show_ground,
+ MapLayer::Buildings => view.show_buildings,
+ MapLayer::Roofs => view.show_roofs,
+ MapLayer::Shadows => view.show_shadows,
+ MapLayer::InternalSprites => view.show_internal_sprites,
+ MapLayer::Collisions => view.show_collisions,
+ MapLayer::Events => view.show_events,
+ MapLayer::UnderRoofMarkers => view.show_under_roof_markers,
+ MapLayer::NoSpellCastZones => view.show_no_spell_cast_zones,
+ MapLayer::BtlOverlaysInDarkMaps => view.show_btl_overlays_in_dark_maps,
+ MapLayer::Monsters => view.show_monsters,
+ MapLayer::Npcs => view.show_npcs,
+ MapLayer::NpcWaypoints => view.show_npc_waypoints,
+ MapLayer::Objects => view.show_objects,
+ MapLayer::DrawItems => view.show_draw_items,
+ MapLayer::ObjectIds => view.show_object_ids,
+ }
+}
+
+fn set_layer_visible(view: &mut MapViewState, layer: MapLayer, visible: bool) {
+ match layer {
+ MapLayer::Ground => view.show_ground = visible,
+ MapLayer::Buildings => view.show_buildings = visible,
+ MapLayer::Roofs => view.show_roofs = visible,
+ MapLayer::Shadows => view.show_shadows = visible,
+ MapLayer::InternalSprites => view.show_internal_sprites = visible,
+ MapLayer::Collisions => view.show_collisions = visible,
+ MapLayer::Events => view.show_events = visible,
+ MapLayer::UnderRoofMarkers => view.show_under_roof_markers = visible,
+ MapLayer::NoSpellCastZones => view.show_no_spell_cast_zones = visible,
+ MapLayer::BtlOverlaysInDarkMaps => view.show_btl_overlays_in_dark_maps = visible,
+ MapLayer::Monsters => view.show_monsters = visible,
+ MapLayer::Npcs => view.show_npcs = visible,
+ MapLayer::NpcWaypoints => view.show_npc_waypoints = visible,
+ MapLayer::Objects => view.show_objects = visible,
+ MapLayer::DrawItems => view.show_draw_items = visible,
+ MapLayer::ObjectIds => view.show_object_ids = visible,
+ }
+}
+
+/// Select the active canvas tool. If the tool's owning layer is hidden,
+/// force-enable it (visibility ≠ editability: selecting an editing tool
+/// implies you need to see what you edit).
+pub fn select_tool(app: &mut App, tab_id: usize, tool: MapTool) -> Task {
+ if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
+ if let Some(layer) = tool.owning_layer()
+ && !layer_visible(&state.view, layer)
+ {
+ set_layer_visible(&mut state.view, layer, true);
+ state.view.overlay_cache.clear();
+ state.view.tile_layer_cache.clear();
+ }
+ state.view.active_tool = tool;
+ }
+ Task::none()
+}
+
+/// Toggle the collision flag on a tile. Returns `true` when the map changed.
+pub fn toggle_collision_at(
+ state: &mut crate::editors::map_editor::state::MapEditorState,
+ tx: i32,
+ ty: i32,
+) -> bool {
+ if !state.data.can_mutate_map_data() {
+ state.data.notify(
+ gui_widgets::components::toast::Status::Warning,
+ "Collision",
+ "Cannot edit while save/export is in progress",
+ );
+ return false;
+ }
+ let LoadingState::Loaded(ref mut handle) = state.data.loading_state else {
+ return false;
+ };
+ let map_data =
+ Arc::get_mut(&mut handle.0).expect("MapData Arc has unexpected shared reference");
+ let old = map_data.collisions.get(&(tx, ty)).copied().unwrap_or(false);
+ map_data.collisions.insert((tx, ty), !old);
+ state.push_undo(MapEditAction {
+ entity: SelectedEntity::CollisionTile(tx, ty),
+ field: "collision".into(),
+ old_value: old.to_string(),
+ new_value: (!old).to_string(),
+ });
+ state.view.selected_entity = None;
+ state.view.overlay_cache.clear();
+ // Replace-in-place: painting a stroke updates one toast, not many.
+ state.data.notify_replace(
+ "Collision",
+ gui_widgets::components::toast::Status::Primary,
+ "Collision",
+ format!(
+ "{} ({},{})",
+ if old { "Unblocked" } else { "Blocked" },
+ tx,
+ ty
+ ),
+ );
+ true
+}
+
+/// Apply the object-id brush to a tile according to the current brush mode.
+///
+/// Paint writes the brush value (overwriting whatever was there); Erase removes
+/// the entry regardless of its current value. Returns `true` when changed.
+pub fn apply_object_id_edit(
+ state: &mut crate::editors::map_editor::state::MapEditorState,
+ tx: i32,
+ ty: i32,
+) -> bool {
+ if !state.data.can_mutate_map_data() {
+ state.data.notify(
+ gui_widgets::components::toast::Status::Warning,
+ "Object ID",
+ "Cannot edit while save/export is in progress",
+ );
+ return false;
+ }
+ let LoadingState::Loaded(ref mut handle) = state.data.loading_state else {
+ return false;
+ };
+ let erase = state.view.object_brush_mode == ObjectBrushMode::Erase;
+ let brush = state.data.object_brush.clamp(1, 511);
+ let map_data =
+ Arc::get_mut(&mut handle.0).expect("MapData Arc has unexpected shared reference");
+ let old = map_data.object_ids.get(&(tx, ty)).copied().unwrap_or(0);
+ let new = if erase { 0 } else { brush };
+ if new == 0 {
+ map_data.object_ids.remove(&(tx, ty));
+ } else {
+ map_data.object_ids.insert((tx, ty), new);
+ }
+ state.push_undo(MapEditAction {
+ entity: SelectedEntity::ObjectIdTile(tx, ty),
+ field: "object_id".into(),
+ old_value: old.to_string(),
+ new_value: new.to_string(),
+ });
+ state.view.selected_entity = None;
+ state.view.overlay_cache.clear();
+ state.view.tile_layer_cache.clear();
+ // Replace-in-place: painting a stroke updates one toast, not many.
+ let body = if erase || new == 0 {
+ format!("Erased obj @ ({},{})", tx, ty)
+ } else {
+ format!("Obj {} → ({},{})", new, tx, ty)
+ };
+ state.data.notify_replace(
+ "Object ID",
+ gui_widgets::components::toast::Status::Primary,
+ "Object ID",
+ body,
+ );
+ true
+}
+
pub fn open(app: &mut App, tab_id: usize, path: PathBuf) -> Task {
// Ensure item lookups are loaded for composite-item pickers in the
// entity-inspector panels (MonsterRef, NPC, ExtraRef).
@@ -112,6 +275,19 @@ pub fn map_loaded(
seq_handles.sort_by_key(|s| s.sequence_idx);
state.data.sprite_sequence_handles = seq_handles;
+ let loaded_name = state
+ .data
+ .map_path
+ .as_ref()
+ .and_then(|p| p.file_name())
+ .map(|n| n.to_string_lossy().to_string())
+ .unwrap_or_default();
+ state.data.notify(
+ gui_widgets::components::toast::Status::Success,
+ "Loaded",
+ loaded_name,
+ );
+
// Convert decoded internal sprites → Iced Handles.
state.data.internal_sprite_handles = decoded_sprites
.into_iter()
@@ -125,6 +301,22 @@ pub fn map_loaded(
})
.collect();
+ // Load the shadow/lighting fade tables for Dark maps (no-op for
+ // Light maps or when no game path is configured).
+ let game_path = app.state.workspace.game_path.clone();
+ let map_stem = state
+ .data
+ .map_path
+ .as_ref()
+ .and_then(|p| p.file_stem())
+ .map(|s| s.to_string_lossy().to_string());
+ state.data.shadow_fog = match (game_path, map_stem) {
+ (Some(gp), Some(stem)) => {
+ dispel_core::map::render::load_fog_if_dark(&gp, &stem).map(std::sync::Arc::new)
+ }
+ _ => None,
+ };
+
state.data.loading_state = LoadingState::Loaded(handle);
state.view.tile_layer_cache.clear();
state.view.overlay_cache.clear();
@@ -230,7 +422,11 @@ pub fn tiles_decoded(
};
let game_path = app.state.workspace.game_path.clone();
if game_path.is_none() {
- state.data.status_msg = Some("No game path set — entity files not loaded".to_string());
+ state.data.notify(
+ gui_widgets::components::toast::Status::Warning,
+ "Load",
+ "No game path set — entity files not loaded",
+ );
}
Task::perform(
async move { load_entities(&map_path, game_path) },
@@ -784,4 +980,146 @@ mod tests {
assert_eq!(bundle.all_map_id, None);
assert_eq!(bundle.monsters.len(), 0);
}
+
+ // ── Tool selection & object-id brush ──────────────────────────────────────
+
+ use crate::app::App;
+ use crate::editors::map_editor::state::MapEditorState;
+ use dispel_core::map::{MapData, MapModel};
+
+ const TAB: usize = 7;
+
+ /// App with a map editor tab whose map data is loaded (8×8 tiles).
+ fn app_with_loaded_map(object_ids: HashMap<(i32, i32), i32>) -> App {
+ let mut app = App::new().0;
+ let model = MapModel {
+ border_count: 2,
+ tiled_map_width: 8,
+ tiled_map_height: 8,
+ map_width_in_pixels: 16 * 32,
+ map_height_in_pixels: 16 * 16,
+ map_non_occluded_start_x: 0,
+ map_non_occluded_start_y: 0,
+ occluded_map_in_pixels_width: 8 * 64,
+ occluded_map_in_pixels_height: 8 * 32,
+ };
+ let map_data = MapData {
+ overlay_modes: Vec::new(),
+ model,
+ gtl_tiles: HashMap::new(),
+ btl_tiles: HashMap::new(),
+ access_ref_words: HashMap::new(),
+ collisions: HashMap::new(),
+ events: HashMap::new(),
+ object_ids,
+ tiled_infos: vec![],
+ internal_sprites: vec![],
+ sprite_blocks: vec![],
+ internal_sprite_stamps: Vec::new(),
+ tiled_object_refs: Vec::new(),
+ tiled_bundles: Vec::new(),
+ };
+ let state = app.state.editors.map_editors.entry(TAB).or_default();
+ state.data.loading_state = LoadingState::Loaded(MapDataHandle(Arc::new(map_data)));
+ app
+ }
+
+ #[test]
+ fn test_select_tool_collision_enables_layer() {
+ let mut app = app_with_loaded_map(HashMap::new());
+ assert!(!layer_visible(
+ &app.state.editors.map_editors[&TAB].view,
+ MapLayer::Collisions
+ ));
+
+ let _ = select_tool(&mut app, TAB, MapTool::Collision);
+
+ let state = &app.state.editors.map_editors[&TAB];
+ assert_eq!(state.view.active_tool, MapTool::Collision);
+ assert!(
+ layer_visible(&state.view, MapLayer::Collisions),
+ "selecting the Collision tool must force-enable the Collisions layer"
+ );
+ }
+
+ #[test]
+ fn test_hide_layer_resets_tool_to_pan() {
+ let mut app = app_with_loaded_map(HashMap::new());
+ let _ = select_tool(&mut app, TAB, MapTool::ObjectId);
+ assert_eq!(
+ app.state.editors.map_editors[&TAB].view.active_tool,
+ MapTool::ObjectId
+ );
+
+ // Hide the layer that owns the active tool via LayerToggled.
+ let msg = MapEditorMessage::LayerToggled(TAB, MapLayer::ObjectIds);
+ let _ = super::super::handle(msg, &mut app);
+
+ assert_eq!(
+ app.state.editors.map_editors[&TAB].view.active_tool,
+ MapTool::Pan,
+ "hiding the owning layer must reset the tool to Pan"
+ );
+ }
+
+ #[test]
+ fn test_object_id_paint_writes_brush() {
+ let mut app = app_with_loaded_map(HashMap::from([((5, 5), 7)]));
+ let state = app.state.editors.map_editors.get_mut(&TAB).unwrap();
+ state.data.object_brush = 3;
+ state.view.object_brush_mode = ObjectBrushMode::Paint;
+
+ assert!(apply_object_id_edit(state, 5, 5));
+ let state = app.state.editors.map_editors.get_mut(&TAB).unwrap();
+ if let LoadingState::Loaded(ref handle) = state.data.loading_state {
+ assert_eq!(
+ handle.0.object_ids.get(&(5, 5)),
+ Some(&3),
+ "paint overwrites existing value"
+ );
+ } else {
+ panic!("map not loaded");
+ }
+ assert_eq!(state.data.undo_stack.len(), 1);
+ }
+
+ #[test]
+ fn test_object_id_erase_removes_any_value() {
+ let mut app = app_with_loaded_map(HashMap::from([((5, 5), 7)]));
+ let state = app.state.editors.map_editors.get_mut(&TAB).unwrap();
+ state.data.object_brush = 7; // same value as existing entry — erase must still remove
+ state.view.object_brush_mode = ObjectBrushMode::Erase;
+
+ assert!(apply_object_id_edit(state, 5, 5));
+ let state = app.state.editors.map_editors.get_mut(&TAB).unwrap();
+ if let LoadingState::Loaded(ref handle) = state.data.loading_state {
+ assert!(
+ !handle.0.object_ids.contains_key(&(5, 5)),
+ "erase removes the entry regardless of its value"
+ );
+ } else {
+ panic!("map not loaded");
+ }
+ }
+
+ #[test]
+ fn test_brush_stepper_clamps_at_bounds() {
+ let mut app = app_with_loaded_map(HashMap::new());
+
+ for v in [999, 600] {
+ let msg = MapEditorMessage::SetObjectBrush(TAB, v);
+ let _ = super::super::handle(msg, &mut app);
+ assert_eq!(app.state.editors.map_editors[&TAB].data.object_brush, 511);
+ }
+ for v in [0, -5] {
+ let msg = MapEditorMessage::SetObjectBrush(TAB, v);
+ let _ = super::super::handle(msg, &mut app);
+ assert_eq!(app.state.editors.map_editors[&TAB].data.object_brush, 1);
+ }
+ }
+
+ #[allow(dead_code)]
+ fn _assert_state_type(s: &MapEditorState) -> &MapEditorState {
+ s
+ }
}
diff --git a/dispel-gui/src/editors/map_editor/update/mod.rs b/dispel-gui/src/editors/map_editor/update/mod.rs
index dc197ab6..05ce9600 100644
--- a/dispel-gui/src/editors/map_editor/update/mod.rs
+++ b/dispel-gui/src/editors/map_editor/update/mod.rs
@@ -1,10 +1,9 @@
use crate::app::App;
-use crate::components::loading_state::LoadingState;
-use crate::editors::map_editor::canvas::find_hovered_element;
-use crate::editors::map_editor::{MapEditAction, MapEditorMessage, MapLayer, SelectedEntity};
+use crate::editors::map_editor::canvas::{find_hovered_element, find_tile_at};
+use crate::editors::map_editor::{MapEditorMessage, MapLayer, MapTool, SelectedEntity};
use crate::message::{Message, MessageExt};
+use gui_widgets::components::toast;
use iced::Task;
-use std::sync::Arc;
mod conversation;
mod dialog;
@@ -14,9 +13,6 @@ pub use map::resolve_map_filename;
mod persistence;
mod sprite_export;
-/// Duration before a status message is automatically cleared.
-const STATUS_DISMISS_SECS: u64 = 3;
-
pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
match message {
// ── Delegated to submodules ───────────────────────────────────────────
@@ -54,7 +50,9 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
Some(p) => p.clone(),
None => {
if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) {
- editor.data.status_msg = Some("Map file path unknown".into());
+ editor
+ .data
+ .notify(toast::Status::Danger, "Error", "Map file path unknown");
}
return Task::none();
}
@@ -62,7 +60,9 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
let Some(map_data) = map_data_opt else {
if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) {
- editor.data.status_msg = Some("Map not loaded".into());
+ editor
+ .data
+ .notify(toast::Status::Danger, "Error", "Map not loaded");
}
return Task::none();
};
@@ -105,10 +105,10 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
if let Some(editor) = app.state.editors.map_editors.get_mut(&tab_id) {
match result {
Ok(path) => {
- editor.data.status_msg = Some(format!("TMX exported to: {}", path));
+ editor.data.notify(toast::Status::Success, "Export", path);
}
Err(e) => {
- editor.data.status_msg = Some(format!("TMX export error: {}", e));
+ editor.data.notify(toast::Status::Danger, "Error", e);
}
}
}
@@ -189,6 +189,16 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
state.view.show_collisions = !state.view.show_collisions
}
MapLayer::Events => state.view.show_events = !state.view.show_events,
+ MapLayer::UnderRoofMarkers => {
+ state.view.show_under_roof_markers = !state.view.show_under_roof_markers
+ }
+ MapLayer::NoSpellCastZones => {
+ state.view.show_no_spell_cast_zones = !state.view.show_no_spell_cast_zones
+ }
+ MapLayer::BtlOverlaysInDarkMaps => {
+ state.view.show_btl_overlays_in_dark_maps =
+ !state.view.show_btl_overlays_in_dark_maps
+ }
MapLayer::Monsters => state.view.show_monsters = !state.view.show_monsters,
MapLayer::Npcs => state.view.show_npcs = !state.view.show_npcs,
MapLayer::NpcWaypoints => {
@@ -196,6 +206,16 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
}
MapLayer::Objects => state.view.show_objects = !state.view.show_objects,
MapLayer::DrawItems => state.view.show_draw_items = !state.view.show_draw_items,
+ MapLayer::ObjectIds => state.view.show_object_ids = !state.view.show_object_ids,
+ MapLayer::Shadows => state.view.show_shadows = !state.view.show_shadows,
+ }
+ // Hiding the layer that owns the active editing tool makes that
+ // tool meaningless — reset to Pan.
+ if let Some(owner) = state.view.active_tool.owning_layer()
+ && owner == layer
+ && !map::layer_visible(&state.view, layer)
+ {
+ state.view.active_tool = MapTool::Pan;
}
// Tile canvas renders entities and tile layers; overlay renders
// collisions and events — clear both caches.
@@ -232,62 +252,74 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
MapEditorMessage::CanvasClicked(tab_id, cx, cy) => {
if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
- // Use find_hovered_element which also detects collision and event
- // tiles.
- let clicked = find_hovered_element(state, cx, cy);
- state.data.status_msg = Some(match clicked {
- Some(SelectedEntity::CollisionTile(tx, ty)) => {
- format!("Collision tile ({},{}) detected!", tx, ty)
- }
- Some(SelectedEntity::EventTile(tx, ty)) => {
- format!("Event tile ({},{}) detected!", tx, ty)
- }
- Some(SelectedEntity::Monster(i)) => format!("Monster {} detected", i),
- Some(SelectedEntity::Npc(i)) => format!("NPC {} detected", i),
- Some(SelectedEntity::Extra(i)) => format!("Extra {} detected", i),
- Some(SelectedEntity::DrawItem(i)) => {
- format!("Draw item {} detected", i)
+ // Tool routing comes FIRST: the active tool decides what a
+ // click means. Layer visibility no longer gates editing.
+ match state.view.active_tool {
+ MapTool::Pan => {
+ // Selection only — never mutates.
+ let clicked = find_hovered_element(state, cx, cy);
+ if let Some(sel) = &clicked {
+ let (title, body) = match sel {
+ SelectedEntity::CollisionTile(tx, ty) => {
+ ("Collision", format!("Tile ({},{}) detected", tx, ty))
+ }
+ SelectedEntity::EventTile(tx, ty) => {
+ ("Event", format!("Tile ({},{}) detected", tx, ty))
+ }
+ SelectedEntity::ObjectIdTile(tx, ty) => {
+ ("Object ID", format!("Tile ({},{}) detected", tx, ty))
+ }
+ SelectedEntity::Monster(i) => {
+ ("Monster", format!("#{} detected", i))
+ }
+ SelectedEntity::Npc(i) => ("NPC", format!("#{} detected", i)),
+ SelectedEntity::Extra(i) => ("Extra", format!("#{} detected", i)),
+ SelectedEntity::DrawItem(i) => {
+ ("Draw Item", format!("#{} detected", i))
+ }
+ };
+ // Replace-in-place keeps the stack clean while
+ // clicking across entities.
+ state.data.notify_replace(
+ title,
+ gui_widgets::components::toast::Status::Primary,
+ title,
+ body,
+ );
+ }
+ state.view.selected_entity = clicked;
+ state.view.overlay_cache.clear();
}
- None => {
- format!("Clicked at ({:.0},{:.0}) — no tile detected", cx, cy)
+ MapTool::Collision => {
+ if let Some((tx, ty)) = find_tile_at(state, cx, cy)
+ && map::toggle_collision_at(state, tx, ty)
+ {
+ set_tab_modified(app, tab_id, true);
+ }
}
- });
- match clicked {
- Some(SelectedEntity::CollisionTile(tx, ty)) => {
- if !state.data.can_mutate_map_data() {
- state.data.status_msg = Some(
- "Cannot edit collision while save/export is in progress".into(),
- );
- } else if let LoadingState::Loaded(ref mut handle) =
- state.data.loading_state
+ MapTool::ObjectId => {
+ if let Some((tx, ty)) = find_tile_at(state, cx, cy)
+ && map::apply_object_id_edit(state, tx, ty)
{
- let map_data = Arc::get_mut(&mut handle.0)
- .expect("MapData Arc has unexpected shared reference");
- let old = map_data.collisions.get(&(tx, ty)).copied().unwrap_or(false);
- map_data.collisions.insert((tx, ty), !old);
- state.push_undo(MapEditAction {
- entity: SelectedEntity::CollisionTile(tx, ty),
- field: "collision".into(),
- old_value: old.to_string(),
- new_value: (!old).to_string(),
- });
- state.view.selected_entity = None;
- state.view.overlay_cache.clear();
set_tab_modified(app, tab_id, true);
}
}
- _ => {
- state.view.selected_entity = clicked;
- state.view.overlay_cache.clear();
+ MapTool::EventInspect => {
+ if let Some((tx, ty)) = find_tile_at(state, cx, cy) {
+ state.view.selected_entity = Some(SelectedEntity::EventTile(tx, ty));
+ state.view.overlay_cache.clear();
+ }
}
}
}
Task::none()
}
- MapEditorMessage::ClearStatus(tab_id) => {
- if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
- state.data.status_msg = None;
+ MapEditorMessage::DismissToast(tab_id, index) => {
+ if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id)
+ && index < state.data.toasts.len()
+ {
+ state.data.toasts.remove(index);
}
Task::none()
}
@@ -343,6 +375,36 @@ pub fn handle(message: MapEditorMessage, app: &mut App) -> Task {
}
Task::none()
}
+
+ MapEditorMessage::SelectTool(tab_id, tool) => map::select_tool(app, tab_id, tool),
+
+ MapEditorMessage::ToggleLayersPopover(tab_id) => {
+ if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
+ state.view.layers_popover_open = !state.view.layers_popover_open;
+ }
+ Task::none()
+ }
+
+ MapEditorMessage::CloseLayersPopover(tab_id) => {
+ if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
+ state.view.layers_popover_open = false;
+ }
+ Task::none()
+ }
+
+ MapEditorMessage::SetObjectBrushMode(tab_id, mode) => {
+ if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
+ state.view.object_brush_mode = mode;
+ }
+ Task::none()
+ }
+
+ MapEditorMessage::SetObjectBrush(tab_id, value) => {
+ if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
+ state.data.object_brush = value.clamp(1, 511);
+ }
+ Task::none()
+ }
}
}
@@ -352,13 +414,3 @@ fn set_tab_modified(app: &mut App, tab_id: usize, modified: bool) {
tab.modified = modified;
}
}
-
-/// Emit a delayed `ClearStatus` message to auto-dismiss the toolbar status text.
-fn dismiss_status_after(tab_id: usize) -> Task {
- Task::perform(
- async move {
- tokio::time::sleep(std::time::Duration::from_secs(STATUS_DISMISS_SECS)).await;
- },
- move |()| Message::map_editor(MapEditorMessage::ClearStatus(tab_id)),
- )
-}
diff --git a/dispel-gui/src/editors/map_editor/update/persistence.rs b/dispel-gui/src/editors/map_editor/update/persistence.rs
index f1cb9f08..f01b013c 100644
--- a/dispel-gui/src/editors/map_editor/update/persistence.rs
+++ b/dispel-gui/src/editors/map_editor/update/persistence.rs
@@ -249,19 +249,23 @@ pub fn save_complete(
match result {
Ok(msg) => {
state.data.dirty = false;
- state.data.status_msg = Some(msg);
+ state.data.notify(
+ gui_widgets::components::toast::Status::Success,
+ "Saved",
+ msg,
+ );
}
Err(e) => {
- state.data.status_msg = Some(format!("Save failed: {e}"));
+ state
+ .data
+ .notify(gui_widgets::components::toast::Status::Danger, "Error", e);
}
}
}
if success {
super::set_tab_modified(app, tab_id, false);
- super::dismiss_status_after(tab_id)
- } else {
- Task::none()
}
+ Task::none()
}
pub fn map_saved(app: &mut App, tab_id: usize, result: Result) -> Task {
@@ -271,19 +275,23 @@ pub fn map_saved(app: &mut App, tab_id: usize, result: Result) -
match result {
Ok(msg) => {
state.data.dirty = false;
- state.data.status_msg = Some(msg);
+ state.data.notify(
+ gui_widgets::components::toast::Status::Success,
+ "Saved",
+ msg,
+ );
}
Err(e) => {
- state.data.status_msg = Some(format!("Save failed: {e}"));
+ state
+ .data
+ .notify(gui_widgets::components::toast::Status::Danger, "Error", e);
}
}
}
if success {
super::set_tab_modified(app, tab_id, false);
- super::dismiss_status_after(tab_id)
- } else {
- Task::none()
}
+ Task::none()
}
pub fn export_image(app: &mut App, tab_id: usize) -> Task {
@@ -352,12 +360,12 @@ pub fn export_image(app: &mut App, tab_id: usize) -> Task {
reader: &mut reader,
output_path: &output_path,
data: &map_data,
- occlusion: false,
gtl_tileset: >l_tiles,
btl_tileset: &btl_tiles,
map_id: &map_id,
game_path: game_path.as_deref(),
toggles: Default::default(),
+ lights: &[],
})
.map_err(|e| format!("Render failed: {e}"))?;
@@ -378,19 +386,24 @@ pub fn export_complete(
tab_id: usize,
result: Result,
) -> Task {
- let success = result.is_ok();
if let Some(state) = app.state.editors.map_editors.get_mut(&tab_id) {
state.data.is_exporting = false;
- state.data.status_msg = Some(match result {
- Ok(msg) => msg,
- Err(e) => format!("Export failed: {e}"),
- });
- }
- if success {
- super::dismiss_status_after(tab_id)
- } else {
- Task::none()
+ match result {
+ Ok(msg) => {
+ state.data.notify(
+ gui_widgets::components::toast::Status::Success,
+ "Export",
+ msg,
+ );
+ }
+ Err(e) => {
+ state
+ .data
+ .notify(gui_widgets::components::toast::Status::Danger, "Error", e);
+ }
+ }
}
+ Task::none()
}
#[cfg(test)]
diff --git a/dispel-gui/src/editors/map_editor/view/inspector.rs b/dispel-gui/src/editors/map_editor/view/inspector.rs
index e5335bda..fc6eb913 100644
--- a/dispel-gui/src/editors/map_editor/view/inspector.rs
+++ b/dispel-gui/src/editors/map_editor/view/inspector.rs
@@ -93,7 +93,7 @@ pub fn build_inspector<'a>(
if let Some(event) = map_data.events.get(&(tx, ty)) {
let x_str = event.x.to_string();
let y_str = event.y.to_string();
- let event_id_val = event.event_id.to_string();
+ let event_id_val = event.event_id().to_string();
container(
column![
@@ -176,6 +176,33 @@ pub fn build_inspector<'a>(
};
("Event Inspector", 240.0, body)
}
+ SelectedEntity::ObjectIdTile(tx, ty) => {
+ let body = if let Some(map_handle) = state.map_data() {
+ let map_data = &map_handle.0;
+ let current_id = map_data.object_ids.get(&(tx, ty)).copied().unwrap_or(0);
+ column![
+ text(format!("Object ID at ({}, {})", tx, ty)).size(12),
+ horizontal_rule(1),
+ row![
+ text("object_id")
+ .size(11)
+ .width(140.0)
+ .style(style::subtle_text),
+ text(current_id.to_string()).size(11),
+ ]
+ .spacing(6)
+ .align_y(iced::Alignment::Center),
+ text("Use the brush toolbar to paint object IDs.")
+ .size(11)
+ .style(style::subtle_text),
+ ]
+ .spacing(8)
+ .into()
+ } else {
+ text("Map data not loaded").size(11).into()
+ };
+ ("Object ID Inspector", 240.0, body)
+ }
};
let header = row![
diff --git a/dispel-gui/src/editors/map_editor/view/mod.rs b/dispel-gui/src/editors/map_editor/view/mod.rs
index 254a001b..213ee520 100644
--- a/dispel-gui/src/editors/map_editor/view/mod.rs
+++ b/dispel-gui/src/editors/map_editor/view/mod.rs
@@ -1,12 +1,15 @@
use super::canvas::{MapCanvasOverlaysLayer, MapCanvasTilesLayer};
-use super::message::{MapEditorMessage, MapLayer, MapViewMode};
+use super::message::{MapEditorMessage, MapLayer, MapTool, MapViewMode, ObjectBrushMode};
use crate::app::App;
use crate::components::loading_state::LoadingState;
use crate::message::{Message, MessageExt};
use crate::style;
use gui_widgets::components::modal::modal;
+use gui_widgets::components::toast;
use gui_widgets::lucide::{LUCIDE_FONT, icon_char};
-use iced::widget::{button, canvas, column, container, progress_bar, row, stack, text, toggler};
+use iced::widget::{
+ button, canvas, column, container, progress_bar, row, stack, text, text_input, toggler,
+};
use iced::{Element, Fill};
use lucide_icons::Icon;
@@ -73,8 +76,6 @@ pub fn view(app: &App) -> Element<'_, Message> {
LoadingState::Loaded(map_handle) => {
let map_data = &map_handle.0;
let model = &map_data.model;
- let gtl_count = map_data.gtl_tiles.len();
- let btl_count = map_data.btl_tiles.len();
let path_label = state
.data
@@ -84,54 +85,11 @@ pub fn view(app: &App) -> Element<'_, Message> {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Unknown".to_string());
- // ── Info row ─────────────────────────────────────────────────
- let info_row = row![
- info_cell("File", &path_label),
- info_cell(
- "Tiles (W×H)",
- &format!("{}×{}", model.tiled_map_width, model.tiled_map_height)
- ),
- info_cell("GTL", >l_count.to_string()),
- info_cell("BTL", &btl_count.to_string()),
- info_cell(
- "Handles",
- &format!(
- "{}/{}",
- state.data.gtl_handles.len(),
- state.data.btl_handles.len()
- )
- ),
- info_cell(
- "Entities",
- &format!(
- "{}M {}N {}O {}D",
- state.data.monsters.len(),
- state.data.npcs.len(),
- state.data.extra_refs.len(),
- state.data.draw_items.len()
- )
- ),
- info_cell(
- "Tiles",
- &format!(
- "{} GTL + {} BTL loaded",
- state.data.gtl_handles.len(),
- state.data.btl_handles.len()
- )
- ),
- ]
- .spacing(16)
- .padding([8, 16]);
-
- // ── Layer toggles ─────────────────────────────────────────────
- let tile_status = if state.data.tiles_ready {
- text("").size(10).style(style::subtle_text)
- } else {
- text("Decoding tiles…").size(10).style(style::subtle_text)
- };
+ // ── Layer toggles, grouped into three segments ────────────────
- let layer_row = row![
- text("Layers:").size(11).style(style::subtle_text),
+ // Vertical toggle groups for the popover panel. Group headers live
+ // in the panel itself (exactly one per group).
+ let terrain_group = column![
layer_toggle(
"Ground",
state.view.show_ground,
@@ -153,6 +111,13 @@ pub fn view(app: &App) -> Element<'_, Message> {
MapLayer::Roofs,
None
),
+ layer_toggle(
+ "Shadows",
+ state.view.show_shadows,
+ tab_id,
+ MapLayer::Shadows,
+ None
+ ),
layer_toggle(
"Sprites",
state.view.show_internal_sprites,
@@ -160,6 +125,10 @@ pub fn view(app: &App) -> Element<'_, Message> {
MapLayer::InternalSprites,
None
),
+ ]
+ .spacing(6);
+
+ let overlay_group = column![
layer_toggle(
"Collisions",
state.view.show_collisions,
@@ -174,6 +143,38 @@ pub fn view(app: &App) -> Element<'_, Message> {
MapLayer::Events,
None
),
+ layer_toggle(
+ "Under Roof / Hide BTL",
+ state.view.show_under_roof_markers,
+ tab_id,
+ MapLayer::UnderRoofMarkers,
+ None
+ ),
+ layer_toggle(
+ "No Spell Casting Range",
+ state.view.show_no_spell_cast_zones,
+ tab_id,
+ MapLayer::NoSpellCastZones,
+ None
+ ),
+ layer_toggle(
+ "BTL Overlays in Dark Maps",
+ state.view.show_btl_overlays_in_dark_maps,
+ tab_id,
+ MapLayer::BtlOverlaysInDarkMaps,
+ None
+ ),
+ layer_toggle(
+ "Object IDs",
+ state.view.show_object_ids,
+ tab_id,
+ MapLayer::ObjectIds,
+ None
+ ),
+ ]
+ .spacing(6);
+
+ let entity_group = column![
layer_toggle(
"Monsters",
state.view.show_monsters,
@@ -210,11 +211,76 @@ pub fn view(app: &App) -> Element<'_, Message> {
Some(state.data.draw_items.len())
),
]
- .spacing(12)
- .padding([6, 16])
- .align_y(iced::Alignment::Center);
+ .spacing(6);
- // ── Action buttons row ─────────────────────────────────────────
+ // ── Layers dropdown (popover) ─────────────────────────────────
+ let layer_visibility = [
+ state.view.show_ground,
+ state.view.show_buildings,
+ state.view.show_roofs,
+ state.view.show_internal_sprites,
+ state.view.show_collisions,
+ state.view.show_events,
+ state.view.show_under_roof_markers,
+ state.view.show_no_spell_cast_zones,
+ state.view.show_btl_overlays_in_dark_maps,
+ state.view.show_monsters,
+ state.view.show_npcs,
+ state.view.show_npc_waypoints,
+ state.view.show_objects,
+ state.view.show_draw_items,
+ state.view.show_object_ids,
+ ];
+ let visible_layers = layer_visibility.iter().filter(|&&v| v).count();
+ let total_layers = layer_visibility.len();
+
+ let layers_trigger = button(
+ row![
+ text("LAYERS").size(11),
+ text("▾").size(10).style(style::subtle_text),
+ text(format!("· {}/{}", visible_layers, total_layers))
+ .size(10)
+ .style(style::subtle_text),
+ ]
+ .spacing(4)
+ .align_y(iced::Alignment::Center),
+ )
+ .padding([3, 10])
+ .on_press(Message::map_editor(MapEditorMessage::ToggleLayersPopover(
+ tab_id,
+ )))
+ .style(if state.view.layers_popover_open {
+ style::active_chip
+ } else {
+ style::chip
+ });
+
+ let layers_panel = container(
+ column![
+ segment_label("Terrain"),
+ terrain_group,
+ h_rule(),
+ segment_label("Overlays"),
+ overlay_group,
+ h_rule(),
+ segment_label("Entities"),
+ entity_group,
+ ]
+ .spacing(12),
+ )
+ .padding(14)
+ .width(iced::Length::Fixed(200.0))
+ .style(style::panel_container);
+
+ let layers_popover: Element<'_, Message> = gui_widgets::components::popover::popover(
+ layers_trigger,
+ layers_panel,
+ state.view.layers_popover_open,
+ move || Message::map_editor(MapEditorMessage::ToggleLayersPopover(tab_id)),
+ move || Message::map_editor(MapEditorMessage::CloseLayersPopover(tab_id)),
+ );
+
+ // ── Action buttons (Row B) ────────────────────────────────────
let can_undo = !state.data.undo_stack.is_empty();
let can_redo = !state.data.redo_stack.is_empty();
let save_label = if state.data.is_saving {
@@ -230,12 +296,13 @@ pub fn view(app: &App) -> Element<'_, Message> {
save_btn.on_press(Message::map_editor(MapEditorMessage::SaveMap(tab_id)));
}
+ // Icon + label undo/redo — icons alone are anti-UX.
let mut undo_btn = button(
row![
text(icon_char(Icon::Undo2)).font(LUCIDE_FONT).size(11),
- text(" Undo").size(11),
+ text("Undo").size(11),
]
- .spacing(2),
+ .spacing(4),
)
.padding([3, 8]);
if can_undo {
@@ -244,10 +311,10 @@ pub fn view(app: &App) -> Element<'_, Message> {
let mut redo_btn = button(
row![
- text("Redo ").size(11),
text(icon_char(Icon::Redo2)).font(LUCIDE_FONT).size(11),
+ text("Redo").size(11),
]
- .spacing(2),
+ .spacing(4),
)
.padding([3, 8]);
if can_redo {
@@ -259,76 +326,182 @@ pub fn view(app: &App) -> Element<'_, Message> {
} else {
"Export PNG"
};
- let mut export_btn = button(text(export_label).size(11)).padding([3, 8]);
+ let mut export_btn = button(text(export_label).size(11))
+ .padding([3, 8])
+ .accessible_label("Export map as PNG");
if !state.data.is_exporting {
export_btn =
export_btn.on_press(Message::map_editor(MapEditorMessage::ExportImage(tab_id)));
}
- let mut tmx_btn = button(text("Export TMX…").size(11)).padding([3, 8]);
+ let mut tmx_btn = button(text("Export TMX").size(11))
+ .padding([3, 8])
+ .accessible_label("Export map as TMX");
if !state.data.is_exporting {
tmx_btn =
tmx_btn.on_press(Message::map_editor(MapEditorMessage::ExportTmx(tab_id)));
}
- let status_text = if let Some(msg) = &state.data.status_msg {
- text(msg.as_str()).size(10).style(style::subtle_text)
- } else {
- text("").size(10).style(style::subtle_text)
+ // Folded summary replacing the duplicate Handles/Tiles info cells.
+ let summary_text = format!(
+ "{}×{} · {} NPC · {} gtl",
+ model.tiled_map_width,
+ model.tiled_map_height,
+ state.data.npcs.len(),
+ state.data.gtl_handles.len()
+ );
+
+ // Per-tool click hint. Hidden for Pan (default tool needs no help
+ // text); always visible for editing tools.
+ let hint: Option = match state.view.active_tool {
+ MapTool::Pan => None,
+ MapTool::Collision => Some("Click tile: block/unblock".into()),
+ MapTool::ObjectId => Some(match state.view.object_brush_mode {
+ ObjectBrushMode::Paint => {
+ format!("Click: paint obj {}", state.data.object_brush)
+ }
+ ObjectBrushMode::Erase => "Click: erase any value".into(),
+ }),
+ MapTool::EventInspect => Some("Click tile: inspect event".into()),
};
- let action_row = row![
+ let actions_row = row![
save_btn,
undo_btn,
redo_btn,
export_btn,
tmx_btn,
- status_text,
+ text(path_label).size(10).style(style::subtle_text),
+ horizontal_space(),
+ match &hint {
+ Some(h) => Element::new(text(h.clone()).size(10).style(style::subtle_text)),
+ None => Element::new(horizontal_space()),
+ },
]
.spacing(6)
.padding([4, 16])
.align_y(iced::Alignment::Center);
- let mode_tab_row = row![
- button(text("Map").size(11))
+ // ── Row A: context row ────────────────────────────────────────
+ let mode_chip = |label: &'static str, mode: MapViewMode| {
+ button(text(label).size(11))
.on_press(Message::map_editor(MapEditorMessage::SwitchViewMode(
- tab_id,
- MapViewMode::Map
+ tab_id, mode,
)))
- .padding([3, 10])
- .style(if state.view.view_mode == MapViewMode::Map {
+ .padding([3, 8])
+ .style(if state.view.view_mode == mode {
style::active_chip
} else {
style::chip
- }),
- button(text("Sprites").size(11))
- .on_press(Message::map_editor(MapEditorMessage::SwitchViewMode(
- tab_id,
- MapViewMode::Sprites
+ })
+ };
+
+ let tool_chip = |label: &'static str, tool: MapTool| {
+ button(text(label).size(11))
+ .on_press(Message::map_editor(MapEditorMessage::SelectTool(
+ tab_id, tool,
)))
- .padding([3, 10])
- .style(if state.view.view_mode == MapViewMode::Sprites {
+ .padding([3, 8])
+ .style(if state.view.active_tool == tool {
style::active_chip
} else {
style::chip
- }),
- ]
- .spacing(4)
- .padding([6, 16]);
+ })
+ };
- let toolbar = container(
- column![
- row![mode_tab_row, action_row].spacing(0),
- row![info_row].spacing(0),
- row![layer_row, tile_status]
- .spacing(16)
- .align_y(iced::Alignment::Center),
- ]
- .spacing(0),
- )
- .width(Fill)
- .style(style::toolbar_container)
- .accessible_label("Map editor toolbar");
+ // Contextual object-id brush options — inline in Row A, only when
+ // the Obj ID tool is active.
+ let brush_slot: Option> =
+ if state.view.active_tool == MapTool::ObjectId {
+ let mode_chip = |label: &'static str, mode: ObjectBrushMode| {
+ button(text(label).size(11))
+ .on_press(Message::map_editor(MapEditorMessage::SetObjectBrushMode(
+ tab_id, mode,
+ )))
+ .padding([3, 6])
+ .style(if state.view.object_brush_mode == mode {
+ style::active_chip
+ } else {
+ style::chip
+ })
+ };
+ let brush = state.data.object_brush;
+ let preset_chip = |n: i32| {
+ button(text(n.to_string()).size(11))
+ .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush(
+ tab_id, n,
+ )))
+ .padding([3, 5])
+ .style(style::chip)
+ };
+ let brush_str = brush.to_string();
+ Some(
+ row![
+ rule(),
+ mode_chip("Paint", ObjectBrushMode::Paint),
+ mode_chip("Erase", ObjectBrushMode::Erase),
+ rule(),
+ button(text("−").size(12))
+ .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush(
+ tab_id,
+ brush - 1
+ )))
+ .padding([3, 6])
+ .style(style::chip),
+ container(
+ text_input("1", brush_str)
+ .width(iced::Length::Fixed(44.0))
+ .on_input(move |v: String| {
+ let val = v.parse::().unwrap_or(1).clamp(1, 511);
+ Message::map_editor(MapEditorMessage::SetObjectBrush(
+ tab_id, val,
+ ))
+ }),
+ )
+ .padding([2, 4])
+ .style(style::info_card),
+ button(text("+").size(12))
+ .on_press(Message::map_editor(MapEditorMessage::SetObjectBrush(
+ tab_id,
+ brush + 1
+ )))
+ .padding([3, 6])
+ .style(style::chip),
+ preset_chip(1),
+ preset_chip(2),
+ preset_chip(3),
+ preset_chip(5),
+ preset_chip(10),
+ ]
+ .spacing(3)
+ .align_y(iced::Alignment::Center)
+ .into(),
+ )
+ } else {
+ None
+ };
+
+ let mut context_row = row![].spacing(6).padding([4, 16]);
+ context_row = context_row
+ .push(mode_chip("Map", MapViewMode::Map))
+ .push(mode_chip("Sprites", MapViewMode::Sprites))
+ .push(rule())
+ .push(tool_chip("Pan", MapTool::Pan))
+ .push(tool_chip("Collide", MapTool::Collision))
+ .push(tool_chip("Obj ID", MapTool::ObjectId))
+ .push(tool_chip("Event", MapTool::EventInspect));
+ if let Some(slot) = brush_slot {
+ context_row = context_row.push(slot);
+ }
+ context_row = context_row
+ .push(horizontal_space())
+ .push(text(summary_text).size(10).style(style::subtle_text))
+ .push(layers_popover);
+
+ let toolbar = container(column![context_row, actions_row].spacing(0))
+ .width(Fill)
+ .style(style::toolbar_container)
+ .accessible_label("Map editor toolbar");
// ── Canvas for tile layers, sprites (images) ───────────────────────
let tiles_canvas = canvas(MapCanvasTilesLayer { state })
@@ -407,7 +580,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
.into();
// Wrap in dialog preview modal if open
- if let Some(ref preview) = state.view.dialog_preview {
+ let root: Element<'_, Message> = if let Some(ref preview) = state.view.dialog_preview {
modal(
base,
dialog_preview::view_dialog_preview(state, tab_id, preview),
@@ -416,20 +589,60 @@ pub fn view(app: &App) -> Element<'_, Message> {
)
} else {
base
- }
+ };
+
+ // Toast notifications overlay (top-right, auto-dismiss).
+ toast::Manager::new(root, &state.data.toasts, move |i| {
+ Message::map_editor(MapEditorMessage::DismissToast(tab_id, i))
+ })
+ .timeout(3)
+ .into()
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
-fn info_cell<'a>(label: &'static str, value: &str) -> Element<'a, Message> {
- column![
- text(label).size(10).style(style::subtle_text),
- text(value.to_string()).size(11),
- ]
- .spacing(2)
- .into()
+use iced::widget::space::Space;
+
+/// Fill remaining horizontal space (pushes following widgets right).
+fn horizontal_space() -> Space {
+ Space::new().width(Fill)
+}
+
+/// Thin vertical separator between layer-toggle segments.
+fn rule() -> Element<'static, Message> {
+ container(text(""))
+ .width(iced::Length::Fixed(1.0))
+ .height(iced::Length::Fixed(16.0))
+ .style(|_| container::Style {
+ background: Some(iced::Background::Color(iced::Color::from_rgba(
+ 0.6, 0.55, 0.45, 0.4,
+ ))),
+ ..container::Style::default()
+ })
+ .into()
+}
+
+fn segment_label(label: &'static str) -> Element<'static, Message> {
+ text(label.to_string())
+ .size(10)
+ .style(style::subtle_text)
+ .into()
+}
+
+/// Thin horizontal separator between layer groups inside the popover panel.
+fn h_rule() -> Element<'static, Message> {
+ container(text(""))
+ .width(Fill)
+ .height(iced::Length::Fixed(1.0))
+ .style(|_| container::Style {
+ background: Some(iced::Background::Color(iced::Color::from_rgba(
+ 0.6, 0.55, 0.45, 0.4,
+ ))),
+ ..container::Style::default()
+ })
+ .into()
}
fn layer_toggle(
diff --git a/dispel-gui/src/editors/map_info_multi/component.rs b/dispel-gui/src/editors/map_info_multi/component.rs
new file mode 100644
index 00000000..ff7a76a2
--- /dev/null
+++ b/dispel-gui/src/editors/map_info_multi/component.rs
@@ -0,0 +1,36 @@
+use crate::components::editable::EditableRecord;
+use crate::editable_record_fields;
+use dispel_core::MapInfoMulti;
+
+editable_record_fields!(MapInfoMulti, {
+ { id = Integer / "ID:" },
+ { self_name = String / "Self Name:" },
+ { anchor_tile_index = Integer / "Anchor Tile:" },
+ { stride = Integer / "Stride:" },
+ { row_step = Integer / "Row Step:" },
+ { start_pos_a_x = Integer / "Start A X:" },
+ { start_pos_a_y = Integer / "Start A Y:" },
+ { start_pos_b_x = Integer / "Start B X:" },
+ { start_pos_b_y = Integer / "Start B Y:" },
+ { transition_target = String / "Transition Target:" },
+ { mon_filename = String / "Mon Filename:" },
+});
+
+impl EditableRecord for MapInfoMulti {
+ crate::editable_record_delegate!();
+ fn list_label(&self) -> String {
+ format!(
+ "Map info (name={}, anchor={}, stride={})",
+ self.self_name, self.anchor_tile_index, self.stride
+ )
+ }
+ fn detail_title() -> &'static str {
+ "Map Info Multi Details"
+ }
+ fn empty_selection_text() -> &'static str {
+ "No map info selected"
+ }
+ fn save_button_label() -> &'static str {
+ "Save Map Info"
+ }
+}
diff --git a/dispel-gui/src/editors/map_info_multi/mod.rs b/dispel-gui/src/editors/map_info_multi/mod.rs
new file mode 100644
index 00000000..63f482ee
--- /dev/null
+++ b/dispel-gui/src/editors/map_info_multi/mod.rs
@@ -0,0 +1,9 @@
+mod component;
+
+crate::define_standard_editor! {
+ name: map_info_multi,
+ name_pascal: MapInfoMulti,
+ record: dispel_core::MapInfoMulti,
+ field: map_info_multi_editor,
+ file: "Map/Multi/*.ifm",
+}
diff --git a/dispel-gui/src/editors/mod.rs b/dispel-gui/src/editors/mod.rs
index 6511bfb1..e2b3b61a 100644
--- a/dispel-gui/src/editors/mod.rs
+++ b/dispel-gui/src/editors/mod.rs
@@ -9,6 +9,7 @@
pub mod all_map_ini;
pub mod chdata;
+pub mod chest_spawn;
pub mod db_viewer;
pub mod dialogue_paragraph;
pub mod dialogue_script;
@@ -20,11 +21,13 @@ pub mod event_npc_ref;
pub mod event_scr;
pub mod extra_ini;
pub mod extra_ref;
+pub mod fog_data;
pub mod heal_item;
pub mod hex_wrapper;
pub mod localization_manager;
pub mod magic;
pub mod map_editor;
+pub mod map_info_multi;
pub mod map_ini;
pub mod message_scr;
pub mod misc_item;
@@ -32,6 +35,7 @@ pub mod mod_packager;
pub mod monster;
pub mod monster_ini;
pub mod monster_ref;
+pub mod monster_spawn;
pub mod npc_ini;
pub mod npc_ref;
pub mod party_ini;
@@ -39,9 +43,12 @@ pub mod party_level_db;
pub mod party_ref;
pub mod quest_scr;
pub mod save_file_viewer;
+pub mod save_ifo;
pub mod snf_editor;
pub mod sprite_editor;
pub mod store;
+pub mod tile_grid;
+pub mod tile_grid_canvas;
pub mod tileset;
pub mod wave_ini;
pub mod weapon;
diff --git a/dispel-gui/src/editors/monster_spawn/component.rs b/dispel-gui/src/editors/monster_spawn/component.rs
new file mode 100644
index 00000000..2f9bb1e3
--- /dev/null
+++ b/dispel-gui/src/editors/monster_spawn/component.rs
@@ -0,0 +1,33 @@
+use crate::components::editable::EditableRecord;
+use crate::editable_record_fields;
+use dispel_core::MonsterSpawn;
+
+editable_record_fields!(MonsterSpawn, {
+ { serial = Integer / "Serial:" },
+ { monster_no = Integer / "Monster No:" },
+ { x = Integer / "X:" },
+ { y = Integer / "Y:" },
+ { persistent = i32Enum(dispel_core::BooleanFlag, ["False", "True"]) / "Persistent:" },
+ { burrow = i32Enum(dispel_core::BooleanFlag, ["False", "True"]) / "Burrow:" },
+ { item_group = Integer / "Item Group:" },
+ { drop_attribute = Integer / "Drop Attr:" },
+});
+
+impl EditableRecord for MonsterSpawn {
+ crate::editable_record_delegate!();
+ fn list_label(&self) -> String {
+ format!(
+ "Monster spawn (serial={}, species={}, x={}, y={})",
+ self.serial, self.monster_no, self.x, self.y
+ )
+ }
+ fn detail_title() -> &'static str {
+ "Monster Spawn Details"
+ }
+ fn empty_selection_text() -> &'static str {
+ "No monster spawn selected"
+ }
+ fn save_button_label() -> &'static str {
+ "Save Monster Spawns"
+ }
+}
diff --git a/dispel-gui/src/editors/monster_spawn/mod.rs b/dispel-gui/src/editors/monster_spawn/mod.rs
new file mode 100644
index 00000000..02f3d95e
--- /dev/null
+++ b/dispel-gui/src/editors/monster_spawn/mod.rs
@@ -0,0 +1,9 @@
+mod component;
+
+crate::define_standard_editor! {
+ name: monster_spawn,
+ name_pascal: MonsterSpawn,
+ record: dispel_core::MonsterSpawn,
+ field: monster_spawn_editor,
+ file: "Map/Multi/*.mon",
+}
diff --git a/dispel-gui/src/editors/npc_ref/component.rs b/dispel-gui/src/editors/npc_ref/component.rs
index 9d017299..523a8460 100644
--- a/dispel-gui/src/editors/npc_ref/component.rs
+++ b/dispel-gui/src/editors/npc_ref/component.rs
@@ -1,7 +1,7 @@
use crate::components::editable::EditableRecord;
use dispel_core::NPC;
use dispel_core::references::enums::{
- BooleanFlag, NpcInteractionMode, NpcLookingDirection, NpcMovementMode,
+ BooleanFlag, NpcInteractionMode, NpcLookingDirection, NpcMovementMode, NpcRoleResult,
};
use crate::editable_record_fields;
@@ -11,7 +11,7 @@ editable_record_fields!(NPC, {
{ npc_ini_id = Integer / "NPC ID:" },
{ name = String / "Name:" },
{ role_description = String / "Role Description:" },
- { party_member_slot = Integer / "Party Member Slot:" },
+ { role_result = DispEnum(NpcRoleResult, ["NormalDialogue", "PartyMemberDialogue1", "PartyMemberDialogue2", "PartyMemberDialogue3", "PartyMemberDialogue4", "PartyMemberDialogue5", "PartyMemberDialogue6", "PartyMemberDialogue7", "PartyMemberDialogue8", "WeaponShop", "HealMiscShop", "EditItemShop", "Inn"]) / "Role Result:" },
{ show_on_event = Integer / "Show on Event:" },
{ movement_mode = DispEnum(NpcMovementMode, ["Static", "Waypoints", "RandomInActivationRect"]) / "Movement Mode:" },
{ goto1_filled = Enum(BooleanFlag, ["False", "True"]) / "Waypoint 1 Filled:" },
diff --git a/dispel-gui/src/editors/save_file_viewer/update/mod.rs b/dispel-gui/src/editors/save_file_viewer/update/mod.rs
index ca6212cd..638249fb 100644
--- a/dispel-gui/src/editors/save_file_viewer/update/mod.rs
+++ b/dispel-gui/src/editors/save_file_viewer/update/mod.rs
@@ -1292,7 +1292,7 @@ pub fn handle(msg: SaveFileViewerMessage, app: &mut App) -> Task {
item.name.clone(),
item.description.clone(),
item.base_price.to_string(),
- hex_bytes(&item.reserved_definition_bytes),
+ hex_bytes(&item.reserved_mark_tail_bytes),
item.misc_item_id.to_string(),
item.item_category.to_string(),
item.inventory_record_index.to_string(),
diff --git a/dispel-gui/src/editors/save_ifo/message.rs b/dispel-gui/src/editors/save_ifo/message.rs
new file mode 100644
index 00000000..88e1c9f9
--- /dev/null
+++ b/dispel-gui/src/editors/save_ifo/message.rs
@@ -0,0 +1,25 @@
+//! Messages for the Save.ifo editor.
+
+/// The global tail fields are recorded in the mod changelog under this
+/// record id (`Save.ifo` is a single-record file; the hand-written
+/// `SaveIfoPatcher` in dispel-core rejects any other id). Slot records
+/// themselves are display-only here.
+pub const TAIL_RECORD_ID: u32 = 0;
+
+#[derive(Debug, Clone)]
+pub enum SaveIfoEditorMessage {
+ /// Load `Save.ifo` plus per-slot summaries from the configured game path.
+ LoadCatalog,
+ CatalogLoaded(Result<(Vec, dispel_core::SaveIfo), String>),
+ /// A tail field changed: (field path, new string value).
+ FieldChanged(String, String),
+ /// User asked to swap two slots — opens the confirmation modal.
+ SwapRequested(usize, usize),
+ /// User confirmed the pending swap (persisted immediately, non-undoable).
+ SwapConfirm,
+ /// User dismissed the swap confirmation.
+ SwapCancel,
+ SwapDone(Result<(Vec, dispel_core::SaveIfo), String>),
+ Save,
+ Saved(Result<(), String>),
+}
diff --git a/dispel-gui/src/editors/save_ifo/mod.rs b/dispel-gui/src/editors/save_ifo/mod.rs
new file mode 100644
index 00000000..9e6b6ac1
--- /dev/null
+++ b/dispel-gui/src/editors/save_ifo/mod.rs
@@ -0,0 +1,14 @@
+//! Save.ifo editor: save-slot overview with swapping plus global tail fields.
+//!
+//! Slot rows are display-only; the editable surface is the global tail block.
+//! Swaps are confirmed via a modal and persisted immediately (non-undoable).
+
+mod message;
+mod state;
+mod update;
+mod view;
+
+pub use message::*;
+pub use state::*;
+pub use update::*;
+pub use view::view;
diff --git a/dispel-gui/src/editors/save_ifo/state.rs b/dispel-gui/src/editors/save_ifo/state.rs
new file mode 100644
index 00000000..6615467e
--- /dev/null
+++ b/dispel-gui/src/editors/save_ifo/state.rs
@@ -0,0 +1,350 @@
+//! State for the Save.ifo editor.
+//!
+//! Slot rows are display-only (timestamps are written by the game itself on
+//! save); the editable surface is the global tail block plus slot swapping.
+//! Tail edits go through [`EditHistory`] so Ctrl+Z/Ctrl+Y work; swaps are
+//! confirmed, immediately persisted, and intentionally NOT undoable.
+
+use crate::components::edit_history::{EditAction, EditHistory};
+use crate::components::generic_editor::UndoRedo;
+use crate::components::loading_state::LoadingState;
+use dispel_core::{SaveIfo, SlotSummary};
+
+/// Loaded editor payload.
+#[derive(Debug, Clone)]
+pub struct EditorData {
+ pub summaries: Vec,
+ pub ifo: SaveIfo,
+ pub dirty: bool,
+}
+
+/// String buffers for the tail text inputs (source of truth while typing).
+#[derive(Debug, Clone, Default)]
+pub struct TailBuffers {
+ pub game_version: String,
+ pub game_tmp_key: String,
+ pub map_id: String,
+ pub reserved: String,
+ pub payload_counts: [String; 4],
+}
+
+#[derive(Debug, Clone)]
+pub struct SaveIfoEditorState {
+ pub data: Option,
+ pub loading_state: LoadingState<()>,
+ pub status_msg: String,
+ pub tail_buffers: TailBuffers,
+ pub edit_history: EditHistory,
+ /// Slots awaiting confirmation: `(a, b)`.
+ pub pending_swap: Option<(usize, usize)>,
+}
+
+impl Default for SaveIfoEditorState {
+ fn default() -> Self {
+ Self {
+ data: None,
+ loading_state: LoadingState::Idle,
+ status_msg: String::new(),
+ tail_buffers: TailBuffers::default(),
+ edit_history: EditHistory::default(),
+ pending_swap: None,
+ }
+ }
+}
+
+impl SaveIfoEditorState {
+ /// Install freshly loaded data and reset transient editing state.
+ pub fn load_completed(&mut self, summaries: Vec, ifo: SaveIfo) {
+ self.data = Some(EditorData {
+ summaries,
+ ifo,
+ dirty: false,
+ });
+ self.sync_tail_buffers();
+ self.edit_history = EditHistory::default();
+ self.pending_swap = None;
+ }
+
+ /// Splice in post-swap data: slot records and summaries are replaced from
+ /// the fresh on-disk state, but unsaved tail edits (and their buffers)
+ /// survive — a swap never touches the global tail.
+ pub fn apply_swapped(&mut self, summaries: Vec, fresh: SaveIfo) {
+ let Some(data) = &mut self.data else {
+ self.load_completed(summaries, fresh);
+ return;
+ };
+ data.summaries = summaries;
+ data.ifo.slots = fresh.slots;
+ // Tail fields and buffers intentionally left as-is (dirty stays true).
+ }
+
+ /// Refresh tail buffers from the loaded record.
+ pub fn sync_tail_buffers(&mut self) {
+ let Some(data) = &self.data else { return };
+ let ifo = &data.ifo;
+ self.tail_buffers = TailBuffers {
+ game_version: ifo.game_version.to_string(),
+ game_tmp_key: ifo.game_tmp_key.to_string(),
+ map_id: ifo.map_id.to_string(),
+ reserved: ifo.reserved.to_string(),
+ payload_counts: ifo.payload_counts.map(|v| v.to_string()),
+ };
+ }
+
+ /// Read a tail field as its string representation. `None` for unknown paths.
+ pub fn field_value(ifo: &SaveIfo, path: &str) -> Option {
+ match path {
+ "tail.game_version" => Some(ifo.game_version.to_string()),
+ "tail.game_tmp_key" => Some(ifo.game_tmp_key.to_string()),
+ "tail.map_id" => Some(ifo.map_id.to_string()),
+ "tail.reserved" => Some(ifo.reserved.to_string()),
+ other => {
+ let index = other
+ .strip_prefix("tail.payload_counts.")?
+ .parse::()
+ .ok()?;
+ ifo.payload_counts.get(index).map(|v| v.to_string())
+ }
+ }
+ }
+
+ /// Parse and apply a tail field. Returns `false` when the path is unknown
+ /// or the value does not parse (the model is left untouched then).
+ fn apply_field(ifo: &mut SaveIfo, path: &str, value: &str) -> bool {
+ match path {
+ "tail.game_version" => match value.parse::() {
+ Ok(v) => {
+ ifo.game_version = v;
+ true
+ }
+ Err(_) => false,
+ },
+ "tail.game_tmp_key" => Self::apply_u32(&mut ifo.game_tmp_key, value),
+ "tail.map_id" => Self::apply_u32(&mut ifo.map_id, value),
+ "tail.reserved" => Self::apply_u32(&mut ifo.reserved, value),
+ other => match other.strip_prefix("tail.payload_counts.") {
+ Some(index) => match index.parse::() {
+ Ok(i) if i < ifo.payload_counts.len() => {
+ Self::apply_u32(&mut ifo.payload_counts[i], value)
+ }
+ _ => false,
+ },
+ None => false,
+ },
+ }
+ }
+
+ fn apply_u32(target: &mut u32, value: &str) -> bool {
+ match value.parse::() {
+ Ok(v) => {
+ *target = v;
+ true
+ }
+ Err(_) => false,
+ }
+ }
+
+ /// Commit a tail edit: validates, applies to the model, marks dirty, and
+ /// records history. Invalid input only updates the status message.
+ pub fn update_field(&mut self, path: String, value: String) {
+ let Some(data) = &mut self.data else {
+ return;
+ };
+ let Some(old_value) = Self::field_value(&data.ifo, &path) else {
+ return;
+ };
+ if old_value == value {
+ return;
+ }
+ if !Self::apply_field(&mut data.ifo, &path, &value) {
+ self.status_msg = format!("Invalid value for {path}: '{value}'");
+ return;
+ }
+ data.dirty = true;
+ self.set_tail_buffer(&path, value.clone());
+ self.edit_history.push(EditAction::FieldChange {
+ record_idx: 0,
+ field: path,
+ old_value,
+ new_value: value,
+ });
+ }
+
+ fn set_tail_buffer(&mut self, path: &str, value: String) {
+ match path {
+ "tail.game_version" => self.tail_buffers.game_version = value,
+ "tail.game_tmp_key" => self.tail_buffers.game_tmp_key = value,
+ "tail.map_id" => self.tail_buffers.map_id = value,
+ "tail.reserved" => self.tail_buffers.reserved = value,
+ other => {
+ if let Some(index) = other.strip_prefix("tail.payload_counts.")
+ && let Ok(i) = index.parse::()
+ && i < self.tail_buffers.payload_counts.len()
+ {
+ self.tail_buffers.payload_counts[i] = value;
+ }
+ }
+ }
+ }
+}
+
+impl UndoRedo for SaveIfoEditorState {
+ fn undo(&mut self) -> Option {
+ let action = self.edit_history.undo()?;
+ if let EditAction::FieldChange {
+ ref field,
+ ref old_value,
+ ..
+ } = action
+ {
+ if let Some(data) = &mut self.data {
+ Self::apply_field(&mut data.ifo, field, old_value);
+ }
+ self.set_tail_buffer(field, old_value.clone());
+ }
+ Some(format!("Undo: {}", action.display_text()))
+ }
+
+ fn redo(&mut self) -> Option {
+ let action = self.edit_history.redo()?;
+ // The redo action is the inverted undo action: old_value holds the
+ // value to re-apply (same convention as the store editor).
+ if let EditAction::FieldChange {
+ ref field,
+ ref old_value,
+ ..
+ } = action
+ {
+ if let Some(data) = &mut self.data {
+ Self::apply_field(&mut data.ifo, field, old_value);
+ }
+ self.set_tail_buffer(field, old_value.clone());
+ }
+ Some(format!("Redo: {}", action.display_text()))
+ }
+
+ fn can_undo(&self) -> bool {
+ self.edit_history.can_undo()
+ }
+
+ fn can_redo(&self) -> bool {
+ self.edit_history.can_redo()
+ }
+
+ fn edit_history(&self) -> &EditHistory {
+ &self.edit_history
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn editor_with_data() -> SaveIfoEditorState {
+ let mut editor = SaveIfoEditorState::default();
+ editor.load_completed(
+ Vec::new(),
+ SaveIfo {
+ game_tmp_key: 6,
+ map_id: 12,
+ ..SaveIfo::default()
+ },
+ );
+ editor
+ }
+
+ #[test]
+ fn test_field_change_updates_model_and_marks_dirty() {
+ let mut editor = editor_with_data();
+ editor.update_field("tail.map_id".into(), "13".into());
+ let data = editor.data.as_ref().unwrap();
+ assert_eq!(data.ifo.map_id, 13);
+ assert!(data.dirty);
+ assert!(editor.edit_history.can_undo());
+ }
+
+ #[test]
+ fn test_field_change_invalid_value_is_rejected() {
+ let mut editor = editor_with_data();
+ editor.update_field("tail.map_id".into(), "abc".into());
+ assert_eq!(editor.data.as_ref().unwrap().ifo.map_id, 12);
+ assert!(!editor.data.as_ref().unwrap().dirty);
+ assert!(!editor.edit_history.can_undo());
+ assert!(editor.status_msg.contains("Invalid value"));
+ }
+
+ #[test]
+ fn test_undo_reverts_tail_field_and_buffer() {
+ let mut editor = editor_with_data();
+ editor.update_field("tail.game_tmp_key".into(), "99".into());
+ let msg = editor.undo();
+ assert!(msg.is_some());
+ assert_eq!(editor.data.as_ref().unwrap().ifo.game_tmp_key, 6);
+ assert_eq!(editor.tail_buffers.game_tmp_key, "6");
+ }
+
+ #[test]
+ fn test_redo_reapplies_tail_field() {
+ let mut editor = editor_with_data();
+ editor.update_field("tail.payload_counts.0".into(), "350".into());
+ editor.undo();
+ editor.redo();
+ assert_eq!(editor.data.as_ref().unwrap().ifo.payload_counts[0], 350);
+ assert_eq!(editor.tail_buffers.payload_counts[0], "350");
+ }
+
+ #[test]
+ fn test_no_change_skips_history() {
+ let mut editor = editor_with_data();
+ editor.update_field("tail.map_id".into(), "12".into());
+ assert!(!editor.edit_history.can_undo());
+ }
+
+ #[test]
+ fn test_load_completed_resets_history_and_dirty() {
+ let mut editor = editor_with_data();
+ editor.update_field("tail.map_id".into(), "20".into());
+ editor.load_completed(Vec::new(), SaveIfo::default());
+ assert!(!editor.data.as_ref().unwrap().dirty);
+ assert!(!editor.edit_history.can_undo());
+ assert_eq!(editor.tail_buffers.map_id, "0");
+ }
+
+ #[test]
+ fn swapdone_preserves_unsaved_tail_edits() {
+ let mut editor = editor_with_data();
+ editor.update_field("tail.map_id".into(), "99".into());
+
+ // Fresh on-disk state after a swap: different slots, different tail.
+ let mut fresh = SaveIfo {
+ game_tmp_key: 7,
+ map_id: 55,
+ ..SaveIfo::default()
+ };
+ fresh.slots[0].flags = [1, 0, 0, 0];
+ fresh.slots[0].month = 9;
+ let summaries = vec![SlotSummary {
+ index: 0,
+ occupied: true,
+ sav_present: true,
+ month: 9,
+ day: 1,
+ hour: 2,
+ minute: 3,
+ game_tmp_key: Some(7),
+ map_id: Some(55),
+ }];
+
+ editor.apply_swapped(summaries, fresh);
+
+ let data = editor.data.as_ref().unwrap();
+ // Slots + summaries replaced from fresh…
+ assert_eq!(data.ifo.slots[0].month, 9);
+ assert_eq!(data.summaries.len(), 1);
+ assert_eq!(data.summaries[0].month, 9);
+ // …but unsaved tail edits survive.
+ assert_eq!(data.ifo.map_id, 99);
+ assert!(data.dirty);
+ assert_eq!(editor.tail_buffers.map_id, "99");
+ }
+}
diff --git a/dispel-gui/src/editors/save_ifo/update.rs b/dispel-gui/src/editors/save_ifo/update.rs
new file mode 100644
index 00000000..268ea3c8
--- /dev/null
+++ b/dispel-gui/src/editors/save_ifo/update.rs
@@ -0,0 +1,247 @@
+//! Message handling for the Save.ifo editor.
+
+use crate::app::App;
+use crate::components::loading_state::LoadingState;
+use crate::editors::mod_packager::recording;
+use crate::editors::save_ifo::message::{SaveIfoEditorMessage, TAIL_RECORD_ID};
+use crate::editors::save_ifo::state::SaveIfoEditorState;
+use crate::message::{Message, MessageExt};
+use dispel_core::{Extractor, SaveIfo, SlotSummary, summarize_slots, swap_slots};
+use iced::Task;
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+type LoadResult = Result<(Vec, SaveIfo), String>;
+
+/// Keep only the newest [`BACKUPS_TO_KEEP`] `Save.ifo.bak.*` files in `root`.
+/// Pruning failures are logged, never fatal — a save must not fail because
+/// old backups could not be removed.
+const BACKUPS_TO_KEEP: usize = 5;
+
+fn prune_old_backups(root: &Path) {
+ let mut backups: Vec = match std::fs::read_dir(root) {
+ Ok(entries) => entries
+ .filter_map(|e| e.ok())
+ .map(|e| e.path())
+ .filter(|p| {
+ p.file_name()
+ .and_then(|n| n.to_str())
+ .is_some_and(|n| n.starts_with("Save.ifo.bak."))
+ })
+ .collect(),
+ Err(e) => {
+ eprintln!("save_ifo: failed to list backups for pruning: {e}");
+ return;
+ }
+ };
+ // Names embed a Unix-timestamp suffix, so lexical order == chronological.
+ backups.sort();
+ while backups.len() > BACKUPS_TO_KEEP {
+ let oldest = backups.remove(0);
+ if let Err(e) = std::fs::remove_file(&oldest) {
+ eprintln!(
+ "save_ifo: failed to remove old backup {}: {e}",
+ oldest.display()
+ );
+ }
+ }
+}
+
+/// Read per-slot summaries plus the `Save.ifo` record from a game root.
+fn read_game_data(root: &Path) -> LoadResult {
+ let summaries = summarize_slots(root).map_err(|e| e.to_string())?;
+ let mut records = SaveIfo::read_file(&root.join("Save.ifo")).map_err(|e| e.to_string())?;
+ let ifo = records
+ .pop()
+ .ok_or_else(|| "Save.ifo contained no records".to_string())?;
+ Ok((summaries, ifo))
+}
+
+pub fn handle(message: SaveIfoEditorMessage, app: &mut App) -> Task {
+ match message {
+ SaveIfoEditorMessage::LoadCatalog => {
+ if app.state.shared_game_path.is_empty() {
+ app.state.editors.save_ifo_editor.status_msg =
+ "Please select game path first.".into();
+ return Task::none();
+ }
+ if app.state.editors.save_ifo_editor.loading_state == LoadingState::Loading {
+ app.state.editors.save_ifo_editor.status_msg =
+ "Operation already in progress.".into();
+ return Task::none();
+ }
+ app.state.editors.save_ifo_editor.loading_state = LoadingState::Loading;
+ app.state.editors.save_ifo_editor.status_msg = "Loading Save.ifo…".into();
+
+ let root = PathBuf::from(&app.state.shared_game_path);
+ Task::perform(async move { read_game_data(&root) }, |result| {
+ Message::save_ifo(SaveIfoEditorMessage::CatalogLoaded(result))
+ })
+ }
+ SaveIfoEditorMessage::CatalogLoaded(result) => {
+ let editor = &mut app.state.editors.save_ifo_editor;
+ match result {
+ Ok((summaries, ifo)) => {
+ let occupied = summaries.iter().filter(|s| s.occupied).count();
+ editor.load_completed(summaries, ifo);
+ editor.loading_state = LoadingState::Loaded(());
+ editor.status_msg = format!("Loaded Save.ifo: {occupied} of 6 slots used");
+ }
+ Err(e) => {
+ editor.loading_state = LoadingState::Failed(e.clone());
+ editor.status_msg = format!("Failed to load Save.ifo: {e}");
+ }
+ }
+ Task::none()
+ }
+ SaveIfoEditorMessage::FieldChanged(path, value) => {
+ let old = app
+ .state
+ .editors
+ .save_ifo_editor
+ .data
+ .as_ref()
+ .and_then(|d| SaveIfoEditorState::field_value(&d.ifo, &path));
+ app.state
+ .editors
+ .save_ifo_editor
+ .update_field(path.clone(), value.clone());
+ let new_value = app
+ .state
+ .editors
+ .save_ifo_editor
+ .data
+ .as_ref()
+ .and_then(|d| SaveIfoEditorState::field_value(&d.ifo, &path));
+ match (old, new_value) {
+ (Some(old), Some(new)) if old != new => recording::observe_field_change(
+ app,
+ "Save.ifo",
+ TAIL_RECORD_ID,
+ &path,
+ old,
+ new,
+ ),
+ _ => Task::none(),
+ }
+ }
+ SaveIfoEditorMessage::SwapRequested(a, b) => {
+ app.state.editors.save_ifo_editor.pending_swap = Some((a, b));
+ Task::none()
+ }
+ SaveIfoEditorMessage::SwapCancel => {
+ app.state.editors.save_ifo_editor.pending_swap = None;
+ Task::none()
+ }
+ SaveIfoEditorMessage::SwapConfirm => {
+ let editor = &mut app.state.editors.save_ifo_editor;
+ if editor.loading_state == LoadingState::Loading {
+ // Prevents a confirmed swap racing an in-flight save on Save.ifo.
+ editor.status_msg = "Operation already in progress.".into();
+ return Task::none();
+ }
+ let Some((a, b)) = editor.pending_swap.take() else {
+ return Task::none();
+ };
+ if app.state.shared_game_path.is_empty() {
+ editor.status_msg = "Please select game path first.".into();
+ return Task::none();
+ }
+ editor.loading_state = LoadingState::Loading;
+ editor.status_msg = format!("Swapping slots {a} and {b}…");
+
+ let root = PathBuf::from(&app.state.shared_game_path);
+ Task::perform(
+ async move {
+ swap_slots(&root, a, b).map_err(|e| e.to_string())?;
+ read_game_data(&root)
+ },
+ |result| Message::save_ifo(SaveIfoEditorMessage::SwapDone(result)),
+ )
+ }
+ SaveIfoEditorMessage::SwapDone(result) => {
+ let editor = &mut app.state.editors.save_ifo_editor;
+ editor.pending_swap = None;
+ match result {
+ Ok((summaries, fresh)) => {
+ // A swap only rewrites slot records; keep any unsaved tail
+ // edits by splicing in just the fresh slot list.
+ if editor.data.is_some() {
+ editor.apply_swapped(summaries, fresh);
+ } else {
+ editor.load_completed(summaries, fresh);
+ }
+ editor.loading_state = LoadingState::Loaded(());
+ editor.status_msg = "Slots swapped".into();
+ }
+ Err(e) => {
+ // Leave loading_state untouched when nothing is loaded —
+ // there was no in-flight operation to complete.
+ if editor.data.is_some() {
+ editor.loading_state = LoadingState::Loaded(());
+ }
+ editor.status_msg = format!("Swap failed: {e}");
+ }
+ }
+ Task::none()
+ }
+ SaveIfoEditorMessage::Save => {
+ if app.state.shared_game_path.is_empty() {
+ app.state.editors.save_ifo_editor.status_msg =
+ "Please select game path first.".into();
+ return Task::none();
+ }
+ if app.state.editors.save_ifo_editor.loading_state == LoadingState::Loading {
+ // Prevents Ctrl+S racing an in-flight swap writing the same file.
+ app.state.editors.save_ifo_editor.status_msg =
+ "Operation already in progress.".into();
+ return Task::none();
+ }
+ let Some(data) = app.state.editors.save_ifo_editor.data.clone() else {
+ app.state.editors.save_ifo_editor.status_msg = "Nothing loaded to save.".into();
+ return Task::none();
+ };
+ app.state.editors.save_ifo_editor.status_msg = "Saving Save.ifo…".into();
+ app.state.editors.save_ifo_editor.loading_state = LoadingState::Loading;
+
+ let root = PathBuf::from(&app.state.shared_game_path);
+ Task::perform(
+ async move {
+ let path = root.join("Save.ifo");
+ if path.exists() {
+ // Timestamped backup before overwriting.
+ let stamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map_err(|e| e.to_string())?
+ .as_secs();
+ let backup = root.join(format!("Save.ifo.bak.{stamp}"));
+ std::fs::copy(&path, &backup)
+ .map_err(|e| format!("Failed to create backup: {e}"))?;
+ }
+ Extractor::save_file(std::slice::from_ref(&data.ifo), &path)
+ .map_err(|e| e.to_string())?;
+ prune_old_backups(&root);
+ Ok(())
+ },
+ |result| Message::save_ifo(SaveIfoEditorMessage::Saved(result)),
+ )
+ }
+ SaveIfoEditorMessage::Saved(result) => {
+ let editor = &mut app.state.editors.save_ifo_editor;
+ editor.loading_state = LoadingState::Loaded(());
+ match result {
+ Ok(()) => {
+ if let Some(data) = &mut editor.data {
+ data.dirty = false;
+ }
+ editor.sync_tail_buffers();
+ editor.status_msg = "Saved Save.ifo".into();
+ }
+ Err(e) => {
+ editor.status_msg = format!("Error saving Save.ifo: {e}");
+ }
+ }
+ Task::none()
+ }
+ }
+}
diff --git a/dispel-gui/src/editors/save_ifo/view.rs b/dispel-gui/src/editors/save_ifo/view.rs
new file mode 100644
index 00000000..3b380c92
--- /dev/null
+++ b/dispel-gui/src/editors/save_ifo/view.rs
@@ -0,0 +1,326 @@
+//! View for the Save.ifo editor.
+
+use super::message::SaveIfoEditorMessage;
+use super::state::{EditorData, SaveIfoEditorState};
+use crate::app::App;
+use crate::components::loading_state::LoadingState;
+use crate::components::utils::horizontal_space;
+use crate::message::{Message, MessageExt};
+use crate::style;
+use gui_widgets::components::modal::modal;
+use gui_widgets::lucide::{LUCIDE_FONT, icon_char};
+use iced::widget::{button, column, container, row, scrollable, text, text_input};
+use iced::{Element, Length};
+use lucide_icons::Icon;
+
+const SLOT_COUNT: usize = 6;
+
+pub fn view(app: &App) -> Element<'_, Message> {
+ let editor = &app.state.editors.save_ifo_editor;
+ let base: Element<'_, Message> = match &editor.loading_state {
+ LoadingState::Failed(error) => failed_view(error),
+ LoadingState::Loading => hint_view("Loading Save.ifo…"),
+ _ => match &editor.data {
+ Some(data) => loaded_view(data, editor),
+ None => hint_view(
+ "No Save.ifo loaded. Pick a game folder first, then reopen this tab to inspect its save slots.",
+ ),
+ },
+ };
+
+ match editor.pending_swap {
+ Some((a, b)) => modal(
+ base,
+ swap_confirm_modal(a, b),
+ || Message::save_ifo(SaveIfoEditorMessage::SwapCancel),
+ 0.6,
+ ),
+ None => base,
+ }
+}
+
+fn loaded_view<'a>(data: &'a EditorData, editor: &'a SaveIfoEditorState) -> Element<'a, Message> {
+ let occupied = data.summaries.iter().filter(|s| s.occupied).count();
+
+ let header = row![
+ column![
+ text("Save slots").size(18),
+ text(format!("{occupied} of {SLOT_COUNT} slots used"))
+ .size(12)
+ .style(style::subtle_text),
+ ]
+ .spacing(2),
+ horizontal_space(),
+ dirty_indicator(data.dirty),
+ save_button(data.dirty),
+ ]
+ .spacing(8)
+ .align_y(iced::alignment::Vertical::Center);
+
+ let content = column![
+ header,
+ slot_table(&data.summaries),
+ container(column![].height(6.0)),
+ tail_section(editor),
+ ]
+ .spacing(14)
+ .padding(12);
+
+ let body = column![
+ scrollable(content).width(Length::Fill).height(Length::Fill),
+ status_bar(editor),
+ ]
+ .height(Length::Fill);
+
+ body.into()
+}
+
+fn dirty_indicator(dirty: bool) -> Element<'static, Message> {
+ if dirty {
+ row![
+ text(icon_char(Icon::Dot)).font(LUCIDE_FONT).size(12),
+ text("unsaved changes").style(style::subtle_text),
+ ]
+ .spacing(4)
+ .align_y(iced::alignment::Vertical::Center)
+ .into()
+ } else {
+ horizontal_space().into()
+ }
+}
+
+fn save_button(dirty: bool) -> Element<'static, Message> {
+ button(text("Save"))
+ .padding([4, 14])
+ .style(style::commit_button)
+ .on_press_maybe(dirty.then(|| Message::save_ifo(SaveIfoEditorMessage::Save)))
+ .into()
+}
+
+fn slot_table(summaries: &[dispel_core::SlotSummary]) -> Element<'_, Message> {
+ let mut table = column![slot_header_row()].spacing(2);
+ for summary in summaries.iter().take(SLOT_COUNT) {
+ table = table.push(slot_row(summary.index, summary));
+ }
+ container(table).into()
+}
+
+fn header_cell(label: &str, width: f32) -> Element<'_, Message> {
+ container(text(label).style(style::subtle_text))
+ .width(width)
+ .into()
+}
+
+fn slot_header_row() -> Element<'static, Message> {
+ row![
+ header_cell("Slot", 40.0),
+ header_cell("Used", 50.0),
+ header_cell("Save file", 90.0),
+ header_cell("Saved at", 110.0),
+ header_cell("Tmp key", 70.0),
+ header_cell("Map id", 70.0),
+ header_cell("Swap", 80.0),
+ ]
+ .spacing(6)
+ .into()
+}
+
+fn cell(value: String, width: f32) -> Element<'static, Message> {
+ container(text(value)).width(width).into()
+}
+
+fn icon_cell(icon: Icon, width: f32) -> Element<'static, Message> {
+ container(text(icon_char(icon)).font(LUCIDE_FONT))
+ .width(width)
+ .into()
+}
+
+fn saved_at_text(summary: &dispel_core::SlotSummary) -> String {
+ if !summary.occupied {
+ return "—".into();
+ }
+ format!(
+ "{:02}/{:02} {:02}:{:02}",
+ summary.month, summary.day, summary.hour, summary.minute
+ )
+}
+
+fn sav_cell(summary: &dispel_core::SlotSummary) -> Element<'static, Message> {
+ if summary.sav_present {
+ icon_cell(Icon::Check, 90.0)
+ } else if summary.occupied {
+ container(
+ row![
+ text(icon_char(Icon::AlertTriangle))
+ .font(LUCIDE_FONT)
+ .size(11),
+ text("missing").size(11),
+ ]
+ .spacing(3),
+ )
+ .width(90.0)
+ .into()
+ } else {
+ cell("—".into(), 90.0)
+ }
+}
+
+fn swap_button(icon: Icon, from: usize, to: usize) -> Element<'static, Message> {
+ let request = (to < SLOT_COUNT && from != to)
+ .then(|| Message::save_ifo(SaveIfoEditorMessage::SwapRequested(from, to)));
+ button(text(icon_char(icon)).font(LUCIDE_FONT).size(11))
+ .padding([2, 7])
+ .style(style::browse_button)
+ .on_press_maybe(request)
+ .into()
+}
+
+fn slot_row(index: usize, summary: &dispel_core::SlotSummary) -> Element<'static, Message> {
+ let opt = |value: Option| value.map_or_else(|| "—".to_string(), |v| v.to_string());
+ row![
+ cell(index.to_string(), 40.0),
+ icon_cell(
+ if summary.occupied {
+ Icon::CircleDot
+ } else {
+ Icon::Circle
+ },
+ 50.0,
+ ),
+ sav_cell(summary),
+ cell(saved_at_text(summary), 110.0),
+ cell(opt(summary.game_tmp_key), 70.0),
+ cell(opt(summary.map_id), 70.0),
+ row![
+ swap_button(Icon::ChevronUp, index, index.wrapping_sub(1)),
+ swap_button(Icon::ChevronDown, index, index + 1),
+ ]
+ .spacing(4),
+ ]
+ .spacing(6)
+ .align_y(iced::alignment::Vertical::Center)
+ .into()
+}
+
+fn labeled_input<'a>(label: &'a str, value: &'a str, path: &'static str) -> Element<'a, Message> {
+ row![
+ container(text(label).style(style::subtle_text)).width(140.0),
+ text_input("", value)
+ .on_input(
+ move |v| Message::save_ifo(SaveIfoEditorMessage::FieldChanged(path.to_string(), v))
+ )
+ .width(170.0),
+ ]
+ .spacing(8)
+ .align_y(iced::alignment::Vertical::Center)
+ .into()
+}
+
+fn tail_section(editor: &SaveIfoEditorState) -> Element<'_, Message> {
+ let buffers = &editor.tail_buffers;
+ let counts = row![
+ container(text("Payload counts").style(style::subtle_text)).width(140.0),
+ text_input("", &buffers.payload_counts[0])
+ .on_input(|v| count_msg(0, v))
+ .width(90.0),
+ text_input("", &buffers.payload_counts[1])
+ .on_input(|v| count_msg(1, v))
+ .width(90.0),
+ text_input("", &buffers.payload_counts[2])
+ .on_input(|v| count_msg(2, v))
+ .width(90.0),
+ text_input("", &buffers.payload_counts[3])
+ .on_input(|v| count_msg(3, v))
+ .width(90.0),
+ ]
+ .spacing(8)
+ .align_y(iced::alignment::Vertical::Center);
+
+ column![
+ text("Global state").size(16),
+ labeled_input("Game version", &buffers.game_version, "tail.game_version"),
+ labeled_input("Tmp key", &buffers.game_tmp_key, "tail.game_tmp_key"),
+ labeled_input("Map id", &buffers.map_id, "tail.map_id"),
+ labeled_input("Reserved", &buffers.reserved, "tail.reserved"),
+ counts,
+ ]
+ .spacing(8)
+ .into()
+}
+
+fn count_msg(index: usize, value: String) -> Message {
+ Message::save_ifo(SaveIfoEditorMessage::FieldChanged(
+ format!("tail.payload_counts.{index}"),
+ value,
+ ))
+}
+
+fn status_bar(editor: &SaveIfoEditorState) -> Element<'_, Message> {
+ container(
+ row![
+ dirty_indicator(editor.data.as_ref().is_some_and(|d| d.dirty)),
+ horizontal_space(),
+ text(&editor.status_msg).style(style::subtle_text),
+ ]
+ .spacing(8),
+ )
+ .padding([4, 10])
+ .width(Length::Fill)
+ .style(style::status_bar)
+ .into()
+}
+
+fn swap_confirm_modal(a: usize, b: usize) -> Element<'static, Message> {
+ let content = column![
+ text(format!("Swap save slots {a} and {b}?")).size(16),
+ text(
+ "The two .sav files exchange positions immediately and the\n\
+ slot metadata in Save.ifo is updated to match.\n\
+ This cannot be undone with Ctrl+Z."
+ )
+ .style(style::subtle_text),
+ row![
+ button(text("Cancel"))
+ .padding([4, 14])
+ .style(style::browse_button)
+ .on_press(Message::save_ifo(SaveIfoEditorMessage::SwapCancel)),
+ button(text("Swap"))
+ .padding([4, 14])
+ .style(style::commit_button)
+ .on_press(Message::save_ifo(SaveIfoEditorMessage::SwapConfirm)),
+ ]
+ .spacing(8),
+ ]
+ .spacing(10);
+
+ container(content)
+ .padding(16)
+ .width(430.0)
+ .style(style::modal_container)
+ .into()
+}
+
+fn hint_view(message: &str) -> Element<'_, Message> {
+ container(text(message).style(style::subtle_text))
+ .width(Length::Fill)
+ .center_x(Length::Fill)
+ .padding(24)
+ .into()
+}
+
+fn failed_view(error: &str) -> Element<'_, Message> {
+ container(
+ column![
+ text(format!("Failed to load Save.ifo: {error}")),
+ button(text("Retry"))
+ .padding([4, 14])
+ .style(style::browse_button)
+ .on_press(Message::save_ifo(SaveIfoEditorMessage::LoadCatalog)),
+ ]
+ .spacing(10),
+ )
+ .width(Length::Fill)
+ .center_x(Length::Fill)
+ .padding(24)
+ .into()
+}
diff --git a/dispel-gui/src/editors/sprite_editor/message.rs b/dispel-gui/src/editors/sprite_editor/message.rs
index e91b1bc5..dd6cfda7 100644
--- a/dispel-gui/src/editors/sprite_editor/message.rs
+++ b/dispel-gui/src/editors/sprite_editor/message.rs
@@ -47,6 +47,8 @@ pub enum SpriteViewerMessage {
ZoomOut,
ZoomReset,
ZoomToFit,
+ /// Toggle whether raw `0x0000` pixels are displayed as opaque black.
+ TogglePreserveBlack,
// ── Editing ──────────────────────────────────────────────────────────────
/// Save the current sprite file to disk.
Save,
diff --git a/dispel-gui/src/editors/sprite_editor/state.rs b/dispel-gui/src/editors/sprite_editor/state.rs
index 78fe0456..1babe7ee 100644
--- a/dispel-gui/src/editors/sprite_editor/state.rs
+++ b/dispel-gui/src/editors/sprite_editor/state.rs
@@ -50,6 +50,8 @@ pub struct SpriteViewerState {
pub selected_frame: usize,
pub frames: Vec,
pub error: Option,
+ /// Show raw `0x0000` pixels as opaque black instead of transparent.
+ pub preserve_black: bool,
// Editing
pub dirty: bool,
pub undo_stack: Vec,
@@ -83,6 +85,7 @@ impl Default for SpriteViewerState {
selected_frame: 0,
frames: Vec::new(),
error: None,
+ preserve_black: false,
dirty: false,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
@@ -202,7 +205,7 @@ impl SpriteViewerState {
}
}
- /// Rebuild `self.frames` from `self.sprite_file` by decoding all RGB565
+ /// Rebuild `self.frames` from `self.sprite_file` by decoding all 16-bit
/// pixel data to RGBA and creating iced image handles.
///
/// Call this after any edit to `sprite_file` to keep the display in sync.
@@ -222,7 +225,7 @@ impl SpriteViewerState {
let w = frame.width.max(0) as u32;
let h = frame.height.max(0) as u32;
let rgba = if w > 0 && h > 0 {
- frame.decode_to_rgba()
+ frame.decode_to_rgba_with_black(self.preserve_black)
} else {
Vec::new()
};
diff --git a/dispel-gui/src/editors/sprite_editor/update.rs b/dispel-gui/src/editors/sprite_editor/update.rs
index d8e86cef..7762c4cf 100644
--- a/dispel-gui/src/editors/sprite_editor/update.rs
+++ b/dispel-gui/src/editors/sprite_editor/update.rs
@@ -4,7 +4,9 @@ use crate::app::App;
use crate::editors::sprite_editor::{
ExportDialogState, ExportFormat, ExportStatus, SpriteViewerMessage,
};
-use dispel_core::sprite::{SpriteFrameData, write_sprite_to_path};
+use dispel_core::sprite::{
+ SpriteFrameData, SpritePixelFormat, rgba_to_rgb16_bytes, write_sprite_to_path,
+};
use iced::Task;
/// Real-time milliseconds per animation tick (~60 fps clock).
@@ -126,6 +128,10 @@ pub fn handle(message: SpriteViewerMessage, app: &mut App) -> Task {
+ viewer.preserve_black = !viewer.preserve_black;
+ viewer.rebuild_preview_frames();
+ }
// ── Undo / Redo ──────────────────────────────────────────────────────
SpriteViewerMessage::Undo => {
@@ -147,6 +153,13 @@ pub fn handle(message: SpriteViewerMessage, app: &mut App) -> Task Task Task Task Element<'_, Message> {
if let Some(ref dlg) = viewer.export_dialog {
modal(
base,
- view_export_dialog(dlg),
+ view_export_dialog(viewer, dlg),
|| sv(SpriteViewerMessage::CloseExportDialog),
0.5,
)
@@ -270,6 +272,13 @@ fn view_zoom_controls(viewer: &SpriteViewerState) -> Element<'_, Message> {
.size(11)
.style(style::subtle_text);
+ let preserve_black = row![
+ checkbox(viewer.preserve_black).on_toggle(|_| sv(SpriteViewerMessage::TogglePreserveBlack)),
+ text("Keep #000 black").size(11),
+ ]
+ .spacing(4)
+ .align_y(Alignment::Center);
+
container(
row![
zoom_out,
@@ -279,6 +288,8 @@ fn view_zoom_controls(viewer: &SpriteViewerState) -> Element<'_, Message> {
zoom_fit,
Space::new().width(4),
zoom_label,
+ Space::new().width(12),
+ preserve_black,
]
.spacing(2)
.padding([2, 8])
@@ -634,7 +645,10 @@ fn view_playback_controls(viewer: &SpriteViewerState) -> Element<'_, Message> {
// ── Export dialog ─────────────────────────────────────────────────────────────
-fn view_export_dialog(dlg: &ExportDialogState) -> Element<'_, Message> {
+fn view_export_dialog<'a>(
+ viewer: &'a SpriteViewerState,
+ dlg: &'a ExportDialogState,
+) -> Element<'a, Message> {
let title = text("Export Sprite").size(14).style(style::primary_text);
// Format selector
@@ -660,6 +674,13 @@ fn view_export_dialog(dlg: &ExportDialogState) -> Element<'_, Message> {
style::chip
});
+ let preserve_black = row![
+ checkbox(viewer.preserve_black).on_toggle(|_| sv(SpriteViewerMessage::TogglePreserveBlack)),
+ text("Keep #000 black").size(12),
+ ]
+ .spacing(4)
+ .align_y(Alignment::Center);
+
// Destination folder
let dir_label = text(
dlg.export_dir
@@ -719,6 +740,7 @@ fn view_export_dialog(dlg: &ExportDialogState) -> Element<'_, Message> {
horizontal_rule(1),
text("Format").size(11).style(style::subtle_text),
row![fmt_png, fmt_sheet].spacing(8),
+ preserve_black,
text("Destination").size(11).style(style::subtle_text),
row![dir_label, Space::new().width(Fill), choose_dir]
.spacing(8)
diff --git a/dispel-gui/src/editors/tile_grid/canvas.rs b/dispel-gui/src/editors/tile_grid/canvas.rs
new file mode 100644
index 00000000..5a2bad8f
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/canvas.rs
@@ -0,0 +1,184 @@
+//! 2D canvas rendering for the tile grid.
+//!
+//! Draws a 249×249 grid of axis-aligned colored squares, one per tile
+//! descriptor. Color-coded by `object_id`:
+//! - 0 = dark gray (empty)
+//! - 1 = light gray (wall)
+//! - 2+ = blue highlight
+//!
+//! Hovered/selected cells get a colored border.
+
+use super::message::TileGridCanvasMessage;
+use super::state::TileGridViewState;
+use crate::message::{Message, MessageExt};
+use dispel_core::references::tile_grid::{TileDescriptor, TileGrid};
+use iced::widget::canvas::{self, Action, Frame, Geometry, Path, Stroke};
+use iced::{Color, Event, Point, Rectangle, mouse};
+
+/// Mutable state for the canvas program — tracks drag-to-pan.
+#[derive(Debug, Default, Clone, Copy)]
+pub struct DragState {
+ /// `true` while the left mouse button is held down and the cursor has
+ /// moved at least one pixel since the press. Used to distinguish a
+ /// genuine drag from a simple click.
+ pub did_drag: bool,
+ /// `true` while the left mouse button is held down.
+ pub dragging: bool,
+ /// Last cursor position in canvas-local coords (for delta computation).
+ pub last_pos: Option,
+}
+
+/// `iced::widget::canvas::Program` that renders the 2D tile grid.
+pub struct TileGridCanvas<'a> {
+ pub grid: &'a TileGrid,
+ pub view: &'a TileGridViewState,
+}
+
+impl<'a> canvas::Program for TileGridCanvas<'a> {
+ type State = DragState;
+
+ fn update(
+ &self,
+ state: &mut DragState,
+ event: &Event,
+ bounds: Rectangle,
+ cursor: mouse::Cursor,
+ ) -> Option> {
+ use mouse::Event as MouseEvent;
+ match event {
+ Event::Mouse(MouseEvent::ButtonPressed(mouse::Button::Left)) => {
+ if let Some(pos) = cursor.position_in(bounds) {
+ state.dragging = true;
+ state.did_drag = false;
+ state.last_pos = Some(pos);
+ return None;
+ }
+ }
+ Event::Mouse(MouseEvent::ButtonReleased(mouse::Button::Left)) => {
+ let did_drag = state.did_drag;
+ state.did_drag = false;
+ state.dragging = false;
+ state.last_pos = None;
+ if !did_drag {
+ if let Some(pos) = cursor.position_in(bounds) {
+ return Some(Action::publish(Message::tile_grid_canvas(
+ TileGridCanvasMessage::Click(pos.x, pos.y),
+ )));
+ }
+ }
+ }
+ Event::Mouse(MouseEvent::CursorMoved { .. }) => {
+ if let Some(pos) = cursor.position_in(bounds) {
+ if state.dragging {
+ if let Some(last) = state.last_pos {
+ let dx = pos.x - last.x;
+ let dy = pos.y - last.y;
+ if dx != 0.0 || dy != 0.0 {
+ state.did_drag = true;
+ }
+ state.last_pos = Some(pos);
+ return Some(Action::publish(Message::tile_grid_canvas(
+ TileGridCanvasMessage::Pan(dx, dy),
+ )));
+ }
+ state.last_pos = Some(pos);
+ } else {
+ return Some(Action::publish(Message::tile_grid_canvas(
+ TileGridCanvasMessage::Hover(pos.x, pos.y),
+ )));
+ }
+ }
+ }
+ Event::Mouse(MouseEvent::WheelScrolled { delta }) => {
+ let factor = match delta {
+ mouse::ScrollDelta::Lines { y, .. } => 1.0 + y * 0.1,
+ mouse::ScrollDelta::Pixels { y, .. } => 1.0 + y * 0.01,
+ };
+ // Zoom-to-cursor: pass cursor position so the update handler
+ // can keep the world point under the cursor stationary.
+ if let Some(pos) = cursor.position_in(bounds) {
+ return Some(Action::publish(Message::tile_grid_canvas(
+ TileGridCanvasMessage::Zoom(factor, pos.x, pos.y),
+ )));
+ }
+ }
+ Event::Window(iced::window::Event::Resized { .. }) => {
+ return Some(Action::publish(Message::tile_grid_canvas(
+ TileGridCanvasMessage::Resize(bounds.width, bounds.height),
+ )));
+ }
+ _ => {}
+ }
+ None
+ }
+
+ fn draw(
+ &self,
+ _state: &DragState,
+ renderer: &iced::Renderer,
+ _theme: &iced::Theme,
+ bounds: Rectangle,
+ _cursor: mouse::Cursor,
+ ) -> Vec {
+ let frame = Frame::new(renderer, bounds.size());
+ let mut frame = frame;
+ draw_grid(&mut frame, self.grid, self.view);
+ vec![frame.into_geometry()]
+ }
+}
+
+/// Draw the full 249×249 grid into a frame.
+fn draw_grid(frame: &mut Frame, grid: &TileGrid, view: &TileGridViewState) {
+ let cell_px = super::state::CELL_SIZE * view.zoom;
+ let gs = grid.grid_size;
+
+ // Clip to the visible area for performance
+ let visible_x_start = ((-view.pan_x) / cell_px).max(0.0) as u32;
+ let visible_y_start = ((-view.pan_y) / cell_px).max(0.0) as u32;
+ let visible_x_end = ((frame.width() - view.pan_x) / cell_px + 1.0).min(gs as f32) as u32;
+ let visible_y_end = ((frame.height() - view.pan_y) / cell_px + 1.0).min(gs as f32) as u32;
+
+ for gy in visible_y_start..visible_y_end {
+ for gx in visible_x_start..visible_x_end {
+ let index = gy * gs + gx;
+ if let Some(&word) = grid.data.get(index as usize) {
+ let desc = TileDescriptor::from_word(word);
+ let color = cell_color(desc.object_id);
+ let x = gx as f32 * cell_px + view.pan_x;
+ let y = gy as f32 * cell_px + view.pan_y;
+ frame.fill_rectangle(
+ iced::Point::new(x, y),
+ iced::Size::new(cell_px, cell_px),
+ color,
+ );
+ }
+ }
+ }
+
+ // Draw hover/selection borders
+ for &index in [view.hovered_cell, view.selected_cell].iter().flatten() {
+ let gx = (index % gs) as f32;
+ let gy = (index / gs) as f32;
+ let x = gx * cell_px + view.pan_x;
+ let y = gy * cell_px + view.pan_y;
+ let stroke_color = if Some(index) == view.selected_cell {
+ Color::from_rgb(1.0, 0.8, 0.0) // gold for selected
+ } else {
+ Color::from_rgb(0.3, 0.7, 1.0) // light blue for hovered
+ };
+ let path = Path::rectangle(iced::Point::new(x, y), iced::Size::new(cell_px, cell_px));
+ frame.stroke(
+ &path,
+ Stroke::default().with_color(stroke_color).with_width(2.0),
+ );
+ }
+}
+
+/// Color for a cell based on its `object_id`.
+fn cell_color(object_id: u16) -> Color {
+ match object_id {
+ 0 => Color::from_rgb(0.15, 0.15, 0.15), // empty: dark
+ 1 => Color::from_rgb(0.6, 0.6, 0.6), // wall: light gray
+ _ => Color::from_rgb(0.3, 0.5, 0.9), // other: blue
+ }
+}
diff --git a/dispel-gui/src/editors/tile_grid/component.rs b/dispel-gui/src/editors/tile_grid/component.rs
new file mode 100644
index 00000000..ac16667c
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/component.rs
@@ -0,0 +1,66 @@
+use crate::components::editable::EditableRecord;
+use crate::editable_record_fields;
+use dispel_core::TileGrid;
+use dispel_core::references::tile_grid::TileDescriptor;
+
+editable_record_fields!(TileGrid, {
+ { id = Integer / "ID:" },
+ { payload_length = Integer / "Payload Length:" },
+ { grid_size = Integer / "Grid Size:" },
+ { data = VecU32 / "Grid Data (comma-separated u32):" },
+});
+
+impl EditableRecord for TileGrid {
+ crate::editable_record_delegate!();
+ fn list_label(&self) -> String {
+ let stats = grid_stats(self);
+ format!(
+ "Tile grid (grid={}x{}, entries={}, walls={}, empty={}, unique_tiles={})",
+ self.grid_size,
+ self.grid_size,
+ self.data.len(),
+ stats.walls,
+ stats.empty,
+ stats.unique_tiles
+ )
+ }
+ fn detail_title() -> &'static str {
+ "Tile Grid Details"
+ }
+ fn empty_selection_text() -> &'static str {
+ "No tile grid selected"
+ }
+ fn save_button_label() -> &'static str {
+ "Save Tile Grid"
+ }
+}
+
+/// Summary statistics for a tile grid, computed via `TileDescriptor` unpacking.
+struct GridStats {
+ /// Tiles with `object_id == 1` (walls).
+ walls: usize,
+ /// Tiles with `object_id == 0` (empty).
+ empty: usize,
+ /// Number of distinct `tile_index` values across the grid.
+ unique_tiles: usize,
+}
+
+fn grid_stats(grid: &TileGrid) -> GridStats {
+ let mut walls = 0usize;
+ let mut empty = 0usize;
+ let mut tile_indices = std::collections::HashSet::new();
+ for word in &grid.data {
+ let d = TileDescriptor::from_word(*word);
+ match d.object_id {
+ 0 => empty += 1,
+ 1 => walls += 1,
+ _ => {}
+ }
+ tile_indices.insert(d.tile_index);
+ }
+ GridStats {
+ walls,
+ empty,
+ unique_tiles: tile_indices.len(),
+ }
+}
diff --git a/dispel-gui/src/editors/tile_grid/message.rs b/dispel-gui/src/editors/tile_grid/message.rs
new file mode 100644
index 00000000..362eebaf
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/message.rs
@@ -0,0 +1,40 @@
+//! Messages for the tile grid canvas view.
+//!
+//! These are handled by `update::handle` and routed via `dispatch_table.rs`.
+
+use dispel_core::references::tile_grid::TileDescriptor;
+
+/// User actions on the tile grid 2D canvas.
+#[derive(Debug, Clone)]
+pub enum TileGridCanvasMessage {
+ /// Pan the canvas by (dx, dy) in screen pixels.
+ Pan(f32, f32),
+ /// Zoom by a multiplicative factor, keeping the world point under the
+ /// cursor at the same screen position. `(factor, cursor_x, cursor_y)`.
+ Zoom(f32, f32, f32),
+ /// Set zoom to a specific value (clamped to `[MIN_ZOOM, MAX_ZOOM]`).
+ SetZoom(f32),
+ /// User clicked at canvas-local pixel coords; selects the cell under the cursor.
+ Click(f32, f32),
+ /// User moved the cursor; updates the hovered cell.
+ Hover(f32, f32),
+ /// Canvas was resized.
+ Resize(f32, f32),
+ /// Fit the entire grid into the current canvas size.
+ FitToWindow,
+ /// Edit the currently selected cell's descriptor fields.
+ EditSelected(TileDescriptor),
+ /// Clear the current selection.
+ ClearSelection,
+ /// Inspector pane was resized by the user.
+ PaneResized(iced::widget::pane_grid::ResizeEvent),
+ /// Inspector pane was clicked/focused.
+ PaneClicked(iced::widget::pane_grid::Pane),
+ /// Update the local edit buffer for a field without committing to
+ /// `grid.data`. Used so the user can type "999" and see "999" before
+ /// it gets clamped on commit (Enter/blur).
+ EditFieldBuffer { field: String, value: String },
+ /// Commit the edit buffer for a field to `grid.data` (called on Enter
+ /// or blur). If the buffer is empty or invalid, the field is ignored.
+ CommitFieldBuffer { field: String },
+}
diff --git a/dispel-gui/src/editors/tile_grid/mod.rs b/dispel-gui/src/editors/tile_grid/mod.rs
new file mode 100644
index 00000000..b0c402c3
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/mod.rs
@@ -0,0 +1,93 @@
+mod canvas;
+mod component;
+pub mod message;
+pub mod state;
+mod update;
+pub mod view;
+
+#[cfg(test)]
+mod tests;
+
+pub use message::TileGridCanvasMessage;
+pub use state::TileGridViewState;
+
+/// Handle a `TileGridCanvasMessage`. Mutates the view state and the active
+/// grid record (if loaded), and returns a status string for the inspector.
+pub fn handle_canvas(
+ msg: TileGridCanvasMessage,
+ view: &mut TileGridViewState,
+ grid: &mut dispel_core::TileGrid,
+) -> Option {
+ update::handle(msg, view, grid)
+}
+
+/// Handle a `TileGridCanvasMessage` from the dispatch table.
+/// Mutates the registry's `tile_grid_view` and the first loaded grid record.
+/// Records `EditSelected` mutations in the standard editor's `EditHistory`
+/// so undo/redo works for canvas edits.
+pub fn handle_canvas_message(
+ msg: TileGridCanvasMessage,
+ app: &mut crate::app::App,
+) -> iced::Task {
+ // Step 1: capture everything we need from `app` before the mutable
+ // borrow (catalog mutation + history push can't overlap).
+ let (old_word, new_desc_for_history, selected_idx) = {
+ let view = &app.state.editors.tile_grid_view;
+ let catalog = &app.state.editors.tile_grid_editor.state.catalog;
+ let old = if let TileGridCanvasMessage::EditSelected(_) = &msg {
+ catalog.as_ref().and_then(|c| c.first()).and_then(|g| {
+ view.selected_cell
+ .and_then(|idx| g.data.get(idx as usize).copied())
+ })
+ } else {
+ None
+ };
+ let new_desc = if let TileGridCanvasMessage::EditSelected(d) = &msg {
+ Some(*d)
+ } else {
+ None
+ };
+ (old, new_desc, view.selected_cell)
+ };
+
+ // Step 2: mutate the catalog (mutable borrow of app).
+ let view = &mut app.state.editors.tile_grid_view;
+ let status = if let Some(catalog) = app.state.editors.tile_grid_editor.state.catalog.as_mut() {
+ if let Some(grid) = catalog.first_mut() {
+ update::handle(msg, view, grid)
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ // Step 3: push to EditHistory (mutable borrow, now safe since the
+ // catalog borrow from step 2 is dropped).
+ if let (Some(old), Some(new_desc)) = (old_word, new_desc_for_history) {
+ let new_word = update::encode_descriptor(new_desc);
+ if new_word != old {
+ app.state.editors.tile_grid_editor.state.edit_history.push(
+ crate::components::edit_history::EditAction::FieldChange {
+ record_idx: selected_idx.unwrap_or(0) as usize,
+ field: format!("cell_{}", selected_idx.unwrap_or(0)),
+ old_value: old.to_string(),
+ new_value: new_word.to_string(),
+ },
+ );
+ }
+ }
+
+ if let Some(s) = status {
+ app.state.status_msg = s;
+ }
+ iced::Task::none()
+}
+
+crate::define_standard_editor! {
+ name: tile_grid,
+ name_pascal: TileGrid,
+ record: dispel_core::TileGrid,
+ field: tile_grid_editor,
+ file: "Map/Multi/*.dat",
+}
diff --git a/dispel-gui/src/editors/tile_grid/state.rs b/dispel-gui/src/editors/tile_grid/state.rs
new file mode 100644
index 00000000..23ad2dc3
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/state.rs
@@ -0,0 +1,144 @@
+//! View state for the 2D tile grid canvas.
+//!
+//! Holds pan/zoom/cursor state for the axis-aligned 2D grid view of a
+//! `TileGrid`. This is intentionally simpler than the isometric
+//! `MapViewState` — no layer toggles, no sprite handles, no tool system.
+
+use iced::widget::canvas;
+use iced::widget::pane_grid;
+use std::collections::HashMap;
+
+/// Viewport state for the tile grid canvas.
+#[derive(Debug)]
+pub struct TileGridViewState {
+ /// Pixel pan offset (canvas translation).
+ pub pan_x: f32,
+ /// Pixel pan offset (canvas translation).
+ pub pan_y: f32,
+ /// Zoom factor (1.0 = `CELL_SIZE` pixels per cell).
+ pub zoom: f32,
+ /// Flat index of the hovered cell (`y * grid_size + x`), or `None`.
+ pub hovered_cell: Option,
+ /// Flat index of the selected cell, or `None`.
+ pub selected_cell: Option,
+ /// Last known canvas size in pixels (updated from `Resized`).
+ pub last_canvas_w: f32,
+ pub last_canvas_h: f32,
+ /// Cached grid layer. Cleared on pan/zoom/grid-data change.
+ pub grid_cache: canvas::Cache,
+ /// `true` until the first `FitToWindow` has been applied after a grid load.
+ /// Used by the canvas view to auto-fit on first render.
+ // TODO(bug: no-auto-fit-on-catalog-load): needs_fit is set to `true` in
+ // Default, but the render path can't mutate state (view is `&App`,
+ // immutable). The flag is only cleared by explicit `FitToWindow` or
+ // `Resize` events. Loading a `.dat` file requires the user to click
+ // "Fit" or resize the window before the grid is visible. Fix by
+ // dispatching `FitToWindow` from the standard editor's `CatalogLoaded`
+ // handler — requires adding a post-load hook to `define_standard_editor!`.
+ pub needs_fit: bool,
+ /// Last status message from a canvas operation (e.g. "Edited cell 5").
+ /// Shown in the inspector panel.
+ pub last_status: Option,
+ /// Resizable pane grid layout (canvas | inspector).
+ pub pane_state: Option>,
+ /// Last focused pane in the pane grid.
+ pub pane_focus: Option,
+ /// Per-cell, per-field edit buffers. Keyed by `(cell_index, field_name)`
+ /// so switching cells never carries a wrong "999" value.
+ pub edit_buffers: HashMap<(u32, String), String>,
+}
+
+/// Identifies which pane the user is looking at.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PaneContent {
+ /// The 2D canvas.
+ Canvas,
+ /// The inspector panel.
+ Inspector,
+}
+
+impl Default for TileGridViewState {
+ fn default() -> Self {
+ let (mut pane_state, canvas_pane) = pane_grid::State::new(PaneContent::Canvas);
+ // Split the canvas pane vertically to place the inspector below
+ // the canvas. In `pane_grid::Axis`, `Vertical` means the split
+ // line runs top-to-bottom, so the new pane appears beneath.
+ let (inspector_pane, split) = pane_state
+ .split(
+ pane_grid::Axis::Vertical,
+ canvas_pane,
+ PaneContent::Inspector,
+ )
+ .expect("split on fresh pane_grid should always succeed");
+ // Set the split so the inspector (bottom pane) takes ~30% of the
+ // vertical space, with the canvas taking the remaining ~70%.
+ // The ratio is the fraction of space given to the *first* child
+ // (the canvas), so 0.7 = 70% canvas, 30% inspector.
+ pane_state.resize(split, 0.7);
+ let _ = inspector_pane;
+ Self {
+ pan_x: 0.0,
+ pan_y: 0.0,
+ zoom: 1.0,
+ hovered_cell: None,
+ selected_cell: None,
+ last_canvas_w: 800.0,
+ last_canvas_h: 600.0,
+ grid_cache: canvas::Cache::new(),
+ needs_fit: true,
+ last_status: None,
+ pane_state: Some(pane_state),
+ pane_focus: None,
+ edit_buffers: HashMap::new(),
+ }
+ }
+}
+
+/// Rendered size of one cell in canvas pixels at zoom = 1.0.
+pub const CELL_SIZE: f32 = 8.0;
+/// Minimum zoom (cells become very small).
+pub const MIN_ZOOM: f32 = 0.25;
+/// Maximum zoom (cells become very large).
+pub const MAX_ZOOM: f32 = 8.0;
+/// Fraction of the full window width occupied by the canvas pane.
+/// The remaining fraction is taken by the inspector pane beside it.
+/// Must match the split ratio set in `Default`.
+pub const CANVAS_WIDTH_RATIO: f32 = 0.7;
+
+impl TileGridViewState {
+ /// Invalidate the grid cache (call when data, pan, or zoom changes).
+ pub fn invalidate_cache(&mut self) {
+ self.grid_cache = canvas::Cache::new();
+ }
+
+ /// Clamp zoom to `[MIN_ZOOM, MAX_ZOOM]`.
+ pub fn clamp_zoom(&mut self) {
+ self.zoom = self.zoom.clamp(MIN_ZOOM, MAX_ZOOM);
+ }
+
+ /// Convert canvas-local pixel coords to a flat cell index.
+ /// Returns `None` if the position is outside the grid.
+ pub fn pixel_to_cell(&self, cx: f32, cy: f32, grid_size: u32) -> Option {
+ let world_x = (cx - self.pan_x) / (CELL_SIZE * self.zoom);
+ let world_y = (cy - self.pan_y) / (CELL_SIZE * self.zoom);
+ if world_x < 0.0 || world_y < 0.0 {
+ return None;
+ }
+ let gx = world_x as u32;
+ let gy = world_y as u32;
+ if gx >= grid_size || gy >= grid_size {
+ return None;
+ }
+ Some(gy * grid_size + gx)
+ }
+
+ /// Convert a flat cell index to canvas-local pixel coords (top-left).
+ pub fn cell_to_pixel(&self, index: u32, grid_size: u32) -> (f32, f32) {
+ let gx = (index % grid_size) as f32;
+ let gy = (index / grid_size) as f32;
+ (
+ gx * CELL_SIZE * self.zoom + self.pan_x,
+ gy * CELL_SIZE * self.zoom + self.pan_y,
+ )
+ }
+}
diff --git a/dispel-gui/src/editors/tile_grid/tests.rs b/dispel-gui/src/editors/tile_grid/tests.rs
new file mode 100644
index 00000000..a4ab4ad5
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/tests.rs
@@ -0,0 +1,412 @@
+use super::message::TileGridCanvasMessage;
+use super::state::{CELL_SIZE, MAX_ZOOM, MIN_ZOOM, TileGridViewState};
+use dispel_core::references::tile_grid::{TileDescriptor, TileGrid};
+
+#[test]
+fn pixel_to_cell_origin() {
+ let v = TileGridViewState::default();
+ // At pan=0, zoom=1, pixel (0,0) → cell (0,0) → flat index 0
+ assert_eq!(v.pixel_to_cell(0.0, 0.0, 249), Some(0));
+ // Pixel (8,0) → cell (1,0) → flat index 1
+ assert_eq!(v.pixel_to_cell(CELL_SIZE, 0.0, 249), Some(1));
+ // Pixel (0,8) → cell (0,1) → flat index grid_size
+ assert_eq!(v.pixel_to_cell(0.0, CELL_SIZE, 249), Some(249));
+ // Pixel (8,8) → cell (1,1) → flat index 250
+ assert_eq!(v.pixel_to_cell(CELL_SIZE, CELL_SIZE, 249), Some(250));
+}
+
+#[test]
+fn pixel_to_cell_out_of_bounds() {
+ let v = TileGridViewState::default();
+ // Negative coords → None
+ assert_eq!(v.pixel_to_cell(-1.0, 0.0, 249), None);
+ assert_eq!(v.pixel_to_cell(0.0, -1.0, 249), None);
+ // Way past the grid → None
+ assert_eq!(v.pixel_to_cell(10000.0, 10000.0, 249), None);
+}
+
+#[test]
+fn pixel_to_cell_with_zoom() {
+ let mut v = TileGridViewState::default();
+ v.zoom = 2.0;
+ // At zoom=2, cell size is 16px. Pixel (16,0) → cell (1,0)
+ assert_eq!(v.pixel_to_cell(CELL_SIZE * 2.0, 0.0, 249), Some(1));
+ // Pixel (8,0) at zoom=2 → still cell (0,0)
+ assert_eq!(v.pixel_to_cell(CELL_SIZE, 0.0, 249), Some(0));
+}
+
+#[test]
+fn pixel_to_cell_with_pan() {
+ let mut v = TileGridViewState::default();
+ v.pan_x = -CELL_SIZE; // shifted left by 1 cell
+ // Pixel (0,0) with pan=-8 → world_x = (0 - (-8)) / 8 = 1 → cell (1,0)
+ assert_eq!(v.pixel_to_cell(0.0, 0.0, 249), Some(1));
+}
+
+#[test]
+fn cell_to_pixel_origin() {
+ let v = TileGridViewState::default();
+ let (x, y) = v.cell_to_pixel(0, 249);
+ assert_eq!(x, 0.0);
+ assert_eq!(y, 0.0);
+}
+
+#[test]
+fn cell_to_pixel_with_zoom() {
+ let mut v = TileGridViewState::default();
+ v.zoom = 2.0;
+ let (x, y) = v.cell_to_pixel(1, 249);
+ assert_eq!(x, CELL_SIZE * 2.0);
+ assert_eq!(y, 0.0);
+}
+
+#[test]
+fn cell_to_pixel_row() {
+ let v = TileGridViewState::default();
+ // Cell at row 1, col 0 in a 249-wide grid → flat index 249
+ let (x, y) = v.cell_to_pixel(249, 249);
+ assert_eq!(x, 0.0);
+ assert_eq!(y, CELL_SIZE);
+}
+
+#[test]
+fn clamp_zoom_bounds() {
+ let mut v = TileGridViewState::default();
+ v.zoom = 0.0;
+ v.clamp_zoom();
+ assert_eq!(v.zoom, MIN_ZOOM);
+
+ v.zoom = 100.0;
+ v.clamp_zoom();
+ assert_eq!(v.zoom, MAX_ZOOM);
+
+ v.zoom = 2.0;
+ v.clamp_zoom();
+ assert_eq!(v.zoom, 2.0);
+}
+
+#[test]
+fn pan_message_updates_state() {
+ let mut v = TileGridViewState::default();
+ let mut g = TileGrid::default();
+ let status = super::update::handle(TileGridCanvasMessage::Pan(10.0, 20.0), &mut v, &mut g);
+ assert_eq!(v.pan_x, 10.0);
+ assert_eq!(v.pan_y, 20.0);
+ assert!(status.is_none());
+}
+
+#[test]
+fn zoom_message_clamps() {
+ let mut v = TileGridViewState::default();
+ let mut g = TileGrid::default();
+ // Zoom in 10× (cursor at 0,0)
+ super::update::handle(TileGridCanvasMessage::Zoom(10.0, 0.0, 0.0), &mut v, &mut g);
+ assert_eq!(v.zoom, 10.0_f32.min(MAX_ZOOM));
+ // Zoom out 100× — should clamp to MIN_ZOOM
+ super::update::handle(TileGridCanvasMessage::Zoom(0.01, 0.0, 0.0), &mut v, &mut g);
+ assert_eq!(v.zoom, MIN_ZOOM);
+}
+
+#[test]
+fn zoom_to_cursor_keeps_world_point_stationary() {
+ // Before zoom, the cell under the cursor (100, 100) should still be
+ // under the cursor after zooming. The world cell at that pixel is:
+ // world_x = (cx - pan_x) / (CELL_SIZE * old_zoom)
+ // After zoom, pan must adjust so:
+ // world_x = (cx - pan_x_new) / (CELL_SIZE * new_zoom)
+ // remains the same.
+ let mut v = TileGridViewState {
+ pan_x: 0.0,
+ pan_y: 0.0,
+ zoom: 1.0,
+ ..Default::default()
+ };
+ let mut g = TileGrid {
+ grid_size: 249,
+ ..Default::default()
+ };
+ // Cursor at (100, 100), zoom in 2×
+ // old_zoom = 1.0, new_zoom = 2.0
+ // world_x = (100 - 0) / (8 * 1) = 12.5
+ // pan_x_new = 100 - 12.5 * 16 = 100 - 200 = -100
+ super::update::handle(
+ TileGridCanvasMessage::Zoom(2.0, 100.0, 100.0),
+ &mut v,
+ &mut g,
+ );
+ assert_eq!(v.zoom, 2.0);
+ assert!(
+ (v.pan_x - (-100.0)).abs() < 0.01,
+ "pan_x should be -100, got {}",
+ v.pan_x
+ );
+ assert!(
+ (v.pan_y - (-100.0)).abs() < 0.01,
+ "pan_y should be -100, got {}",
+ v.pan_y
+ );
+}
+
+#[test]
+fn zoom_to_cursor_with_existing_pan() {
+ // With an initial pan, zooming should still keep the world point
+ // under the cursor stationary.
+ let mut v = TileGridViewState {
+ pan_x: -50.0,
+ pan_y: -50.0,
+ zoom: 1.0,
+ ..Default::default()
+ };
+ let mut g = TileGrid {
+ grid_size: 249,
+ ..Default::default()
+ };
+ // Cursor at (150, 150), zoom in 2×
+ // world_x = (150 - (-50)) / 8 = 200/8 = 25.0
+ // pan_x_new = 150 - 25.0 * 16 = 150 - 400 = -250
+ super::update::handle(
+ TileGridCanvasMessage::Zoom(2.0, 150.0, 150.0),
+ &mut v,
+ &mut g,
+ );
+ assert_eq!(v.zoom, 2.0);
+ assert!(
+ (v.pan_x - (-250.0)).abs() < 0.01,
+ "pan_x should be -250, got {}",
+ v.pan_x
+ );
+ assert!(
+ (v.pan_y - (-250.0)).abs() < 0.01,
+ "pan_y should be -250, got {}",
+ v.pan_y
+ );
+}
+
+#[test]
+fn click_message_selects_cell() {
+ let mut v = TileGridViewState::default();
+ let mut g = TileGrid {
+ grid_size: 249,
+ ..Default::default()
+ };
+ // Click at pixel (16, 16) → cell (2, 2) → flat index 2*249+2 = 500
+ let status = super::update::handle(TileGridCanvasMessage::Click(16.0, 16.0), &mut v, &mut g);
+ assert_eq!(v.selected_cell, Some(500));
+ assert!(status.unwrap().contains("Selected cell"));
+}
+
+#[test]
+fn click_outside_grid_clears_selection() {
+ let mut v = TileGridViewState {
+ selected_cell: Some(42),
+ ..Default::default()
+ };
+ let mut g = TileGrid {
+ grid_size: 10,
+ ..Default::default()
+ };
+ let status = super::update::handle(
+ TileGridCanvasMessage::Click(10000.0, 10000.0),
+ &mut v,
+ &mut g,
+ );
+ assert_eq!(v.selected_cell, None);
+ assert!(status.is_none());
+}
+
+#[test]
+fn hover_message_sets_hovered_cell() {
+ let mut v = TileGridViewState::default();
+ let mut g = TileGrid {
+ grid_size: 249,
+ ..Default::default()
+ };
+ super::update::handle(TileGridCanvasMessage::Hover(0.0, 0.0), &mut v, &mut g);
+ assert_eq!(v.hovered_cell, Some(0));
+}
+
+#[test]
+fn edit_selected_writes_to_grid() {
+ let mut v = TileGridViewState {
+ selected_cell: Some(5),
+ ..Default::default()
+ };
+ let mut g = TileGrid {
+ grid_size: 10,
+ data: vec![0; 100],
+ ..Default::default()
+ };
+ let new_desc = TileDescriptor {
+ object_id: 42,
+ tile_index: 1337,
+ render_flags: 2,
+ reserved: 17,
+ };
+ let status = super::update::handle(
+ TileGridCanvasMessage::EditSelected(new_desc),
+ &mut v,
+ &mut g,
+ );
+ let expected = super::update::encode_descriptor(new_desc);
+ assert_eq!(g.data[5], expected);
+ assert!(status.unwrap().contains("Edited cell 5"));
+}
+
+#[test]
+fn edit_selected_without_selection_returns_error() {
+ let mut v = TileGridViewState::default();
+ let mut g = TileGrid::default();
+ let status = super::update::handle(
+ TileGridCanvasMessage::EditSelected(TileDescriptor {
+ object_id: 1,
+ tile_index: 0,
+ render_flags: 0,
+ reserved: 0,
+ }),
+ &mut v,
+ &mut g,
+ );
+ assert!(status.unwrap().contains("No cell selected"));
+}
+
+#[test]
+fn encode_descriptor_roundtrip() {
+ // Encode then decode should recover the same fields
+ let d = TileDescriptor {
+ object_id: 0x03ff, // max 10-bit
+ tile_index: 0x7fff, // max 15-bit
+ render_flags: 0x03, // max 2-bit
+ reserved: 0x1f, // max 5-bit
+ };
+ let word = super::update::encode_descriptor(d);
+ let decoded = TileDescriptor::from_word(word);
+ assert_eq!(decoded.object_id, d.object_id);
+ assert_eq!(decoded.tile_index, d.tile_index);
+ assert_eq!(decoded.render_flags, d.render_flags);
+ assert_eq!(decoded.reserved, d.reserved);
+}
+
+#[test]
+fn encode_descriptor_zero() {
+ let d = TileDescriptor {
+ object_id: 0,
+ tile_index: 0,
+ render_flags: 0,
+ reserved: 0,
+ };
+ assert_eq!(super::update::encode_descriptor(d), 0);
+}
+
+#[test]
+fn encode_descriptor_packing() {
+ // object_id in bits 0-9, tile_index in bits 10-24, render_flags in bits 25-26, reserved in bits 27-31
+ let d = TileDescriptor {
+ object_id: 1,
+ tile_index: 0,
+ render_flags: 0,
+ reserved: 0,
+ };
+ assert_eq!(super::update::encode_descriptor(d), 1);
+
+ let d = TileDescriptor {
+ object_id: 0,
+ tile_index: 1,
+ render_flags: 0,
+ reserved: 0,
+ };
+ assert_eq!(super::update::encode_descriptor(d), 1 << 10);
+
+ let d = TileDescriptor {
+ object_id: 0,
+ tile_index: 0,
+ render_flags: 1,
+ reserved: 0,
+ };
+ assert_eq!(super::update::encode_descriptor(d), 1 << 25);
+
+ let d = TileDescriptor {
+ object_id: 0,
+ tile_index: 0,
+ render_flags: 0,
+ reserved: 1,
+ };
+ assert_eq!(super::update::encode_descriptor(d), 1 << 27);
+}
+
+#[test]
+fn fit_to_window_computes_correct_zoom() {
+ let mut v = TileGridViewState {
+ last_canvas_w: 800.0,
+ last_canvas_h: 600.0,
+ ..Default::default()
+ };
+ let mut g = TileGrid {
+ grid_size: 249,
+ ..Default::default()
+ };
+ super::update::handle(TileGridCanvasMessage::FitToWindow, &mut v, &mut g);
+ // Grid is 249 * 8 = 1992px. Canvas is 800×600.
+ // With CANVAS_WIDTH_RATIO=0.7: avail_w = 800*0.7 = 560
+ // zoom_x = 560/1992 ≈ 0.281, zoom_y = 600/1992 ≈ 0.301
+ // min = 0.281
+ assert!((v.zoom - 0.281).abs() < 0.01);
+ // pan should center: (600 - 1992*0.281) / 2 ≈ 20
+ assert!(v.pan_y.abs() < 25.0);
+ assert!(v.needs_fit == false);
+}
+
+#[test]
+fn fit_to_window_respects_canvas_width_ratio() {
+ // When the window is 1000×1000 but the canvas pane only takes
+ // CANVAS_WIDTH_RATIO (0.7) of the width, the fit zoom should be
+ // computed against 700px, not 1000px.
+ let mut v = TileGridViewState {
+ last_canvas_w: 1000.0,
+ last_canvas_h: 1000.0,
+ ..Default::default()
+ };
+ let mut g = TileGrid {
+ grid_size: 249,
+ ..Default::default()
+ };
+ super::update::handle(TileGridCanvasMessage::FitToWindow, &mut v, &mut g);
+ // Grid is 249 * 8 = 1992px. Canvas pane: 1000*0.7 = 700px wide.
+ // zoom_x = 700/1992 ≈ 0.351, zoom_y = 1000/1992 ≈ 0.502
+ // min = 0.351
+ assert!((v.zoom - 0.351).abs() < 0.01);
+ // pan_x should center against avail_w (700), not full window (1000)
+ let avail_w = 1000.0 * 0.7;
+ let expected_pan_x = (avail_w - 1992.0 * v.zoom) * 0.5;
+ assert!((v.pan_x - expected_pan_x).abs() < 0.01);
+}
+
+#[test]
+fn clear_selection_works() {
+ let mut v = TileGridViewState {
+ selected_cell: Some(10),
+ ..Default::default()
+ };
+ let mut g = TileGrid::default();
+ super::update::handle(TileGridCanvasMessage::ClearSelection, &mut v, &mut g);
+ assert_eq!(v.selected_cell, None);
+}
+
+#[test]
+fn resize_updates_dimensions() {
+ let mut v = TileGridViewState::default();
+ let mut g = TileGrid::default();
+ super::update::handle(TileGridCanvasMessage::Resize(1024.0, 768.0), &mut v, &mut g);
+ assert_eq!(v.last_canvas_w, 1024.0);
+ assert_eq!(v.last_canvas_h, 768.0);
+}
+
+#[test]
+fn default_state_is_safe() {
+ let v = TileGridViewState::default();
+ assert_eq!(v.zoom, 1.0);
+ assert_eq!(v.pan_x, 0.0);
+ assert_eq!(v.pan_y, 0.0);
+ assert!(v.selected_cell.is_none());
+ assert!(v.hovered_cell.is_none());
+ assert!(v.needs_fit);
+}
diff --git a/dispel-gui/src/editors/tile_grid/update.rs b/dispel-gui/src/editors/tile_grid/update.rs
new file mode 100644
index 00000000..83526cc9
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/update.rs
@@ -0,0 +1,205 @@
+//! Update handler for the tile grid canvas view.
+
+use super::message::TileGridCanvasMessage;
+use super::state::TileGridViewState;
+use dispel_core::references::tile_grid::{TileDescriptor, TileGrid};
+
+/// Handle a `TileGridCanvasMessage`. Returns a status string for the
+/// inspector panel (e.g. "Selected cell (42, 17)") or `None` for silent ops.
+pub fn handle(
+ msg: TileGridCanvasMessage,
+ view: &mut TileGridViewState,
+ grid: &mut TileGrid,
+) -> Option {
+ let status = handle_inner(msg, view, grid);
+ if let Some(ref s) = status {
+ view.last_status = Some(s.clone());
+ }
+ status
+}
+
+fn handle_inner(
+ msg: TileGridCanvasMessage,
+ view: &mut TileGridViewState,
+ grid: &mut TileGrid,
+) -> Option {
+ match msg {
+ TileGridCanvasMessage::Pan(dx, dy) => {
+ view.pan_x += dx;
+ view.pan_y += dy;
+ view.invalidate_cache();
+ None
+ }
+ TileGridCanvasMessage::Zoom(factor, cx, cy) => {
+ let old_zoom = view.zoom;
+ let new_zoom =
+ (view.zoom * factor).clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM);
+ if (new_zoom - old_zoom).abs() > f32::EPSILON {
+ // Zoom-to-cursor: keep the world point under (cx, cy) stationary.
+ // world_x = (cx - pan_x) / (CELL_SIZE * old_zoom)
+ // pan_x_new = cx - world_x * (CELL_SIZE * new_zoom)
+ let cell_old = super::state::CELL_SIZE * old_zoom;
+ let cell_new = super::state::CELL_SIZE * new_zoom;
+ if cell_old > 0.0 {
+ let world_x = (cx - view.pan_x) / cell_old;
+ let world_y = (cy - view.pan_y) / cell_old;
+ view.pan_x = cx - world_x * cell_new;
+ view.pan_y = cy - world_y * cell_new;
+ }
+ view.zoom = new_zoom;
+ view.invalidate_cache();
+ }
+ None
+ }
+ TileGridCanvasMessage::SetZoom(z) => {
+ view.zoom = z.clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM);
+ view.invalidate_cache();
+ Some(format!("Zoom set to {:.2}×", view.zoom))
+ }
+ TileGridCanvasMessage::Click(cx, cy) => {
+ let gs = grid.grid_size;
+ if let Some(idx) = view.pixel_to_cell(cx, cy, gs) {
+ view.selected_cell = Some(idx);
+ let gx = idx % gs;
+ let gy = idx / gs;
+ return Some(format!("Selected cell ({gx}, {gy})"));
+ }
+ view.selected_cell = None;
+ None
+ }
+ TileGridCanvasMessage::Hover(cx, cy) => {
+ let gs = grid.grid_size;
+ view.hovered_cell = view.pixel_to_cell(cx, cy, gs);
+ None
+ }
+ TileGridCanvasMessage::Resize(w, h) => {
+ let was_default = view.last_canvas_w == 0.0 || view.last_canvas_w == 800.0;
+ view.last_canvas_w = w;
+ view.last_canvas_h = h;
+ view.invalidate_cache();
+ // Auto-fit on first real resize if we still need to fit.
+ if was_default && view.needs_fit && w > 0.0 && h > 0.0 {
+ return handle_inner(TileGridCanvasMessage::FitToWindow, view, grid);
+ }
+ None
+ }
+ TileGridCanvasMessage::FitToWindow => {
+ // If dimensions are still defaults, use a reasonable fallback
+ if view.last_canvas_w <= 0.0 || view.last_canvas_h <= 0.0 {
+ view.last_canvas_w = 800.0;
+ view.last_canvas_h = 600.0;
+ }
+ // The canvas pane doesn't fill the full window — the inspector
+ // takes the remaining fraction. Scale the available width/height
+ // by `CANVAS_WIDTH_RATIO` before computing the fit zoom.
+ let avail_w = view.last_canvas_w * super::state::CANVAS_WIDTH_RATIO;
+ let avail_h = view.last_canvas_h;
+ let gs = grid.grid_size as f32;
+ let cell_total = gs * super::state::CELL_SIZE;
+ let zoom_x = avail_w / cell_total;
+ let zoom_y = avail_h / cell_total;
+ view.zoom = zoom_x
+ .min(zoom_y)
+ .clamp(super::state::MIN_ZOOM, super::state::MAX_ZOOM);
+ view.pan_x = (avail_w - cell_total * view.zoom) * 0.5;
+ view.pan_y = (avail_h - cell_total * view.zoom) * 0.5;
+ view.needs_fit = false;
+ view.invalidate_cache();
+ Some(format!("Fit to window: {:.2}×", view.zoom))
+ }
+ TileGridCanvasMessage::EditSelected(new_desc) => {
+ if let Some(idx) = view.selected_cell {
+ let word = encode_descriptor(new_desc);
+ if (idx as usize) < grid.data.len() {
+ // TODO(bug: no-edit-history): EditSelected mutates grid.data
+ // directly without recording in EditHistory, so undo/redo
+ // won't work for canvas edits. Need to push a
+ // (cell_index, old_word, new_word) entry to the standard
+ // editor's EditHistory before mutating.
+ grid.data[idx as usize] = word;
+ // Keep synthetic metadata in sync — payload_length and grid_size
+ // are not on-disk fields for `.dat` but are written inside to_writer.
+ let new_len = grid.data.len() as u32 * 4;
+ grid.payload_length = new_len;
+ grid.grid_size = (grid.data.len() as f64).sqrt() as u32;
+ view.invalidate_cache();
+ return Some(format!("Edited cell {idx}"));
+ }
+ return Some(format!(
+ "Cell {idx} out of range (grid has {})",
+ grid.data.len()
+ ));
+ }
+ Some("No cell selected".to_string())
+ }
+ TileGridCanvasMessage::ClearSelection => {
+ view.selected_cell = None;
+ Some("Selection cleared".to_string())
+ }
+ TileGridCanvasMessage::PaneResized(event) => {
+ if let Some(ref mut ps) = view.pane_state {
+ ps.resize(event.split, event.ratio);
+ }
+ None
+ }
+ TileGridCanvasMessage::PaneClicked(pane) => {
+ view.pane_focus = Some(pane);
+ None
+ }
+ TileGridCanvasMessage::EditFieldBuffer { field, value } => {
+ if let Some(idx) = view.selected_cell {
+ view.edit_buffers.insert((idx, field.clone()), value);
+ }
+ None
+ }
+ TileGridCanvasMessage::CommitFieldBuffer { field } => {
+ if let Some(idx) = view.selected_cell {
+ let buf_opt = view.edit_buffers.remove(&(idx, field.clone()));
+ if buf_opt.is_none() {
+ return Some(format!("No edit buffer for '{field}' on cell {idx}"));
+ }
+ let buf = buf_opt.unwrap();
+ if buf.trim().is_empty() {
+ return Some("Empty value — nothing to commit".to_string());
+ }
+ if let Ok(v) = buf.parse::()
+ && let Some(word) = grid.data.get(idx as usize).copied()
+ {
+ let mut desc = TileDescriptor::from_word(word);
+ apply_field_to_desc(&mut desc, &field, v);
+ if (idx as usize) < grid.data.len() {
+ let old_word = grid.data[idx as usize];
+ let new_word = encode_descriptor(desc);
+ if new_word != old_word {
+ grid.data[idx as usize] = new_word;
+ view.invalidate_cache();
+ }
+ }
+ return Some(format!("Updated '{field}' to {v}"));
+ }
+ }
+ None
+ }
+ }
+}
+
+/// Apply a clamped value to the correct field of a `TileDescriptor` based on the field name.
+fn apply_field_to_desc(d: &mut TileDescriptor, field: &str, v: i32) {
+ if field == "object_id" {
+ d.object_id = v.clamp(0, 1023) as u16;
+ } else if field == "tile_index" {
+ d.tile_index = v.clamp(0, 32767) as u16;
+ } else if field == "render_flags" {
+ d.render_flags = v.clamp(0, 3) as u8;
+ } else if field == "reserved" {
+ d.reserved = v.clamp(0, 31) as u8;
+ }
+}
+
+/// Pack a `TileDescriptor` back into a `u32` word using the file's bit layout.
+pub fn encode_descriptor(d: TileDescriptor) -> u32 {
+ (d.object_id as u32 & 0x03ff)
+ | ((d.tile_index as u32 & 0x7fff) << 10)
+ | ((d.render_flags as u32 & 0x03) << 25)
+ | ((d.reserved as u32 & 0x1f) << 27)
+}
diff --git a/dispel-gui/src/editors/tile_grid/view.rs b/dispel-gui/src/editors/tile_grid/view.rs
new file mode 100644
index 00000000..d2b70547
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid/view.rs
@@ -0,0 +1,151 @@
+//! View rendering for the tile grid canvas view.
+
+use super::canvas::TileGridCanvas;
+use super::message::TileGridCanvasMessage;
+use super::state::{PaneContent, TileGridViewState};
+use crate::message::{Message, MessageExt};
+use dispel_core::references::tile_grid::{TileDescriptor, TileGrid};
+use iced::widget::{button, canvas, column, pane_grid, row, scrollable, text, text_input};
+use iced::{Element, Length};
+
+/// Render the tile grid canvas view: a resizable pane grid with the canvas
+/// on the left and an inspector panel on the right.
+pub fn view_canvas<'a>(grid: &'a TileGrid, view: &'a TileGridViewState) -> Element<'a, Message> {
+ // The pane_grid widget needs a reference to a State. We fall back to
+ // a transient local State if the persistent one is missing — the user
+ // won't be able to resize in that case, but the layout still renders.
+ let pane_state_ref: &pane_grid::State = match view.pane_state.as_ref() {
+ Some(s) => s,
+ None => {
+ // Leak a static State so the reference is valid for 'a.
+ Box::leak(Box::new(pane_grid::State::new(PaneContent::Canvas).0))
+ }
+ };
+
+ pane_grid::PaneGrid::new(pane_state_ref, |_pane, content, _is_maximized| {
+ let inner: Element = match content {
+ PaneContent::Canvas => canvas(TileGridCanvas { grid, view })
+ .width(Length::Fill)
+ .height(Length::Fill)
+ .into(),
+ PaneContent::Inspector => {
+ let inspector = inspector_panel(grid, view);
+ scrollable(inspector)
+ .width(Length::Fill)
+ .height(Length::Fill)
+ .into()
+ }
+ };
+ // Wrap in `pane_grid::Content` so the pane_grid forwards mouse
+ // events (ButtonPressed/CursorMoved/WheelScrolled) to the child
+ // instead of capturing them for resize-handle logic. Content::new
+ // already provides a container with `Length::Fill` sizing, so we
+ // must NOT wrap the inner element in another container — double
+ // wrapping collapses the layout to zero size.
+ pane_grid::Content::new(inner)
+ })
+ .on_resize(10, |event| {
+ Message::tile_grid_canvas(TileGridCanvasMessage::PaneResized(event))
+ })
+ .on_click(|pane| Message::tile_grid_canvas(TileGridCanvasMessage::PaneClicked(pane)))
+ .into()
+}
+
+fn inspector_panel<'a>(grid: &'a TileGrid, view: &'a TileGridViewState) -> Element<'a, Message> {
+ let mut col = column![text("Inspector").size(18)].spacing(6).padding(10);
+
+ if let Some(idx) = view.selected_cell {
+ let gx = idx % grid.grid_size;
+ let gy = idx / grid.grid_size;
+ let desc = grid
+ .data
+ .get(idx as usize)
+ .copied()
+ .map(TileDescriptor::from_word)
+ .unwrap_or(TileDescriptor {
+ object_id: 0,
+ tile_index: 0,
+ render_flags: 0,
+ reserved: 0,
+ });
+
+ col = col
+ .push(text(format!("Cell index: {idx}")))
+ .push(text(format!("Grid coords: ({gx}, {gy})")))
+ .push(text(format!("Raw word: 0x{:08X}", word_from_desc(desc))));
+
+ let fields: [(&str, &str, i32); 4] = [
+ ("object_id", "Object ID (0–1023):", desc.object_id as i32),
+ (
+ "tile_index",
+ "Tile Index (0–32767):",
+ desc.tile_index as i32,
+ ),
+ (
+ "render_flags",
+ "Render Flags (0–3):",
+ desc.render_flags as i32,
+ ),
+ ("reserved", "Reserved (0–31):", desc.reserved as i32),
+ ];
+
+ for (field, label, value) in fields {
+ // Use the edit buffer if present, else the live value.
+ // This prevents the "clamped value mid-edit" bug: typing "999"
+ // shows "999" in the field, not "1023" until the user commits.
+ let display = if let Some(idx) = view.selected_cell {
+ view.edit_buffers
+ .get(&(idx, field.to_string()))
+ .cloned()
+ .unwrap_or_else(|| value.to_string())
+ } else {
+ value.to_string()
+ };
+ col = col.push(text(label).size(12));
+ col = col.push(
+ text_input("", display)
+ .on_input(move |s| {
+ Message::tile_grid_canvas(TileGridCanvasMessage::EditFieldBuffer {
+ field: field.to_string(),
+ value: s,
+ })
+ })
+ .on_submit(Message::tile_grid_canvas(
+ TileGridCanvasMessage::CommitFieldBuffer {
+ field: field.to_string(),
+ },
+ ))
+ .width(Length::Fill),
+ );
+ }
+ } else {
+ col = col.push(text("No cell selected. Click a cell to inspect."));
+ }
+
+ if let Some(s) = view.last_status.as_deref() {
+ col = col.push(text(s).size(12));
+ }
+
+ col = col
+ .push(text(format!("Zoom: {:.2}×", view.zoom)).size(12))
+ .push(
+ row![
+ button("Fit").on_press(Message::tile_grid_canvas(
+ TileGridCanvasMessage::FitToWindow
+ )),
+ button("1:1").on_press(Message::tile_grid_canvas(TileGridCanvasMessage::SetZoom(
+ 1.0
+ ))),
+ ]
+ .spacing(4),
+ );
+
+ col.into()
+}
+
+fn word_from_desc(d: TileDescriptor) -> u32 {
+ (d.object_id as u32 & 0x03ff)
+ | ((d.tile_index as u32 & 0x7fff) << 10)
+ | ((d.render_flags as u32 & 0x03) << 25)
+ | ((d.reserved as u32 & 0x1f) << 27)
+}
diff --git a/dispel-gui/src/editors/tile_grid_canvas/mod.rs b/dispel-gui/src/editors/tile_grid_canvas/mod.rs
new file mode 100644
index 00000000..5a8f4f8a
--- /dev/null
+++ b/dispel-gui/src/editors/tile_grid_canvas/mod.rs
@@ -0,0 +1,29 @@
+//! Dispatch wrapper for tile grid canvas messages and view.
+//!
+//! This module exists so the `define_update_dispatch!` and
+//! `define_view_dispatch!` macros can route `EditorMessage::TileGridCanvas`
+//! and `EditorType::TileGridEditor` to the canvas-based handler/view
+//! without colliding with the standard spreadsheet functions generated by
+//! `define_standard_editor!` for `StandardEditorMessage`.
+
+use crate::editors::tile_grid::{self, TileGridCanvasMessage, TileGridViewState};
+use crate::message::Message;
+use iced::Task;
+
+pub fn handle(msg: TileGridCanvasMessage, app: &mut crate::app::App) -> Task {
+ tile_grid::handle_canvas_message(msg, app)
+}
+
+/// Render the tile grid canvas view. Reads the loaded grid from
+/// `tile_grid_editor` and the pan/zoom state from `tile_grid_view`.
+pub fn view(app: &crate::app::App) -> iced::Element<'_, Message> {
+ let view: &TileGridViewState = &app.state.editors.tile_grid_view;
+ if let Some(catalog) = app.state.editors.tile_grid_editor.state.catalog.as_ref()
+ && let Some(grid) = catalog.first()
+ {
+ return tile_grid::view::view_canvas(grid, view);
+ }
+ // Fallback: show a "no grid loaded" message
+ use iced::widget::{center, text};
+ center(text("No tile grid loaded. Open a .dat file from the file tree.").size(18)).into()
+}
diff --git a/dispel-gui/src/message/editor/mod.rs b/dispel-gui/src/message/editor/mod.rs
index 70dfcfac..2da48730 100644
--- a/dispel-gui/src/message/editor/mod.rs
+++ b/dispel-gui/src/message/editor/mod.rs
@@ -15,6 +15,7 @@ use crate::editors::event_npc_ref::EventNpcRefEditorMessage;
use crate::editors::event_scr::EventScrEditorMessage;
use crate::editors::extra_ini::ExtraIniEditorMessage;
use crate::editors::extra_ref::ExtraRefEditorMessage;
+use crate::editors::fog_data::FogDataMessage;
use crate::editors::heal_item::HealItemEditorMessage;
use crate::editors::localization_manager::LocalizationMessage;
use crate::editors::magic::MagicEditorMessage;
@@ -33,11 +34,19 @@ use crate::editors::party_level_db::PartyLevelDbEditorMessage;
use crate::editors::party_ref::PartyRefEditorMessage;
use crate::editors::quest_scr::QuestScrEditorMessage;
use crate::editors::save_file_viewer::SaveFileViewerMessage;
+use crate::editors::save_ifo::SaveIfoEditorMessage;
use crate::editors::snf_editor::SnfEditorMessage;
use crate::editors::sprite_editor::SpriteViewerMessage;
use crate::editors::store::StoreEditorMessage;
use crate::editors::tileset::TilesetEditorMessage;
use crate::editors::wave_ini::WaveIniEditorMessage;
+
+use crate::editors::chest_spawn::ChestSpawnEditorMessage;
+use crate::editors::map_info_multi::MapInfoMultiEditorMessage;
+use crate::editors::monster_spawn::MonsterSpawnEditorMessage;
+use crate::editors::tile_grid::TileGridCanvasMessage;
+use crate::editors::tile_grid::TileGridEditorMessage;
+
use crate::editors::weapon::WeaponEditorMessage;
use hexedit::HexEditorMessage;
@@ -80,5 +89,12 @@ pub enum EditorMessage {
ModPackager(ModPackagerMessage),
Localization(LocalizationMessage),
SaveFileViewer(SaveFileViewerMessage),
+ SaveIfo(SaveIfoEditorMessage),
+ FogData(FogDataMessage),
HexEditor(HexEditorMessage),
+ ChestSpawn(ChestSpawnEditorMessage),
+ MonsterSpawn(MonsterSpawnEditorMessage),
+ MapInfoMulti(MapInfoMultiEditorMessage),
+ TileGrid(TileGridEditorMessage),
+ TileGridCanvas(TileGridCanvasMessage),
}
diff --git a/dispel-gui/src/message/ext.rs b/dispel-gui/src/message/ext.rs
index 0d6e8bf9..808f2c8b 100644
--- a/dispel-gui/src/message/ext.rs
+++ b/dispel-gui/src/message/ext.rs
@@ -39,6 +39,13 @@ define_message_ext! {
localization: Localization(crate::editors::localization_manager::LocalizationMessage),
hex_editor: HexEditor(hexedit::HexEditorMessage),
save_file_viewer: SaveFileViewer(crate::editors::save_file_viewer::SaveFileViewerMessage),
+ save_ifo: SaveIfo(crate::editors::save_ifo::SaveIfoEditorMessage),
+ fog_data: FogData(crate::editors::fog_data::FogDataMessage),
+ chest_spawn: ChestSpawn(crate::editors::chest_spawn::ChestSpawnEditorMessage),
+ monster_spawn: MonsterSpawn(crate::editors::monster_spawn::MonsterSpawnEditorMessage),
+ map_info_multi: MapInfoMulti(crate::editors::map_info_multi::MapInfoMultiEditorMessage),
+ tile_grid: TileGrid(crate::editors::tile_grid::TileGridEditorMessage),
+ tile_grid_canvas: TileGridCanvas(crate::editors::tile_grid::TileGridCanvasMessage),
}
/// Extension trait for building nested editor messages.
diff --git a/dispel-gui/src/recording_tests.rs b/dispel-gui/src/recording_tests.rs
index a802a23d..b6853e53 100644
--- a/dispel-gui/src/recording_tests.rs
+++ b/dispel-gui/src/recording_tests.rs
@@ -1128,3 +1128,135 @@ fn party_level_db_no_recording_when_no_npc_selected() {
"party_level_db should NOT record when no NPC selected"
);
}
+
+// ============================================================================
+// fog_data — whole-file recorder (save-time `record_file_replace`)
+// ============================================================================
+
+/// Fixture fade-table path, or `None` when fixtures are absent.
+fn fixture_fogdata_path() -> Option {
+ let p = PathBuf::from("../fixtures/Dispel/ExtraInGame/fogdata.dat");
+ p.exists().then_some(p)
+}
+
+#[test]
+fn fog_data_editor_records_file_replace_on_save_when_session_active() {
+ use crate::editors::fog_data::{self, FogDataEditorState};
+ use dispel_core::map::fogdata::MAX_FACTOR;
+ use dispel_core::modding::{ModManifest, Workspace as ModWorkspace};
+
+ let Some(fixture) = fixture_fogdata_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+
+ // Temp mod workspace + temp "game dir" holding a copy of the fixture.
+ let base = std::env::temp_dir().join(format!(
+ "dispel-fog-rec-{}-{}",
+ std::process::id(),
+ fixture.file_name().map(|n| n.len()).unwrap_or(0)
+ ));
+ let ws_root = base.join("ws");
+ let game_dir = base.join("game");
+ std::fs::create_dir_all(game_dir.join("ExtraInGame")).expect("game dir created");
+ let fog_path = game_dir.join("ExtraInGame/fogdata.dat");
+ std::fs::copy(&fixture, &fog_path).expect("fixture copied");
+
+ let ws = ModWorkspace::open(ws_root.clone()).expect("workspace opened");
+ let slug = ws
+ .create_mod(ModManifest::new("fog recording test"))
+ .expect("mod created");
+
+ // Load the copy and dirty it with one edit.
+ let mut state = FogDataEditorState::load_from_path(&fog_path);
+ assert!(state.error.is_none(), "fixture must parse cleanly");
+ assert!(state.paint_factor(0, MAX_FACTOR), "edit applied");
+
+ // Save through the real async path with recording params wired up,
+ // mirroring what `FogDataMessage::Save` dispatches.
+ let params = fog_data::RecordingParams {
+ workspace_root: ws_root.clone(),
+ game_path: game_dir.clone(),
+ mod_slug: slug.clone(),
+ relative_path: "ExtraInGame/fogdata.dat".into(),
+ };
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let outcome = rt.block_on(fog_data::save_fog_data(
+ fog_path.clone(),
+ state.fog.clone(),
+ Some(params),
+ ));
+
+ let (status, recording_error) = outcome.expect("save succeeded");
+ assert!(
+ status.contains("Saved"),
+ "status reports the save: {status}"
+ );
+ assert!(
+ recording_error.is_none(),
+ "recording must succeed: {recording_error:?}"
+ );
+
+ // The changelog must now contain a recorded change for fogdata.dat.
+ let pkg = ws.read_mod(&slug).expect("mod readable");
+ assert!(
+ !pkg.changes.is_empty(),
+ "save recorded a change into the mod changelog"
+ );
+ assert!(
+ pkg.changes
+ .actions()
+ .iter()
+ .any(|a| a.file_path == "ExtraInGame/fogdata.dat"),
+ "recorded action targets fogdata.dat"
+ );
+
+ let _ = std::fs::remove_dir_all(&base);
+}
+
+#[test]
+fn fog_data_editor_save_without_session_writes_file_only() {
+ use crate::editors::fog_data::{self, FogDataEditorState};
+ use dispel_core::map::fogdata::MAX_FACTOR;
+
+ let Some(fixture) = fixture_fogdata_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+
+ let base = std::env::temp_dir().join(format!("dispel-fog-norec-{}", std::process::id()));
+ let game_dir = base.join("game");
+ std::fs::create_dir_all(game_dir.join("ExtraInGame")).expect("game dir created");
+ let fog_path = game_dir.join("ExtraInGame/fogdata.dat");
+ std::fs::copy(&fixture, &fog_path).expect("fixture copied");
+
+ let mut state = FogDataEditorState::load_from_path(&fog_path);
+ assert!(state.paint_factor(1, MAX_FACTOR), "edit applied");
+
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let outcome = rt.block_on(fog_data::save_fog_data(
+ fog_path.clone(),
+ state.fog.clone(),
+ None, // no recording session active
+ ));
+ let (status, recording_error) = outcome.expect("save succeeded");
+ assert!(status.contains("Saved"));
+ assert!(recording_error.is_none());
+ // No session → no recording side effects; only the file write happened.
+ let mut round_trip = std::env::temp_dir().join("dispel-fog-norec-check.dat");
+ state
+ .fog
+ .as_ref()
+ .unwrap()
+ .save_file(&round_trip)
+ .expect("re-encode ok");
+ assert_eq!(
+ std::fs::read(&fog_path).unwrap(),
+ std::fs::read(&round_trip).unwrap(),
+ "edited table persisted to disk"
+ );
+ let _ = std::fs::remove_file(&round_trip);
+ round_trip.pop();
+
+ let _ = std::fs::remove_dir_all(&base);
+}
diff --git a/dispel-gui/src/tests/capability_crosscheck.rs b/dispel-gui/src/tests/capability_crosscheck.rs
index 49dde0e0..42063916 100644
--- a/dispel-gui/src/tests/capability_crosscheck.rs
+++ b/dispel-gui/src/tests/capability_crosscheck.rs
@@ -46,6 +46,7 @@ mod crosscheck {
EditorType::ModPackager,
EditorType::LocalizationManager,
EditorType::HexEditor,
+ EditorType::FogDataEditor,
EditorType::Unknown,
]
}
diff --git a/dispel-gui/src/tests/fog_data.rs b/dispel-gui/src/tests/fog_data.rs
new file mode 100644
index 00000000..a98ad703
--- /dev/null
+++ b/dispel-gui/src/tests/fog_data.rs
@@ -0,0 +1,560 @@
+//! State-level integration tests for the fog data editor
+//! (`ExtraInGame/fogdata.dat`).
+//!
+//! Fixture convention: `../fixtures/Dispel`, gracefully skipped when missing
+//! (same as `map_editor.rs` / `update/map.rs`).
+
+#[cfg(test)]
+mod fog_data_editor_tests {
+ use crate::app::App;
+ use crate::editors::fog_data::{FogDataEditorState, FogDataMessage, handle};
+ use crate::message::Message;
+ use crate::message::system::SystemMessage;
+ use crate::tests::app_with_tab;
+ use crate::workspace::{EditorType, Workspace};
+ use dispel_core::map::fogdata::{MAX_FACTOR, ROWS};
+ use std::path::PathBuf;
+
+ const TAB: usize = 1; // matches app_with_tab's tab id
+
+ /// Fixture fade-table path, or `None` when fixtures are absent.
+ fn fixture_fogdata_path() -> Option {
+ let p = PathBuf::from("../fixtures/Dispel/ExtraInGame/fogdata.dat");
+ p.exists().then_some(p)
+ }
+
+ /// An App whose active tab is a FogDataEditor with the fixture loaded.
+ fn app_with_fixture() -> Option {
+ let mut app = app_with_tab(EditorType::FogDataEditor);
+ let path = fixture_fogdata_path()?;
+ app.state
+ .editors
+ .fog_editors
+ .insert(TAB, FogDataEditorState::load_from_path(&path));
+ Some(app)
+ }
+
+ fn editor_of(app: &App) -> &FogDataEditorState {
+ app.state.editors.fog_editors.get(&TAB).unwrap()
+ }
+
+ fn run(app: &mut App, msg: FogDataMessage) {
+ let _ = handle(msg, app);
+ }
+
+ // ── Open + parse ──────────────────────────────────────────────────────
+
+ #[test]
+ fn test_open_fixture_parses_levels_and_spot_factors() {
+ let Some(app) = app_with_fixture() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+ let editor = editor_of(&app);
+ assert!(editor.error.is_none(), "fixture must parse cleanly");
+ assert!(editor.fog.is_some(), "fade tables present");
+ assert_eq!(editor.level_count(), ROWS, "123 light levels");
+ assert_eq!(editor.current_row().len(), 512, "512 samples per level");
+ // Known values from the shipped file.
+ assert_eq!(
+ editor.fog.as_ref().unwrap().factor(1, 0),
+ 2,
+ "level 1 starts near-black"
+ );
+ assert_eq!(
+ editor.fog.as_ref().unwrap().factor(65, 256),
+ 31,
+ "level 65 mid-curve fully bright"
+ );
+ assert!(!editor.dirty, "freshly opened is clean");
+ }
+
+ #[test]
+ fn test_from_path_routes_dat_fogdata_to_editor() {
+ let p = PathBuf::from("/game/ExtraInGame/fogdata.dat");
+ assert_eq!(EditorType::from_path(&p), EditorType::FogDataEditor);
+ }
+
+ #[test]
+ fn test_open_missing_file_surfaces_error_without_panic() {
+ let state = FogDataEditorState::load_from_path(std::path::Path::new(
+ "/definitely/not/a/real/fogdata.dat",
+ ));
+ assert!(state.error.is_some(), "missing file → error surface");
+ assert!(state.fog.is_none());
+ // The view must render the error surface without panicking.
+ let mut app = app_with_tab(EditorType::FogDataEditor);
+ app.state.editors.fog_editors.insert(TAB, state);
+ let _ = app.view();
+ }
+
+ // ── Painting + dirty flag ─────────────────────────────────────────────
+
+ #[test]
+ fn test_paint_updates_state_dirty_and_tab_flag() {
+ let mut app = app_with_fixture().expect("fixture available");
+ let before = editor_of(&app).selected_factor();
+
+ run(
+ &mut app,
+ FogDataMessage::FactorPainted(TAB, 100, MAX_FACTOR),
+ );
+
+ let editor = editor_of(&app);
+ assert_eq!(editor.selected_pair, 100, "painting selects the pair");
+ assert_eq!(
+ editor.selected_factor(),
+ Some(MAX_FACTOR),
+ "painted value applied"
+ );
+ assert_ne!(editor.selected_factor(), before, "value actually changed");
+ assert!(editor.dirty, "paint marks dirty");
+ assert!(
+ app.state.workspace.tabs[0].modified,
+ "workspace tab flagged modified"
+ );
+
+ // Stroke end commits exactly one undo snapshot.
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+ assert_eq!(editor_of(&app).undo_stack.len(), 1, "one stroke snapshot");
+ assert!(editor_of(&app).can_undo());
+ }
+
+ #[test]
+ fn test_paint_rejects_out_of_range_value() {
+ let mut app = app_with_fixture().expect("fixture available");
+ let before = editor_of(&app).selected_factor();
+
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 5, 32));
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 512, 1));
+
+ let editor = editor_of(&app);
+ assert_eq!(editor.selected_factor(), before, "no state change");
+ assert!(!editor.dirty, "rejected paints stay clean");
+ assert!(editor.undo_stack.is_empty(), "no undo entry created");
+ }
+
+ #[test]
+ fn test_paint_stroke_without_change_creates_no_undo_entry() {
+ let mut app = app_with_fixture().expect("fixture available");
+ let value = editor_of(&app).selected_factor().unwrap();
+ let pair = editor_of(&app).selected_pair;
+
+ // Paint the exact same value — a no-op stroke.
+ run(&mut app, FogDataMessage::FactorPainted(TAB, pair, value));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+
+ assert!(
+ editor_of(&app).undo_stack.is_empty(),
+ "unchanged curve must not push an undo snapshot"
+ );
+ assert!(!editor_of(&app).dirty);
+ }
+
+ // ── Inspector field ───────────────────────────────────────────────────
+
+ #[test]
+ fn test_value_input_validation_shows_inline_error() {
+ let mut app = app_with_fixture().expect("fixture available");
+
+ run(
+ &mut app,
+ FogDataMessage::ValueInputChanged(TAB, "99".into()),
+ );
+ assert!(
+ editor_of(&app).input_error.is_some(),
+ "99 out of range → inline error"
+ );
+
+ run(
+ &mut app,
+ FogDataMessage::ValueInputChanged(TAB, "abc".into()),
+ );
+ assert!(editor_of(&app).input_error.is_some(), "non-numeric → error");
+
+ run(
+ &mut app,
+ FogDataMessage::ValueInputChanged(TAB, "17".into()),
+ );
+ assert!(
+ editor_of(&app).input_error.is_none(),
+ "valid input clears error"
+ );
+ }
+
+ #[test]
+ fn test_value_submit_commits_and_marks_dirty() {
+ let mut app = app_with_fixture().expect("fixture available");
+ let original = editor_of(&app).selected_factor().unwrap();
+ let replacement = if original == 5 { 6 } else { 5 };
+
+ run(
+ &mut app,
+ FogDataMessage::ValueInputChanged(TAB, replacement.to_string()),
+ );
+ run(&mut app, FogDataMessage::ValueSubmitted(TAB));
+
+ let editor = editor_of(&app);
+ assert_eq!(editor.selected_factor(), Some(replacement));
+ assert!(editor.dirty, "committed edit marks dirty");
+ assert_eq!(editor.undo_stack.len(), 1, "commit pushes one snapshot");
+
+ // Submitting an invalid buffer must be rejected without state change.
+ run(
+ &mut app,
+ FogDataMessage::ValueInputChanged(TAB, "40".into()),
+ );
+ run(&mut app, FogDataMessage::ValueSubmitted(TAB));
+ let editor = editor_of(&app);
+ assert_eq!(editor.selected_factor(), Some(replacement), "unchanged");
+ assert!(editor.input_error.is_some());
+ assert_eq!(editor.undo_stack.len(), 1, "no extra snapshot");
+ }
+
+ #[test]
+ fn test_factor_committed_rejects_out_of_range() {
+ let mut app = app_with_fixture().expect("fixture available");
+ let before = editor_of(&app).selected_factor();
+
+ run(&mut app, FogDataMessage::FactorCommitted(TAB, 3, 200));
+
+ let editor = editor_of(&app);
+ assert_eq!(editor.selected_factor(), before, "state untouched");
+ assert!(!editor.dirty, "rejection stays clean");
+ assert!(editor.undo_stack.is_empty());
+ }
+
+ #[test]
+ fn test_level_selected_updates_selection_and_input_buffer() {
+ let mut app = app_with_fixture().expect("fixture available");
+ run(&mut app, FogDataMessage::LevelSelected(TAB, 65));
+ assert_eq!(editor_of(&app).selected_level, 65);
+ assert!(
+ !editor_of(&app).value_input.is_empty(),
+ "input buffer refreshed from new level"
+ );
+
+ // Out-of-range levels are ignored.
+ run(&mut app, FogDataMessage::LevelSelected(TAB, 124));
+ run(&mut app, FogDataMessage::LevelSelected(TAB, 0));
+ assert_eq!(editor_of(&app).selected_level, 65, "clamped to valid range");
+ }
+
+ // ── Undo / redo round-trip ────────────────────────────────────────────
+
+ #[test]
+ fn test_undo_redo_round_trip_restores_prior_curve() {
+ let mut app = app_with_fixture().expect("fixture available");
+ let original = editor_of(&app).fog.as_ref().unwrap().clone();
+
+ // Stroke A: paint pair 10.
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 10, 1));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+ let after_a = editor_of(&app).fog.as_ref().unwrap().clone();
+
+ // Stroke B: paint pair 20 with a value guaranteed to differ from
+ // whatever the fixture holds there.
+ let cur_b = editor_of(&app).fog.as_ref().unwrap().factor(1, 20);
+ let new_b = (cur_b + 7) % MAX_FACTOR;
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 20, new_b));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+ assert_ne!(editor_of(&app).fog.as_ref().unwrap(), &after_a);
+
+ // Undo B → back to after-A.
+ run(&mut app, FogDataMessage::Undo(TAB));
+ assert_eq!(
+ editor_of(&app).fog.as_ref().unwrap(),
+ &after_a,
+ "first undo restores prior stroke"
+ );
+
+ // Undo A → back to the pristine table.
+ run(&mut app, FogDataMessage::Undo(TAB));
+ assert_eq!(
+ editor_of(&app).fog.as_ref().unwrap(),
+ &original,
+ "second undo restores the original curve"
+ );
+ assert!(!editor_of(&app).can_undo());
+
+ // Nothing left to undo reports via status bar.
+ run(&mut app, FogDataMessage::Undo(TAB));
+ assert_eq!(app.state.status_msg, "Nothing to undo");
+
+ // Redo reapplies both strokes in order.
+ run(&mut app, FogDataMessage::Redo(TAB));
+ assert_eq!(editor_of(&app).fog.as_ref().unwrap(), &after_a);
+ run(&mut app, FogDataMessage::Redo(TAB));
+ assert_ne!(
+ editor_of(&app).fog.as_ref().unwrap(),
+ &original,
+ "redo restores edits"
+ );
+ assert!(editor_of(&app).dirty);
+ }
+
+ #[test]
+ fn test_registry_undo_redo_returns_none_with_empty_history() {
+ use std::collections::HashMap;
+
+ let mut registry = crate::editor_registry::EditorRegistry::default();
+ let lookups: HashMap> = HashMap::new();
+
+ assert!(
+ registry
+ .undo_active(EditorType::FogDataEditor, TAB, &lookups)
+ .is_none(),
+ "no tab state → nothing to undo"
+ );
+ assert!(
+ registry
+ .redo_active(EditorType::FogDataEditor, TAB, &lookups)
+ .is_none()
+ );
+
+ // With a tab present but empty stacks it must still report None.
+ registry
+ .fog_editors
+ .insert(TAB, FogDataEditorState::default());
+ assert!(
+ registry
+ .undo_active(EditorType::FogDataEditor, TAB, &lookups)
+ .is_none(),
+ "empty undo stack → None (so the status bar says 'Nothing to undo')"
+ );
+ }
+
+ // ── Save round-trip ───────────────────────────────────────────────────
+
+ #[test]
+ fn test_save_writes_bytes_identical_to_original_when_unchanged() {
+ let Some(path) = fixture_fogdata_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+ let original_bytes = std::fs::read(&path).expect("fixture readable");
+
+ // Copy into a temp dir and open the copy.
+ let dir = std::env::temp_dir().join(format!("dispel-fog-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).expect("temp dir created");
+ let copy = dir.join("fogdata.dat");
+ std::fs::write(©, &original_bytes).expect("copy written");
+
+ let state = FogDataEditorState::load_from_path(©);
+ assert!(state.error.is_none());
+ crate::editors::fog_data::save_to_disk(©, state.fog.as_ref().unwrap())
+ .expect("save succeeds");
+
+ let saved = std::fs::read(©).expect("saved file readable");
+ assert_eq!(
+ saved, original_bytes,
+ "unmodified table round-trips byte-identical"
+ );
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_save_message_dispatches_and_complete_marks_clean() {
+ let mut app = app_with_fixture().expect("fixture available");
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 7, 9));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+ assert!(editor_of(&app).dirty);
+
+ // Save returns an async task; simulate its completion.
+ let save_gen = editor_of(&app).save_generation;
+ run(
+ &mut app,
+ FogDataMessage::SaveComplete(TAB, save_gen, Ok(("Saved → somewhere".into(), None))),
+ );
+ assert!(!editor_of(&app).dirty, "successful save clears dirty");
+ assert!(!app.state.workspace.tabs[0].modified);
+ assert_eq!(app.state.status_msg, "Saved → somewhere");
+
+ // A failed save keeps the editor dirty.
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 8, 10));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+ let save_gen = editor_of(&app).save_generation;
+ run(
+ &mut app,
+ FogDataMessage::SaveComplete(TAB, save_gen, Err("disk on fire".into())),
+ );
+ assert!(editor_of(&app).dirty, "failed save stays dirty");
+ assert!(app.state.workspace.tabs[0].modified);
+ assert!(
+ app.state.status_msg.contains("disk on fire"),
+ "failure reported via status bar"
+ );
+ }
+
+ #[test]
+ fn test_save_completion_with_stale_generation_keeps_tab_dirty() {
+ let mut app = app_with_fixture().expect("fixture available");
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 7, 9));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+ assert!(editor_of(&app).dirty);
+
+ // Save dispatch bumps the generation; simulate the completion
+ // carrying the pre-dispatch value (an edit landed while saving).
+ let stale_gen = editor_of(&app).save_generation - 1;
+ run(
+ &mut app,
+ FogDataMessage::SaveComplete(TAB, stale_gen, Ok(("Saved → x".into(), None))),
+ );
+
+ assert!(
+ editor_of(&app).dirty,
+ "stale save completion must not clear dirty"
+ );
+ assert!(app.state.workspace.tabs[0].modified, "tab stays modified");
+
+ // A current-generation completion still marks clean.
+ let save_gen = editor_of(&app).save_generation;
+ run(
+ &mut app,
+ FogDataMessage::SaveComplete(TAB, save_gen, Ok(("Saved → x".into(), None))),
+ );
+ assert!(!editor_of(&app).dirty);
+ }
+
+ #[test]
+ fn test_save_complete_recording_failure_reports_both_facts_and_marks_clean() {
+ let mut app = app_with_fixture().expect("fixture available");
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 7, 9));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+
+ let save_gen = editor_of(&app).save_generation;
+ run(
+ &mut app,
+ FogDataMessage::SaveComplete(
+ TAB,
+ save_gen,
+ Ok(("Saved → x".into(), Some("changelog locked".into()))),
+ ),
+ );
+
+ let status = &app.state.status_msg;
+ assert!(status.contains("Saved"), "file-saved success is reported");
+ assert!(
+ status.contains("Mod recording failed") && status.contains("changelog locked"),
+ "recording failure reported separately: {status}"
+ );
+ assert!(
+ !editor_of(&app).dirty,
+ "file on disk is current → editor marked clean"
+ );
+ assert!(!app.state.workspace.tabs[0].modified);
+ }
+
+ #[test]
+ fn test_system_save_respects_capability() {
+ let mut app = app_with_tab(EditorType::FogDataEditor);
+ let _ = app.update(Message::System(SystemMessage::Save));
+ assert_ne!(
+ app.state.status_msg, "This editor does not support saving",
+ "Ctrl+S must dispatch to the fog data editor"
+ );
+ }
+
+ // ── Revert ────────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_revert_discards_changes_and_restores_disk_state() {
+ let Some(path) = fixture_fogdata_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+ let original_bytes = std::fs::read(&path).expect("fixture readable");
+ let dir = std::env::temp_dir().join(format!("dispel-fog-revert-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).expect("temp dir created");
+ let copy = dir.join("fogdata.dat");
+ std::fs::write(©, &original_bytes).expect("copy written");
+
+ let mut app = app_with_tab(EditorType::FogDataEditor);
+ app.state
+ .editors
+ .fog_editors
+ .insert(TAB, FogDataEditorState::load_from_path(©));
+
+ // Dirty the copy.
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 42, 30));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+ assert!(editor_of(&app).dirty);
+
+ // Dirty revert asks for confirmation first.
+ run(&mut app, FogDataMessage::Revert(TAB));
+ assert!(editor_of(&app).confirm_revert, "confirm dialog opens");
+ assert!(editor_of(&app).dirty, "nothing discarded yet");
+
+ run(&mut app, FogDataMessage::RevertConfirmed(TAB));
+ let editor = editor_of(&app);
+ assert!(!editor.dirty, "revert restores clean state");
+ assert!(!editor.confirm_revert);
+ assert!(editor.undo_stack.is_empty(), "history cleared");
+ assert_eq!(
+ std::fs::read(©).unwrap(),
+ original_bytes,
+ "disk file untouched by revert"
+ );
+
+ // Clean revert reloads immediately without any dialog.
+ run(&mut app, FogDataMessage::Revert(TAB));
+ assert!(!editor_of(&app).confirm_revert);
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn test_revert_cancelled_keeps_edits() {
+ let mut app = app_with_fixture().expect("fixture available");
+ run(&mut app, FogDataMessage::FactorPainted(TAB, 11, 3));
+ run(&mut app, FogDataMessage::StrokeEnded(TAB));
+
+ run(&mut app, FogDataMessage::Revert(TAB));
+ assert!(editor_of(&app).confirm_revert);
+
+ run(&mut app, FogDataMessage::RevertCancelled(TAB));
+ let editor = editor_of(&app);
+ assert!(!editor.confirm_revert);
+ assert!(editor.dirty, "edits preserved after cancel");
+ assert_eq!(editor.selected_factor(), Some(3), "painted value kept");
+ }
+
+ // ── Workspace lifecycle ───────────────────────────────────────────────
+
+ #[test]
+ fn test_remove_tab_clears_fog_editor_state() {
+ let mut registry = crate::editor_registry::EditorRegistry::default();
+ registry
+ .fog_editors
+ .insert(TAB, FogDataEditorState::default());
+
+ registry.remove_tab(TAB);
+ assert!(!registry.fog_editors.contains_key(&TAB), "tab cleanup");
+
+ registry
+ .fog_editors
+ .insert(2, FogDataEditorState::default());
+ registry.close_all_tabs();
+ assert!(registry.fog_editors.is_empty(), "close_all_tabs cleanup");
+
+ registry
+ .fog_editors
+ .insert(3, FogDataEditorState::default());
+ registry.clear_all();
+ assert!(registry.fog_editors.is_empty(), "clear_all cleanup");
+ }
+
+ #[test]
+ fn test_workspace_reset_clears_open_fog_tabs() {
+ let mut app = App::test_new(Workspace::new());
+ app.state
+ .editors
+ .fog_editors
+ .insert(TAB, Default::default());
+ app.state.editors.clear_all();
+ assert!(app.state.editors.fog_editors.is_empty());
+ }
+}
diff --git a/dispel-gui/src/tests/map_editor.rs b/dispel-gui/src/tests/map_editor.rs
index 2f66f3f4..a7a555b7 100644
--- a/dispel-gui/src/tests/map_editor.rs
+++ b/dispel-gui/src/tests/map_editor.rs
@@ -1,7 +1,9 @@
#[cfg(test)]
mod map_editor_entity_tests {
use crate::app::App;
+ use crate::components::map_render::traits::MapRenderSource;
use crate::editors::map_editor;
+ use crate::editors::map_editor::message::MapLayer;
use crate::editors::map_editor::{
MapEditAction, MapEditorMessage, MapEditorState, SelectedEntity,
};
@@ -593,4 +595,191 @@ mod map_editor_entity_tests {
"first redo: 100 → 150"
);
}
+
+ // ── Shadow/lighting layer ──────────────────────────────────────────────
+
+ /// Fixture game directory (same convention as update/map.rs tests).
+ fn fixture_game_path() -> Option {
+ let p = PathBuf::from("../fixtures/Dispel");
+ p.exists().then_some(p)
+ }
+
+ #[test]
+ fn shadows_layer_defaults_visible() {
+ let state = MapEditorState::default();
+ assert!(
+ state.view.show_shadows,
+ "shadow pass enabled by default, matching observed behavior"
+ );
+ }
+
+ #[test]
+ fn layer_toggle_shadows_flips_visibility_and_back() {
+ let mut app = app_with_map_editor();
+ let tab_id = 0;
+ let before = app.state.editors.map_editors[&tab_id].view.show_shadows;
+
+ let task = map_editor::handle(
+ MapEditorMessage::LayerToggled(tab_id, MapLayer::Shadows),
+ &mut app,
+ );
+ assert_eq!(task.units(), 0);
+ let after = app.state.editors.map_editors[&tab_id].view.show_shadows;
+ assert_ne!(before, after, "LayerToggled(Shadows) flips visibility");
+
+ // Toggling again restores the original state.
+ let _ = map_editor::handle(
+ MapEditorMessage::LayerToggled(tab_id, MapLayer::Shadows),
+ &mut app,
+ );
+ assert_eq!(
+ app.state.editors.map_editors[&tab_id].view.show_shadows, before,
+ "second toggle restores original visibility"
+ );
+ }
+
+ #[test]
+ fn derived_map_overlay_layers_default_hidden_and_toggle_independently() {
+ let mut app = app_with_map_editor();
+ let tab_id = 0;
+ let view = &app.state.editors.map_editors[&tab_id].view;
+ assert!(!view.show_under_roof_markers);
+ assert!(!view.show_no_spell_cast_zones);
+ assert!(!view.show_btl_overlays_in_dark_maps);
+
+ let _ = map_editor::handle(
+ MapEditorMessage::LayerToggled(tab_id, MapLayer::UnderRoofMarkers),
+ &mut app,
+ );
+ assert!(
+ app.state.editors.map_editors[&tab_id]
+ .view
+ .show_under_roof_markers
+ );
+ assert!(
+ !app.state.editors.map_editors[&tab_id]
+ .view
+ .show_no_spell_cast_zones
+ );
+ assert!(
+ !app.state.editors.map_editors[&tab_id]
+ .view
+ .show_btl_overlays_in_dark_maps
+ );
+
+ let _ = map_editor::handle(
+ MapEditorMessage::LayerToggled(tab_id, MapLayer::NoSpellCastZones),
+ &mut app,
+ );
+ let _ = map_editor::handle(
+ MapEditorMessage::LayerToggled(tab_id, MapLayer::BtlOverlaysInDarkMaps),
+ &mut app,
+ );
+ let view = &app.state.editors.map_editors[&tab_id].view;
+ assert!(view.show_under_roof_markers);
+ assert!(view.show_no_spell_cast_zones);
+ assert!(view.show_btl_overlays_in_dark_maps);
+ }
+
+ #[test]
+ fn light_map_loads_no_shadow_data() {
+ let Some(gp) = fixture_game_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+ // cat1 is flagged Light (0) in AllMap.ini → no lighting pass
+ // is applied there.
+ assert!(
+ dispel_core::map::render::load_fog_if_dark(&gp, "cat1").is_none(),
+ "Light maps must not load shadow data"
+ );
+ }
+
+ #[test]
+ fn dark_map_loads_fog_data_with_known_factors() {
+ let Some(gp) = fixture_game_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+ // final is flagged Dark (1) in AllMap.ini.
+ let fog = dispel_core::map::render::load_fog_if_dark(&gp, "final")
+ .expect("Dark map must load fogdata.dat");
+ // Known values from the shipped ExtraInGame/fogdata.dat: level 1 is
+ // uniformly near-black (f=2), level 65 fully bright (f=31).
+ assert_eq!(fog.factor(1, 0), 2);
+ assert_eq!(fog.factor(65, 256), 31);
+ }
+
+ #[test]
+ fn unknown_map_loads_no_shadow_data() {
+ let Some(gp) = fixture_game_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+ assert!(
+ dispel_core::map::render::load_fog_if_dark(&gp, "not_a_map").is_none(),
+ "maps missing from AllMap.ini get no shadow pass"
+ );
+ }
+
+ #[test]
+ fn map_render_source_exposes_loaded_shadow_data() {
+ let Some(gp) = fixture_game_path() else {
+ eprintln!("Skipping: fixtures not found");
+ return;
+ };
+
+ let mut state = MapEditorState::default();
+ assert!(
+ state.shadow_data().is_none(),
+ "no shadow data before a Dark map is loaded"
+ );
+
+ let fog = dispel_core::map::render::FogData::load(&gp).expect("fixture fogdata.dat loads");
+ state.data.shadow_fog = Some(std::sync::Arc::new(fog));
+ assert!(
+ state.shadow_data().is_some(),
+ "MapRenderSource::shadow_data surfaces loaded tables to the canvas"
+ );
+ }
+
+ // ── CLI ↔ GUI placement parity ─────────────────────────────────────────
+
+ /// The GUI draws entities in absolute canvas space; the CLI crops to the
+ /// occluded viewport. Both must agree: cli_pos = gui_world_pos − origin.
+ #[test]
+ fn gui_tile_world_center_matches_core_transform() {
+ use crate::components::map_render::{TILE_H, TILE_W, tile_world_center};
+
+ let model = dispel_core::map::model::MapModel {
+ tiled_map_width: 249,
+ tiled_map_height: 249,
+ ..Default::default()
+ };
+ let diagonal = model.tiled_map_width + model.tiled_map_height;
+ for (x, y) in [(0i32, 0i32), (10, 7), (67, 35), (140, 93), (248, 248)] {
+ let (wx, wy) = tile_world_center(x, y, diagonal);
+ let (px, py) =
+ dispel_core::map::types::convert_map_coords_to_image_coords(x, y, diagonal);
+ assert_eq!(wx, px as f32 + TILE_W * 0.5);
+ assert_eq!(wy, py as f32 + TILE_H * 0.5);
+
+ // The CLI's viewport-space anchor is the same point minus the
+ // non-occluded origin.
+ let cli_cx = px - model.map_non_occluded_start_x
+ + dispel_core::map::tileset::TILE_WIDTH as i32 / 2;
+ let cli_cy = py - model.map_non_occluded_start_y
+ + dispel_core::map::tileset::TILE_HEIGHT as i32 / 2;
+ assert_eq!(
+ wx as i32 - model.map_non_occluded_start_x,
+ cli_cx,
+ "GUI/CLI x placement diverged at ({x},{y})"
+ );
+ assert_eq!(
+ wy as i32 - model.map_non_occluded_start_y,
+ cli_cy,
+ "GUI/CLI y placement diverged at ({x},{y})"
+ );
+ }
+ }
}
diff --git a/dispel-gui/src/tests/mod.rs b/dispel-gui/src/tests/mod.rs
index cba6180b..d9cdb033 100644
--- a/dispel-gui/src/tests/mod.rs
+++ b/dispel-gui/src/tests/mod.rs
@@ -28,6 +28,7 @@ pub(crate) mod editor_field_edit;
pub(crate) mod editor_registry;
pub(crate) mod error_dialog;
pub(crate) mod file_tree;
+pub(crate) mod fog_data;
pub(crate) mod generic_editor_edge;
pub(crate) mod global_search;
pub(crate) mod hex_inspector_toggle;
diff --git a/dispel-gui/src/tests/save_dispatch.rs b/dispel-gui/src/tests/save_dispatch.rs
index c98d79cb..b1bf018d 100644
--- a/dispel-gui/src/tests/save_dispatch.rs
+++ b/dispel-gui/src/tests/save_dispatch.rs
@@ -70,6 +70,7 @@ mod save_dispatch_tests {
EditorType::ModPackager,
EditorType::LocalizationManager,
EditorType::HexEditor,
+ EditorType::FogDataEditor,
EditorType::Unknown,
]
}
diff --git a/dispel-gui/src/tests/sprite_viewer.rs b/dispel-gui/src/tests/sprite_viewer.rs
index 4e379bef..150bfc74 100644
--- a/dispel-gui/src/tests/sprite_viewer.rs
+++ b/dispel-gui/src/tests/sprite_viewer.rs
@@ -48,6 +48,7 @@ mod sprite_viewer_tests {
save_path: PathBuf::new(),
name: "test.spr".into(),
error: None,
+ preserve_black: false,
sprite_file: None,
dirty: false,
undo_stack: Vec::new(),
@@ -90,6 +91,7 @@ mod sprite_viewer_tests {
save_path: PathBuf::new(),
name: "test.spr".into(),
error: None,
+ preserve_black: false,
sprite_file: None,
dirty: false,
undo_stack: Vec::new(),
diff --git a/dispel-gui/src/tests/view_dispatch.rs b/dispel-gui/src/tests/view_dispatch.rs
index 8fe95659..4375fa4e 100644
--- a/dispel-gui/src/tests/view_dispatch.rs
+++ b/dispel-gui/src/tests/view_dispatch.rs
@@ -44,6 +44,7 @@ mod view_dispatch_tests {
ModPackager,
LocalizationManager,
HexEditor,
+ FogDataEditor,
];
for et in types {
diff --git a/dispel-gui/src/tests/workspace_unit.rs b/dispel-gui/src/tests/workspace_unit.rs
index cc222479..22629ad9 100644
--- a/dispel-gui/src/tests/workspace_unit.rs
+++ b/dispel-gui/src/tests/workspace_unit.rs
@@ -625,6 +625,21 @@ fn ini_stems_map_to_correct_editor_types() {
}
}
+#[test]
+fn ifo_files_map_to_save_ifo_editor() {
+ let cases = [
+ ("Save.ifo", EditorType::SaveIfoEditor),
+ ("save.ifo", EditorType::SaveIfoEditor),
+ ];
+ for (filename, expected) in cases {
+ assert_eq!(
+ EditorType::from_path(Path::new(filename)),
+ expected,
+ "failed for {filename}"
+ );
+ }
+}
+
#[test]
fn ref_stems_map_to_correct_editor_types() {
let cases = [
diff --git a/dispel-gui/src/update/file_tree.rs b/dispel-gui/src/update/file_tree.rs
index ab89253f..a6bf4c3b 100644
--- a/dispel-gui/src/update/file_tree.rs
+++ b/dispel-gui/src/update/file_tree.rs
@@ -88,5 +88,64 @@ pub fn handle(message: FileTreeMessage, app: &mut App) -> Task {
+ copy_to_clipboard(app, file_path.display().to_string())
+ }
+ FileTreeMessage::CopyRelativePath(file_path) => {
+ let text = relative_display(&app.state.shared_game_path, &file_path);
+ copy_to_clipboard(app, text)
+ }
+ FileTreeMessage::ClipboardWriteComplete => Task::none(),
+ }
+}
+
+/// Write `text` to the system clipboard and report it in the status bar.
+fn copy_to_clipboard(app: &mut App, text: String) -> Task {
+ app.state.status_msg = format!("Copied to clipboard: {text}");
+ iced::clipboard::write(text)
+ .map(|_| crate::message::Message::file_tree(FileTreeMessage::ClipboardWriteComplete))
+}
+
+/// Path relative to the game directory; falls back to the full path when the
+/// game path is unset or the file is outside of it.
+fn relative_display(shared_game_path: &str, path: &std::path::Path) -> String {
+ if shared_game_path.is_empty() {
+ return path.display().to_string();
+ }
+ match path.strip_prefix(std::path::Path::new(shared_game_path)) {
+ Ok(relative) => relative.display().to_string(),
+ Err(_) => path.display().to_string(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_relative_display_strip_prefix_when_under_game_path() {
+ assert_eq!(
+ relative_display(
+ "/game",
+ std::path::Path::new("/game/CharacterInGame/weaponItem.db")
+ ),
+ "CharacterInGame/weaponItem.db"
+ );
+ }
+
+ #[test]
+ fn test_relative_display_falls_back_to_full_path_outside_game() {
+ assert_eq!(
+ relative_display("/game", std::path::Path::new("/other/file.db")),
+ "/other/file.db"
+ );
+ }
+
+ #[test]
+ fn test_relative_display_falls_back_when_game_path_empty() {
+ assert_eq!(
+ relative_display("", std::path::Path::new("/other/file.db")),
+ "/other/file.db"
+ );
}
}
diff --git a/dispel-gui/src/update/system.rs b/dispel-gui/src/update/system.rs
index cfd5a261..ce8474af 100644
--- a/dispel-gui/src/update/system.rs
+++ b/dispel-gui/src/update/system.rs
@@ -70,12 +70,20 @@ fn save_task_for_editor(
// Custom editors with Save support
EditorType::StoreEditor => Message::store(StoreEditorMessage::Save),
EditorType::PartyLevelDbEditor => Message::party_level_db(PartyLevelDbEditorMessage::Save),
+ EditorType::SaveIfoEditor => {
+ Message::save_ifo(crate::editors::save_ifo::SaveIfoEditorMessage::Save)
+ }
// Sprite editor
EditorType::SpriteViewer => {
Message::sprite_viewer(crate::editors::sprite_editor::SpriteViewerMessage::Save)
}
+ // Fog data editor (per-tab Save carries the tab id)
+ EditorType::FogDataEditor => {
+ Message::fog_data(crate::editors::fog_data::FogDataMessage::Save(tab_id))
+ }
+
// Map editor uses SaveEntities with tab_id
EditorType::MapEditor => Message::map_editor(MapEditorMessage::SaveEntities(tab_id)),
diff --git a/dispel-gui/src/workspace.rs b/dispel-gui/src/workspace.rs
index 700e9e6e..a70543ad 100644
--- a/dispel-gui/src/workspace.rs
+++ b/dispel-gui/src/workspace.rs
@@ -49,6 +49,19 @@ pub enum EditorType {
LocalizationManager,
/// Read-only viewer for Dispel .sav save files.
SaveFileViewer,
+ /// Save-slot manager for `Save.ifo`: swap slots and edit global tail fields.
+ SaveIfoEditor,
+ /// Editor for `ExtraInGame/fogdata.dat` — map-lighting fade tables
+ /// (123 levels × 512 brightness factors).
+ FogDataEditor,
+ /// Chest spawn editor for multiplayer `.cst` files.
+ ChestSpawnEditor,
+ /// Monster spawn editor for multiplayer `.mon` files.
+ MonsterSpawnEditor,
+ /// Map info editor for multiplayer `.ifm` files.
+ MapInfoMultiEditor,
+ /// Tile grid editor for multiplayer `.dat` files.
+ TileGridEditor,
/// Universal fallback editor for any binary file no dedicated editor
/// claims. Also reachable via "Open as Hex" from the file tree.
HexEditor,
@@ -92,6 +105,11 @@ impl EditorType {
"snf" => EditorType::SnfEditor,
"map" => EditorType::MapEditor,
"sav" => EditorType::SaveFileViewer,
+ "ifo" => EditorType::SaveIfoEditor,
+ "cst" => EditorType::ChestSpawnEditor,
+ "mon" => EditorType::MonsterSpawnEditor,
+ "ifm" => EditorType::MapInfoMultiEditor,
+ "dat" => EditorType::TileGridEditor,
_ => EditorType::HexEditor,
}
}
@@ -129,6 +147,8 @@ impl EditorType {
// .scr files (exact stems only; "event*" prefix handled separately)
m.insert(("scr", "quest"), EditorType::QuestScrEditor);
m.insert(("scr", "message"), EditorType::MessageScrEditor);
+ // .dat files (exact stems only)
+ m.insert(("dat", "fogdata"), EditorType::FogDataEditor);
m
})
}
@@ -180,10 +200,12 @@ impl EditorType {
!matches!(
self,
EditorType::EventScrEditor
- | EditorType::MonsterEditor
+ | EditorType::MonsterEditor
| EditorType::MonsterIniEditor
| EditorType::NpcIniEditor
+ // Snapshot-based undo (own stacks, no EditHistory component)
| EditorType::SpriteViewer
+ | EditorType::FogDataEditor
| EditorType::SnfEditor
| EditorType::DbViewer
| EditorType::TilesetEditor
diff --git a/dispel-macros/src/extractor_impl.rs b/dispel-macros/src/extractor_impl.rs
index 753bf7e9..5d9b6430 100644
--- a/dispel-macros/src/extractor_impl.rs
+++ b/dispel-macros/src/extractor_impl.rs
@@ -165,6 +165,30 @@ pub fn expand(input: DeriveInput) -> TokenStream2 {
});
struct_field_inits.push(quote! { #ident: #ident, });
}
+ FieldInfo::Nested {
+ ident,
+ item_ty,
+ max_items,
+ item_size: _,
+ } => {
+ let inner_ty = extract_vec_inner(item_ty).unwrap_or_else(|| {
+ panic!(
+ "#[extractor(nested)] requires the field type to be Vec; got `{}`",
+ quote! { #item_ty }
+ )
+ });
+ let inner_ty_tokens = quote! { #inner_ty };
+ parse_stmts.push(quote! {
+ let mut #ident: Vec<#inner_ty_tokens> = Vec::with_capacity(#max_items);
+ for _ in 0..#max_items {
+ match <#inner_ty_tokens as crate::references::extractor::Extractor>::parse_one(reader)? {
+ Some(item) => #ident.push(item),
+ None => break,
+ }
+ }
+ });
+ struct_field_inits.push(quote! { #ident: #ident, });
+ }
FieldInfo::InventoryItem { ident, wire_type } => {
let read_stmt = match wire_type.as_str() {
"u16" => {
@@ -317,6 +341,34 @@ pub fn expand(input: DeriveInput) -> TokenStream2 {
writer.write_all(&record.#ident)?;
});
}
+ FieldInfo::Nested {
+ ident,
+ item_ty,
+ max_items,
+ item_size,
+ } => {
+ let inner_ty = extract_vec_inner(item_ty).unwrap_or_else(|| {
+ panic!(
+ "#[extractor(nested)] requires the field type to be Vec; got `{}`",
+ quote! { #item_ty }
+ )
+ });
+ let inner_ty_tokens = quote! { #inner_ty };
+ let total_size = *max_items as i32 * *item_size;
+ write_stmts.push(quote! {
+ for item in record.#ident.iter() {
+ <#inner_ty_tokens as crate::references::extractor::Extractor>::write_one(item, writer)?;
+ }
+ let written = (record.#ident.len() as i32) * (#item_size as i32);
+ let remaining_slots = ((#total_size as i32 - written) / #item_size as i32) as usize;
+ if remaining_slots > 0 {
+ let pad = vec![0u8; #item_size as usize];
+ for _ in 0..remaining_slots {
+ writer.write_all(&pad)?;
+ }
+ }
+ });
+ }
FieldInfo::InventoryItem { ident, wire_type } => {
let write_stmt = match wire_type.as_str() {
"u16" => {
@@ -372,6 +424,8 @@ pub fn expand(input: DeriveInput) -> TokenStream2 {
// Generate the full implementation
let expanded = quote! {
impl crate::references::extractor::Extractor for #name {
+ const ITEM_SIZE: i32 = #property_item_size;
+
fn parse(reader: &mut R, len: u64) -> std::io::Result> {
const COUNTER_SIZE: u8 = #counter_size;
const PROPERTY_ITEM_SIZE: i32 = #property_item_size;
@@ -406,6 +460,22 @@ pub fn expand(input: DeriveInput) -> TokenStream2 {
Ok(())
}
+
+ fn parse_one(reader: &mut R) -> std::io::Result