egui changes
This commit is contained in:
+29
-27
@@ -138,23 +138,26 @@ eframe requires `NativeOptions` (including window size) to be set before calling
|
||||
|
||||
`App` holds a `GameState`, an `AppMode` (`Play` | `Edit`), an `EditorState`, a `default_font: BitmapFont` (loaded from `assets/vga-font-8x16.png` at startup, falling back to `create_placeholder`), and a `board_font: Option<BitmapFont>` (loaded from `board.font` when a per-board font is set). `App::new` takes `&egui::Context` to upload textures before the first frame.
|
||||
|
||||
Font change detection: before calling `show_editor_panel`, `font_spec_before` is cloned; after the call, if `board.font` differs, `apply_font_spec` reloads `board_font`. The active font for the frame is `board_font.as_ref().unwrap_or(&default_font)`, extracted as a `let` binding before mutable borrows to satisfy the borrow checker.
|
||||
Font change detection: before calling `show_editor_panel`, `font_spec_before` is cloned; after the call, if `board.font` differs, `apply_font_spec` reloads `board_font`. `App::active_font()` resolves `board_font.as_ref().unwrap_or(&default_font)`; at the editor-panel and glyph-picker sites the same expression is used inline instead so the font borrow stays disjoint from the `&mut editor`/`&mut board` borrows (a method call would borrow all of `self`).
|
||||
|
||||
The `update` method phases:
|
||||
`update` is a thin sequence of phase methods, each owning one concern:
|
||||
|
||||
1. Reads arrow key input and calls `GameState::try_move` — **Play mode only**
|
||||
2. Draws the menu bar with a File menu and a Play/Edit mode toggle
|
||||
3. In Edit mode: calls `editor::show_editor_panel` — declared before `CentralPanel` so egui allocates its space first
|
||||
4. Draws the board and player via `render::draw_board` (see viewport sections below)
|
||||
5. In Edit mode: calls `glyph_picker::show` if `editor.glyph_picker_open`
|
||||
1. `handle_input` — table-driven arrow keys → `GameState::try_move` (**Play mode only**)
|
||||
2. `menu_bar` — File menu (Save/Save As via `save_to`/`save_as`, Exit) and the Play/Edit toggle
|
||||
3. `try_show_script_editor` — if a script is open, fills the `CentralPanel` with the code editor and returns `true`, ending the frame early
|
||||
4. Editor side panel (Edit mode) — `editor::show_editor_panel`, declared before `CentralPanel`; then the font-reload check
|
||||
5. `show_board` — draws the viewport and returns the clicked cell; the click is dispatched to `handle_board_click` *after* it returns
|
||||
6. `show_glyph_pickers` — the Palette and Objects glyph pickers; then the selected object's glyph is flushed back to `board.objects[i]`
|
||||
|
||||
**Click dispatch borrow split:** `show_board(&self)` only draws and returns `Option<(usize, usize)>` (validated cell coords). The mutating `handle_board_click(&mut self, cx, cy)` runs afterward, so the immutable font borrow held during drawing never conflicts with the `&mut self` handler.
|
||||
|
||||
**Viewport — Play mode:**
|
||||
`render::board_origin(available, board_w, board_h, player)` computes the pixel position of cell (0,0). If the board fits along an axis it is centered; if it overflows the viewport is centered on the player and clamped so no empty space appears at the board edges. No scroll bars.
|
||||
`render::board_origin(available, board_w, board_h, player, font, zoom)` computes the pixel position of cell (0,0). If the board fits along an axis it is centered; if it overflows the viewport is centered on the player and clamped so no empty space appears at the board edges. No scroll bars.
|
||||
|
||||
**Viewport — Edit mode:**
|
||||
The board is wrapped in `egui::ScrollArea::new([true, true])`, which shows scroll bars when the board overflows the viewport. Content is allocated at exact board size; `rect.min` from `allocate_exact_size` serves as the origin passed to `render::draw_board`.
|
||||
|
||||
**Stamp action:** `*board.get_mut(cx, cy) = (self.editor.glyph, self.editor.selected)` — the custom glyph and selected archetype are written together. The glyph can differ from the archetype's `default_glyph()` if the user has customized it.
|
||||
**Stamp action:** `*board.get_mut(cx, cy) = (self.editor.palette.glyph, self.editor.palette.selected)` — the custom glyph and selected archetype are written together. The glyph can differ from the archetype's `default_glyph()` if the user has customized it.
|
||||
|
||||
---
|
||||
|
||||
@@ -162,39 +165,38 @@ The board is wrapped in `egui::ScrollArea::new([true, true])`, which shows scrol
|
||||
|
||||
All pixel-level rendering knowledge lives here. No dependency on `EditorState` or `App`. Cell size is no longer a fixed constant — it comes from the active `BitmapFont`.
|
||||
|
||||
**Constants:** Window sizing (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) defined here. No `CELL_W`/`CELL_H` — those were removed when bitmap fonts were introduced.
|
||||
**Constants:** Window sizing (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) defined here. No `CELL_W`/`CELL_H` — those were removed when bitmap fonts were introduced. Per-cell pixel size comes from `BitmapFont::cell_size(zoom)`.
|
||||
|
||||
**`paint_glyph(painter, rect, glyph, font)`** — fills `rect` with `glyph.bg`, then draws the tile image tinted by `glyph.fg`. Used by both `draw_glyph` and `glyph_picker`.
|
||||
**`rgba8_to_color32(c)` / `color32_to_rgba8(c)`** — inverse conversions between the core `color::Rgba8` and egui's `Color32`, used at the glyph-picker boundary where colors cross between the two type systems.
|
||||
|
||||
**`draw_glyph(painter, origin, x, y, glyph, font)`** — computes the cell rect from `font.tile_w/tile_h`, delegates to `paint_glyph`.
|
||||
**`paint_glyph(painter, rect, glyph, font)`** — fills `rect` with `glyph.bg`, then draws the tile image tinted by `glyph.fg`. Used by `draw_glyph`, `glyph_preview_button`, and `glyph_picker`.
|
||||
|
||||
**`draw_board(painter, origin, board, font)`** — iterates all cells calling `draw_glyph`, then draws the player as an overlay on top.
|
||||
**`glyph_preview_button(ui, glyph, font) -> Response`** — allocates a one-cell swatch (unzoomed), paints `glyph`, and returns the click `Response`. Shared by the Palette and Objects editor tabs for their editable glyph swatches.
|
||||
|
||||
**`draw_object_overlays(painter, origin, board, font, selected)`** — called from Edit mode when the Objects tab is active. Draws a dim border on every object cell and a bright border on the selected one, making objects discoverable without obscuring the underlying tile.
|
||||
**`draw_glyph(painter, origin, x, y, glyph, font, zoom)`** — computes the cell rect from `font.cell_size(zoom)`, delegates to `paint_glyph`.
|
||||
|
||||
**`draw_board(painter, origin, board, font, zoom)`** — iterates all cells calling `draw_glyph`, then draws objects and the player as overlays on top.
|
||||
|
||||
**`draw_object_overlays(painter, origin, board, font, selected, zoom)`** — called from Edit mode when the Objects tab is active. Draws a dim border on every object cell and a bright border on the selected one, making objects discoverable without obscuring the underlying tile.
|
||||
|
||||
**`pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)`** — converts a pixel position to cell coordinates using floor division. Returns negative values for clicks above/left of the origin; callers must guard `>= 0`. Mirrors the rendering math so clicks land on the correct cell.
|
||||
|
||||
**`board_origin(available, board_w, board_h, player, font) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
|
||||
**`board_origin(available, board_w, board_h, player, font, zoom) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
|
||||
|
||||
---
|
||||
|
||||
### `editor.rs` — editor state and side panel
|
||||
|
||||
**`EditorTab`** (`Palette` | `Board` | `Objects` | `World`) — which tab is active in the side panel.
|
||||
**`EditorTab`** (`Palette` | `Objects` | `Board` | `Scripts` | `World`) — which tab is active in the side panel.
|
||||
|
||||
**`EditorState`** — transient editor state:
|
||||
- `selected: Archetype` — the archetype class to stamp
|
||||
- `glyph: Glyph` — the visual to stamp (independent of the archetype's default; resets to `archetype.default_glyph()` when the archetype selection changes)
|
||||
- `glyph_picker_open: bool` — whether the glyph picker dialog is visible
|
||||
**`EditorState`** — transient editor state, grouped by concern into sub-structs so each tab can take only the borrow it needs (the dispatch in `show_editor_panel` passes disjoint sub-struct borrows, e.g. `&mut editor.objects` and `&mut editor.scripts`):
|
||||
- `tab: EditorTab` — which side-panel tab is active
|
||||
- `font_dialog_open: bool` — whether the font picker dialog is visible
|
||||
- `font_dialog_state: FontDialogState` — in-progress edits for the font dialog
|
||||
- `selected_object: Option<usize>` — index into `board.objects` of the currently selected object
|
||||
- `object_glyph_picker_open: bool` — whether the object glyph picker is open
|
||||
- `object_editing_glyph: Glyph` — local copy of the selected object's glyph; written back to `board.objects[i].glyph` every frame
|
||||
- `placing_object: bool` — when `true`, the next board click creates a new `ObjectDef` at that cell
|
||||
- `palette: PaletteState` — `selected: Archetype` (class to stamp), `glyph: Glyph` (visual to stamp; resets to `archetype.default_glyph()` when the archetype changes), `picker_open: bool`
|
||||
- `font_dialog: FontDialog` — `open: bool`, `state: FontDialogState`
|
||||
- `objects: ObjectEdit` — `selected: Option<usize>` (index into `board.objects`), `picker_open: bool`, `editing_glyph: Glyph` (local copy written back to `board.objects[i].glyph` each frame), `placing: bool` (next board click creates a new `ObjectDef`)
|
||||
- `scripts: ScriptEdit` — `editing: Option<String>` (script open in the code editor), `content: String` (working copy), `new_name: String` (new-script draft)
|
||||
|
||||
**`show_editor_panel(ctx, editor, board, active_font)`** — renders the resizable right-side panel (default 200 px). Palette tab: scrollable archetype list and glyph preview button. Board tab: current font path label + "Font…" button. Objects tab: "Add Object" button that enters placement mode; lists objects by coordinate with click-to-select; for the selected object shows glyph preview, **Passable** and **Opaque** checkboxes (writing directly to `board.objects[i]`), and a script combobox. World tab: placeholder.
|
||||
**`show_editor_panel(ctx, editor, board, active_font)`** — renders the resizable right-side panel (default 200 px): a tab bar, then dispatch to one function per tab. `palette_tab`: scrollable archetype list and glyph preview button. `objects_tab`: "Add Object" placement toggle, click-to-select, and for the selected object a glyph preview, **Passable**/**Opaque** checkboxes (writing directly to `board.objects[i]`), and a script combobox. `board_tab`: font path label + "Font…" button + zoom slider. `scripts_tab`: create and edit named scripts. World tab: placeholder. The glyph preview swatches use `render::glyph_preview_button`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
||||
- `BitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label)` — loads from embedded bytes
|
||||
- `BitmapFont::create_placeholder(ctx)` — 16×16 grid of 8×8 procedural tiles (bit-pattern bars), used as fallback when no font file is present
|
||||
- `BitmapFont::tile_uv(tile)` — returns a UV `Rect` for the given tile index (left-to-right, top-to-bottom). Unit-tested via `compute_tile_uv`.
|
||||
- `BitmapFont::cell_size(zoom)` — pixel size (`Vec2`) of one rendered cell at the given integer zoom; centralizes the `tile_w * zoom` math used across rendering and hit-testing
|
||||
- `BitmapFont::tile_cols()`, `tile_count()`, `img_w()`, `img_h()` — geometry helpers used by the glyph picker and font dialog
|
||||
|
||||
**`src/font_dialog.rs`** — font picker dialog:
|
||||
@@ -81,24 +82,33 @@ The game is a single Rust binary using **eframe 0.33 / egui 0.33** for the GUI.
|
||||
- `AppMode` enum (`Play` | `Edit`) — gates arrow-key input; toggles the side panel and viewport mode
|
||||
- `App` holds `GameState`, `AppMode`, `EditorState`, `default_font: BitmapFont`, and `board_font: Option<BitmapFont>`
|
||||
- `App::new(board, egui_ctx)` — loads `assets/vga-font-8x16.png` as the default font (falls back to `create_placeholder`); loads per-board font from `board.font` if present
|
||||
- `App::update` is a thin sequence of phase methods: `handle_input` (table-driven arrow keys, Play only) → `menu_bar` (File Save/Save As via `save_to`/`save_as`, Exit, mode toggle) → `try_show_script_editor` (returns `true` to take over the frame) → editor side panel + font reload → `show_board` → `handle_board_click` → `show_glyph_pickers` → object-glyph writeback
|
||||
- `App::active_font()` — resolves the per-board font or the default; used wherever an exclusive borrow of `self` is not also needed
|
||||
- `App::show_board(&self) -> Option<(usize, usize)>` — draws the viewport and returns the clicked cell (Edit mode); the click is dispatched to `handle_board_click(&mut self, cx, cy)` *after* `show_board` returns, so the `&mut` handler never conflicts with the font borrow
|
||||
- Font change detection: `font_spec_before` is snapshotted before `show_editor_panel`; if changed, `apply_font_spec` reloads `board_font`
|
||||
- Borrow split: `let active_font = self.board_font.as_ref().unwrap_or(&self.default_font)` is extracted before mutable borrows of `editor` and `board.font` to satisfy the borrow checker
|
||||
- Borrow split: at the editor-panel and glyph-picker call sites, `self.board_font.as_ref().unwrap_or(&self.default_font)` is used inline (not `active_font()`) so the font borrow stays disjoint from the `&mut self.editor`/`&mut board` borrows
|
||||
- **Play mode**: arrow keys move player; `render::board_origin` centers or player-tracks the viewport
|
||||
- **Edit mode**: `ScrollArea::both()` wraps the board; click-to-paint stamps `(editor.glyph, editor.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show`
|
||||
- **Edit mode**: `ScrollArea::both()` wraps the board; click-to-paint stamps `(editor.palette.glyph, editor.palette.selected)`; calls `editor::show_editor_panel` and `glyph_picker::show`
|
||||
|
||||
**`src/render.rs`** — cell rendering and drawing primitives:
|
||||
- Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`); no fixed `CELL_W/H` — cell size comes from the active `BitmapFont`
|
||||
- `rgba8_to_color32(c)` / `color32_to_rgba8(c)` — inverse bridges between core `color::Rgba8` and egui `Color32`
|
||||
- `paint_glyph(painter, rect, glyph, font)` — `rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg`
|
||||
- `draw_glyph(painter, origin, x, y, glyph, font)` — sizes cell from `font.tile_w/tile_h`, delegates to `paint_glyph`
|
||||
- `draw_board(painter, origin, board, font)` — draws all cells then player overlay
|
||||
- `draw_object_overlays(painter, origin, board, font, selected)` — highlights all object cells in the Objects editor tab; the selected object gets a brighter border
|
||||
- `board_origin(available, board_w, board_h, player, font)` — centers board or clamps to player with no empty space
|
||||
- `glyph_preview_button(ui, glyph, font) -> Response` — allocates a one-cell swatch (unzoomed), paints `glyph`, returns the click response; used by the Palette and Objects tabs
|
||||
- `draw_glyph(painter, origin, x, y, glyph, font, zoom)` — sizes cell via `font.cell_size(zoom)`, delegates to `paint_glyph`
|
||||
- `draw_board(painter, origin, board, font, zoom)` — draws all cells then player overlay
|
||||
- `draw_object_overlays(painter, origin, board, font, selected, zoom)` — highlights all object cells in the Objects editor tab; the selected object gets a brighter border
|
||||
- `board_origin(available, board_w, board_h, player, font, zoom)` — centers board or clamps to player with no empty space
|
||||
- `pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)` — pixel → cell coordinates; negatives signal out-of-bounds
|
||||
|
||||
**`src/editor.rs`** — editor state and side panel:
|
||||
- `EditorTab` enum (`Palette` | `Board` | `Objects` | `World`) — which tab is active
|
||||
- `EditorState` — holds `selected: Archetype`, `glyph: Glyph`, `glyph_picker_open: bool`, `tab: EditorTab`, `font_dialog_open: bool`, `font_dialog_state: FontDialogState`, `selected_object: Option<usize>`, `object_glyph_picker_open: bool`, `object_editing_glyph: Glyph`, `placing_object: bool`; selecting a new archetype resets `glyph` to that archetype's default
|
||||
- `show_editor_panel(ctx, editor, board, active_font)` — resizable right-side panel (default 200 px); Palette tab shows archetype list and glyph preview button; Board tab shows current font path and "Font…" button; Objects tab lists all board objects with click-to-select, glyph preview, **Passable/Opaque checkboxes**, and script combobox; World tab is a placeholder
|
||||
- `EditorTab` enum (`Palette` | `Objects` | `Board` | `Scripts` | `World`) — which tab is active
|
||||
- `EditorState` — `tab: EditorTab` plus four concern-grouped sub-structs so each tab takes only the borrow it needs:
|
||||
- `palette: PaletteState` — `selected: Archetype`, `glyph: Glyph`, `picker_open: bool` (selecting a new archetype resets `glyph` to its default)
|
||||
- `font_dialog: FontDialog` — `open: bool`, `state: FontDialogState`
|
||||
- `objects: ObjectEdit` — `selected: Option<usize>`, `picker_open: bool`, `editing_glyph: Glyph`, `placing: bool`
|
||||
- `scripts: ScriptEdit` — `editing: Option<String>`, `content: String`, `new_name: String`
|
||||
- `show_editor_panel(ctx, editor, board, active_font)` — resizable right-side panel (default 200 px); renders the tab bar then dispatches to a per-tab function (`palette_tab`, `objects_tab`, `board_tab`, `scripts_tab`; World is empty), passing the relevant sub-struct(s). Palette shows the archetype list and glyph preview button; Board shows the font path, "Font…" button, and zoom slider; Objects lists the selected object's glyph preview, **Passable/Opaque checkboxes**, and script combobox; Scripts creates/edits named scripts
|
||||
|
||||
**`src/glyph_picker.rs`** — floating glyph picker dialog:
|
||||
- `show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState`
|
||||
|
||||
Generated
+10
@@ -955,6 +955,15 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_code_editor"
|
||||
version = "0.2.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39a1b847b0ff5a3bac4e8604eca605808970bbcdaba76b3101db1051e4206dc6"
|
||||
dependencies = [
|
||||
"egui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.33.3"
|
||||
@@ -1726,6 +1735,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"color",
|
||||
"eframe",
|
||||
"egui_code_editor",
|
||||
"image",
|
||||
"kiln-core",
|
||||
"rfd",
|
||||
|
||||
@@ -14,3 +14,4 @@ color = "0.3.3"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
rfd = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
egui_code_editor = "=0.2.20"
|
||||
|
||||
+255
-183
@@ -3,7 +3,7 @@ use egui::{Sense, vec2};
|
||||
|
||||
use crate::font::BitmapFont;
|
||||
use crate::font_dialog::{FontDialogState, show as show_font_dialog};
|
||||
use crate::render::{PALETTE_PANEL_W, paint_glyph};
|
||||
use crate::render::{PALETTE_PANEL_W, glyph_preview_button, paint_glyph};
|
||||
use kiln_core::game::{ALL_ARCHETYPES, Archetype, Board, Glyph};
|
||||
|
||||
/// Which tab is active in the editor side panel.
|
||||
@@ -21,49 +21,94 @@ pub(crate) enum EditorTab {
|
||||
World,
|
||||
}
|
||||
|
||||
/// Transient editor state. Only meaningful when the app is in Edit mode.
|
||||
pub(crate) struct EditorState {
|
||||
/// Palette-tab state: what gets stamped when painting cells.
|
||||
pub(crate) struct PaletteState {
|
||||
/// The archetype that will be stamped when the user clicks a cell.
|
||||
pub(crate) selected: Archetype,
|
||||
/// The glyph (tile index + colors) to stamp. Independent of the archetype's default.
|
||||
pub(crate) glyph: Glyph,
|
||||
/// Whether the glyph picker dialog is currently open (Palette tab).
|
||||
pub(crate) glyph_picker_open: bool,
|
||||
pub(crate) picker_open: bool,
|
||||
}
|
||||
|
||||
/// Font-dialog state: the floating font picker and its in-progress edit.
|
||||
pub(crate) struct FontDialog {
|
||||
/// Whether the font picker dialog is currently open.
|
||||
pub(crate) font_dialog_open: bool,
|
||||
pub(crate) open: bool,
|
||||
/// In-progress state for the font dialog while it is open.
|
||||
pub(crate) font_dialog_state: FontDialogState,
|
||||
/// Which side-panel tab is currently shown.
|
||||
pub(crate) tab: EditorTab,
|
||||
pub(crate) state: FontDialogState,
|
||||
}
|
||||
|
||||
/// Objects-tab state: selection, placement, and the glyph being edited.
|
||||
pub(crate) struct ObjectEdit {
|
||||
/// Index into `board.objects` of the currently selected object, or `None`.
|
||||
pub(crate) selected_object: Option<usize>,
|
||||
pub(crate) selected: Option<usize>,
|
||||
/// Whether the glyph picker is open for editing the selected object's glyph.
|
||||
pub(crate) object_glyph_picker_open: bool,
|
||||
pub(crate) picker_open: bool,
|
||||
/// Local copy of the selected object's glyph for the picker to edit.
|
||||
///
|
||||
/// Kept separate from `board.objects` to avoid a borrow conflict while the
|
||||
/// glyph picker holds `&mut Glyph` and the board is also borrowed. Written
|
||||
/// back to `board.objects[i].glyph` each frame while an object is selected.
|
||||
pub(crate) object_editing_glyph: Glyph,
|
||||
pub(crate) editing_glyph: Glyph,
|
||||
/// When `true`, the next board click in the Objects tab places a new object.
|
||||
/// Set by the "Add Object" button; cleared after placement or on tab switch.
|
||||
pub(crate) placing_object: bool,
|
||||
pub(crate) placing: bool,
|
||||
}
|
||||
|
||||
/// Scripts-tab state: the script open in the code editor and the new-script draft.
|
||||
pub(crate) struct ScriptEdit {
|
||||
/// Name of the script currently open in the code editor, or `None` if closed.
|
||||
pub(crate) editing: Option<String>,
|
||||
/// Working copy of the script body while the code editor is open.
|
||||
/// Written back to `board.scripts` when the user clicks "Back".
|
||||
pub(crate) content: String,
|
||||
/// Draft name typed into the "new script" text field in the Scripts tab.
|
||||
pub(crate) new_name: String,
|
||||
}
|
||||
|
||||
/// Transient editor state. Only meaningful when the app is in Edit mode.
|
||||
///
|
||||
/// Fields are grouped by concern into sub-structs so each editor tab can take
|
||||
/// the narrow borrow it needs rather than all of `EditorState`.
|
||||
pub(crate) struct EditorState {
|
||||
/// Which side-panel tab is currently shown.
|
||||
pub(crate) tab: EditorTab,
|
||||
/// Palette tab: archetype + glyph being painted.
|
||||
pub(crate) palette: PaletteState,
|
||||
/// The font picker dialog and its edit buffer.
|
||||
pub(crate) font_dialog: FontDialog,
|
||||
/// Objects tab: selection, placement, and object glyph editing.
|
||||
pub(crate) objects: ObjectEdit,
|
||||
/// Scripts tab: the open script and new-script draft.
|
||||
pub(crate) scripts: ScriptEdit,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Creates editor state with `Wall` selected and all dialogs closed.
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
selected: Archetype::Wall,
|
||||
glyph: Archetype::Wall.default_glyph(),
|
||||
glyph_picker_open: false,
|
||||
font_dialog_open: false,
|
||||
font_dialog_state: FontDialogState::from_spec(None),
|
||||
tab: EditorTab::Palette,
|
||||
selected_object: None,
|
||||
object_glyph_picker_open: false,
|
||||
object_editing_glyph: Glyph::player(), // overwritten on first selection
|
||||
placing_object: false,
|
||||
palette: PaletteState {
|
||||
selected: Archetype::Wall,
|
||||
glyph: Archetype::Wall.default_glyph(),
|
||||
picker_open: false,
|
||||
},
|
||||
font_dialog: FontDialog {
|
||||
open: false,
|
||||
state: FontDialogState::from_spec(None),
|
||||
},
|
||||
objects: ObjectEdit {
|
||||
selected: None,
|
||||
picker_open: false,
|
||||
editing_glyph: Glyph::player(), // overwritten on first selection
|
||||
placing: false,
|
||||
},
|
||||
scripts: ScriptEdit {
|
||||
editing: None,
|
||||
content: String::new(),
|
||||
new_name: String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,9 +125,6 @@ pub(crate) fn show_editor_panel(
|
||||
board: &mut Board,
|
||||
active_font: &BitmapFont,
|
||||
) {
|
||||
let tw = active_font.tile_w as f32;
|
||||
let th = active_font.tile_h as f32;
|
||||
|
||||
egui::SidePanel::right("palette_panel")
|
||||
.resizable(true)
|
||||
.default_width(PALETTE_PANEL_W)
|
||||
@@ -97,174 +139,204 @@ pub(crate) fn show_editor_panel(
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// Dispatch to the active tab's renderer, passing only the sub-struct each
|
||||
// tab needs. World is an empty placeholder.
|
||||
match editor.tab {
|
||||
EditorTab::Palette => {
|
||||
ui.label("Element");
|
||||
|
||||
// Scrollable archetype list; shows at most 5 rows before scrolling.
|
||||
let row_h = th + ui.spacing().item_spacing.y;
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(row_h * 5.0)
|
||||
.id_salt("arch_list")
|
||||
.show(ui, |ui| {
|
||||
for &archetype in ALL_ARCHETYPES {
|
||||
let is_selected = editor.selected == archetype;
|
||||
let glyph = archetype.default_glyph();
|
||||
ui.horizontal(|ui| {
|
||||
let (r, cell_resp) =
|
||||
ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||
paint_glyph(ui.painter(), r, &glyph, active_font);
|
||||
let label_resp =
|
||||
ui.selectable_label(is_selected, archetype.name());
|
||||
if cell_resp.clicked() || label_resp.clicked() {
|
||||
editor.selected = archetype;
|
||||
editor.glyph = archetype.default_glyph();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label("Glyph");
|
||||
// Paint a preview cell showing the current glyph, sized like a board cell.
|
||||
let (cell_rect, glyph_btn) =
|
||||
ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||
paint_glyph(ui.painter(), cell_rect, &editor.glyph, active_font);
|
||||
if glyph_btn.clicked() {
|
||||
editor.glyph_picker_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
EditorTab::Objects => {
|
||||
// "Add Object" toggles placement mode; next board click places a new object.
|
||||
let add_label = if editor.placing_object {
|
||||
"Cancel Add"
|
||||
} else {
|
||||
"Add Object"
|
||||
};
|
||||
if ui.button(add_label).clicked() {
|
||||
editor.placing_object = !editor.placing_object;
|
||||
editor.selected_object = None;
|
||||
}
|
||||
if editor.placing_object {
|
||||
ui.label("Click on the board to place.");
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
match editor.selected_object {
|
||||
None => {
|
||||
if !editor.placing_object {
|
||||
ui.label("Click an object on the board to select it.");
|
||||
}
|
||||
}
|
||||
Some(i) => {
|
||||
// Collect sorted script names before taking a mutable borrow on objects.
|
||||
let mut script_names: Vec<String> =
|
||||
board.scripts.keys().cloned().collect();
|
||||
script_names.sort();
|
||||
|
||||
ui.label("Glyph");
|
||||
// Preview button — same pattern as Palette tab.
|
||||
let (cell_rect, glyph_btn) =
|
||||
ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||
paint_glyph(
|
||||
ui.painter(),
|
||||
cell_rect,
|
||||
&editor.object_editing_glyph,
|
||||
active_font,
|
||||
);
|
||||
if glyph_btn.clicked() {
|
||||
editor.object_glyph_picker_open = true;
|
||||
}
|
||||
|
||||
let obj = &mut board.objects[i];
|
||||
|
||||
ui.checkbox(&mut obj.passable, "Passable");
|
||||
ui.checkbox(&mut obj.opaque, "Opaque");
|
||||
|
||||
let current =
|
||||
obj.script_name.as_deref().unwrap_or("(none)").to_string();
|
||||
|
||||
ui.label("Script");
|
||||
egui::ComboBox::from_id_salt("obj_script")
|
||||
.selected_text(¤t)
|
||||
.show_ui(ui, |ui| {
|
||||
// "(none)" clears the script assignment.
|
||||
if ui
|
||||
.selectable_label(obj.script_name.is_none(), "(none)")
|
||||
.clicked()
|
||||
{
|
||||
obj.script_name = None;
|
||||
}
|
||||
for name in &script_names {
|
||||
let is_sel =
|
||||
obj.script_name.as_deref() == Some(name.as_str());
|
||||
if ui.selectable_label(is_sel, name.as_str()).clicked() {
|
||||
obj.script_name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
// Placeholder for future "create and edit a new script" flow.
|
||||
let _ = ui.selectable_label(false, "(create new...)");
|
||||
});
|
||||
|
||||
if ui.button("Edit Script").clicked() {
|
||||
// TODO: open script editor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorTab::Board => {
|
||||
let font_label = match &board.font {
|
||||
Some(s) => s.path.clone(),
|
||||
None => "(default)".to_owned(),
|
||||
};
|
||||
ui.label(format!("Font: {font_label}"));
|
||||
if ui.button("Font…").clicked() {
|
||||
editor.font_dialog_state =
|
||||
FontDialogState::from_spec(board.font.as_ref());
|
||||
editor.font_dialog_open = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.label("Zoom");
|
||||
ui.add(egui::Slider::new(&mut board.zoom, 1..=8).text("×"));
|
||||
}
|
||||
|
||||
EditorTab::Scripts => {
|
||||
if board.scripts.is_empty() {
|
||||
ui.label("No scripts defined.");
|
||||
} else {
|
||||
let mut names: Vec<&String> = board.scripts.keys().collect();
|
||||
names.sort();
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("scripts_list")
|
||||
.show(ui, |ui| {
|
||||
for name in names {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(name);
|
||||
if ui.button("Edit").clicked() {
|
||||
// TODO: open script editor
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
EditorTab::Palette => palette_tab(ui, &mut editor.palette, active_font),
|
||||
EditorTab::Objects => objects_tab(
|
||||
ui,
|
||||
&mut editor.objects,
|
||||
&mut editor.scripts,
|
||||
board,
|
||||
active_font,
|
||||
),
|
||||
EditorTab::Board => board_tab(ui, &mut editor.font_dialog, board),
|
||||
EditorTab::Scripts => scripts_tab(ui, &mut editor.scripts, board),
|
||||
EditorTab::World => {}
|
||||
}
|
||||
});
|
||||
|
||||
// Font dialog floats outside the side panel; show it here so it renders on top.
|
||||
if editor.font_dialog_open {
|
||||
if editor.font_dialog.open {
|
||||
show_font_dialog(
|
||||
ctx,
|
||||
&mut editor.font_dialog_open,
|
||||
&mut editor.font_dialog_state,
|
||||
&mut editor.font_dialog.open,
|
||||
&mut editor.font_dialog.state,
|
||||
&mut board.font,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Palette tab: scrollable archetype list plus the current-glyph preview button.
|
||||
fn palette_tab(ui: &mut egui::Ui, palette: &mut PaletteState, active_font: &BitmapFont) {
|
||||
let tw = active_font.tile_w as f32;
|
||||
let th = active_font.tile_h as f32;
|
||||
|
||||
ui.label("Element");
|
||||
|
||||
// Scrollable archetype list; shows at most 5 rows before scrolling.
|
||||
let row_h = th + ui.spacing().item_spacing.y;
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(row_h * 5.0)
|
||||
.id_salt("arch_list")
|
||||
.show(ui, |ui| {
|
||||
for &archetype in ALL_ARCHETYPES {
|
||||
let is_selected = palette.selected == archetype;
|
||||
let glyph = archetype.default_glyph();
|
||||
ui.horizontal(|ui| {
|
||||
let (r, cell_resp) = ui.allocate_exact_size(vec2(tw, th), Sense::click());
|
||||
paint_glyph(ui.painter(), r, &glyph, active_font);
|
||||
let label_resp = ui.selectable_label(is_selected, archetype.name());
|
||||
if cell_resp.clicked() || label_resp.clicked() {
|
||||
palette.selected = archetype;
|
||||
palette.glyph = archetype.default_glyph();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.label("Glyph");
|
||||
// Preview swatch for the current glyph; clicking opens the glyph picker.
|
||||
if glyph_preview_button(ui, &palette.glyph, active_font).clicked() {
|
||||
palette.picker_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects tab: add/select objects and edit the selected object's glyph, flags, and script.
|
||||
fn objects_tab(
|
||||
ui: &mut egui::Ui,
|
||||
objects: &mut ObjectEdit,
|
||||
scripts: &mut ScriptEdit,
|
||||
board: &mut Board,
|
||||
active_font: &BitmapFont,
|
||||
) {
|
||||
// "Add Object" toggles placement mode; next board click places a new object.
|
||||
let add_label = if objects.placing {
|
||||
"Cancel Add"
|
||||
} else {
|
||||
"Add Object"
|
||||
};
|
||||
if ui.button(add_label).clicked() {
|
||||
objects.placing = !objects.placing;
|
||||
objects.selected = None;
|
||||
}
|
||||
if objects.placing {
|
||||
ui.label("Click on the board to place.");
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
let Some(i) = objects.selected else {
|
||||
if !objects.placing {
|
||||
ui.label("Click an object on the board to select it.");
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect sorted script names before taking a mutable borrow on board.objects.
|
||||
let mut script_names: Vec<String> = board.scripts.keys().cloned().collect();
|
||||
script_names.sort();
|
||||
|
||||
ui.label("Glyph");
|
||||
// Preview swatch for the object's glyph; clicking opens the glyph picker.
|
||||
if glyph_preview_button(ui, &objects.editing_glyph, active_font).clicked() {
|
||||
objects.picker_open = true;
|
||||
}
|
||||
|
||||
let obj = &mut board.objects[i];
|
||||
|
||||
ui.checkbox(&mut obj.passable, "Passable");
|
||||
ui.checkbox(&mut obj.opaque, "Opaque");
|
||||
|
||||
let current = obj.script_name.as_deref().unwrap_or("(none)").to_string();
|
||||
|
||||
ui.label("Script");
|
||||
egui::ComboBox::from_id_salt("obj_script")
|
||||
.selected_text(¤t)
|
||||
.show_ui(ui, |ui| {
|
||||
// "(none)" clears the script assignment.
|
||||
if ui
|
||||
.selectable_label(obj.script_name.is_none(), "(none)")
|
||||
.clicked()
|
||||
{
|
||||
obj.script_name = None;
|
||||
}
|
||||
for name in &script_names {
|
||||
let is_sel = obj.script_name.as_deref() == Some(name.as_str());
|
||||
if ui.selectable_label(is_sel, name.as_str()).clicked() {
|
||||
obj.script_name = Some(name.clone());
|
||||
}
|
||||
}
|
||||
// Placeholder for future "create and edit a new script" flow.
|
||||
let _ = ui.selectable_label(false, "(create new...)");
|
||||
});
|
||||
|
||||
if ui.button("Edit Script").clicked()
|
||||
&& let Some(ref script_name) = obj.script_name.clone()
|
||||
{
|
||||
scripts.content = board.scripts.get(script_name).cloned().unwrap_or_default();
|
||||
scripts.editing = Some(script_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Board tab: board-level font selection and zoom.
|
||||
fn board_tab(ui: &mut egui::Ui, font_dialog: &mut FontDialog, board: &mut Board) {
|
||||
let font_label = match &board.font {
|
||||
Some(s) => s.path.clone(),
|
||||
None => "(default)".to_owned(),
|
||||
};
|
||||
ui.label(format!("Font: {font_label}"));
|
||||
if ui.button("Font…").clicked() {
|
||||
font_dialog.state = FontDialogState::from_spec(board.font.as_ref());
|
||||
font_dialog.open = true;
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.label("Zoom");
|
||||
ui.add(egui::Slider::new(&mut board.zoom, 1..=8).text("×"));
|
||||
}
|
||||
|
||||
/// Scripts tab: create a named script and edit existing ones.
|
||||
fn scripts_tab(ui: &mut egui::Ui, scripts: &mut ScriptEdit, board: &mut Board) {
|
||||
// New script: text field + Create button.
|
||||
ui.horizontal(|ui| {
|
||||
ui.text_edit_singleline(&mut scripts.new_name);
|
||||
let can_create =
|
||||
!scripts.new_name.is_empty() && !board.scripts.contains_key(&scripts.new_name);
|
||||
if ui
|
||||
.add_enabled(can_create, egui::Button::new("Create"))
|
||||
.clicked()
|
||||
{
|
||||
let name = scripts.new_name.clone();
|
||||
board.scripts.insert(name.clone(), String::new());
|
||||
scripts.new_name.clear();
|
||||
scripts.content = String::new();
|
||||
scripts.editing = Some(name);
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
if board.scripts.is_empty() {
|
||||
ui.label("No scripts yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut names: Vec<String> = board.scripts.keys().cloned().collect();
|
||||
names.sort();
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("scripts_list")
|
||||
.show(ui, |ui| {
|
||||
for name in &names {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(name);
|
||||
if ui.button("Edit").clicked() {
|
||||
scripts.content = board.scripts.get(name).cloned().unwrap_or_default();
|
||||
scripts.editing = Some(name.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
use eframe::egui;
|
||||
use egui::{ColorImage, Rect, TextureHandle, TextureOptions, vec2};
|
||||
use egui::{ColorImage, Rect, TextureHandle, TextureOptions, Vec2, vec2};
|
||||
use image::GenericImageView;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -123,6 +123,17 @@ impl BitmapFont {
|
||||
compute_tile_uv(tile, self.tile_w, self.tile_h, self.img_w, self.img_h)
|
||||
}
|
||||
|
||||
/// Returns the pixel size of one rendered cell at the given integer `zoom`.
|
||||
///
|
||||
/// A cell is `tile_w × tile_h` scaled by `zoom`. Centralizes the
|
||||
/// `tile_w as f32 * zoom as f32` math used throughout rendering and hit-testing.
|
||||
pub fn cell_size(&self, zoom: u32) -> Vec2 {
|
||||
vec2(
|
||||
self.tile_w as f32 * zoom as f32,
|
||||
self.tile_h as f32 * zoom as f32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the number of tile columns in the font image.
|
||||
pub fn tile_cols(&self) -> u32 {
|
||||
(self.img_w / self.tile_w).max(1)
|
||||
|
||||
@@ -4,7 +4,7 @@ use eframe::egui;
|
||||
use egui::{Color32, Rect, Sense, Stroke, vec2};
|
||||
|
||||
use crate::font::BitmapFont;
|
||||
use crate::render::{paint_glyph, pos_to_cell, rgba8_to_color32};
|
||||
use crate::render::{color32_to_rgba8, paint_glyph, pos_to_cell, rgba8_to_color32};
|
||||
use kiln_core::game::{Archetype, Glyph};
|
||||
|
||||
/// Renders the floating glyph-picker dialog and updates `glyph` in place.
|
||||
@@ -70,7 +70,7 @@ pub(crate) fn show(
|
||||
&mut fg,
|
||||
egui::color_picker::Alpha::Opaque,
|
||||
);
|
||||
glyph.fg = color::Rgba8 { r: fg.r(), g: fg.g(), b: fg.b(), a: fg.a() };
|
||||
glyph.fg = color32_to_rgba8(fg);
|
||||
|
||||
let mut bg = rgba8_to_color32(glyph.bg);
|
||||
ui.label("BG");
|
||||
@@ -79,7 +79,7 @@ pub(crate) fn show(
|
||||
&mut bg,
|
||||
egui::color_picker::Alpha::Opaque,
|
||||
);
|
||||
glyph.bg = color::Rgba8 { r: bg.r(), g: bg.g(), b: bg.b(), a: bg.a() };
|
||||
glyph.bg = color32_to_rgba8(bg);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
+199
-137
@@ -7,6 +7,7 @@ mod font;
|
||||
mod font_dialog;
|
||||
mod glyph_picker;
|
||||
mod render;
|
||||
mod script_editor;
|
||||
|
||||
use editor::{EditorState, EditorTab, show_editor_panel};
|
||||
use font::BitmapFont;
|
||||
@@ -32,7 +33,8 @@ enum AppMode {
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let start_path = PathBuf::from("maps/start.toml");
|
||||
let board = map_file::load(start_path.to_str().unwrap()).expect("failed to load maps/start.toml");
|
||||
let board =
|
||||
map_file::load(start_path.to_str().unwrap()).expect("failed to load maps/start.toml");
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
@@ -103,63 +105,70 @@ impl App {
|
||||
.ok()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// --- Input ---
|
||||
// Arrow keys move the player only in Play mode.
|
||||
if self.mode == AppMode::Play {
|
||||
ctx.input(|i| {
|
||||
if i.key_pressed(Key::ArrowUp) {
|
||||
self.state.try_move(0, -1);
|
||||
}
|
||||
if i.key_pressed(Key::ArrowDown) {
|
||||
self.state.try_move(0, 1);
|
||||
}
|
||||
if i.key_pressed(Key::ArrowLeft) {
|
||||
self.state.try_move(-1, 0);
|
||||
}
|
||||
if i.key_pressed(Key::ArrowRight) {
|
||||
self.state.try_move(1, 0);
|
||||
}
|
||||
});
|
||||
/// The font currently used for rendering: the per-board font if loaded, else the default.
|
||||
fn active_font(&self) -> &BitmapFont {
|
||||
self.board_font.as_ref().unwrap_or(&self.default_font)
|
||||
}
|
||||
|
||||
/// Writes the board to `path`, logging any error. Does not change `current_file_path`.
|
||||
fn save_to(&self, path: &Path) {
|
||||
if let Err(e) = map_file::save(&self.state.board, path) {
|
||||
eprintln!("save error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Menu bar ---
|
||||
/// Prompts for a destination and saves there, recording it as the current file on success.
|
||||
fn save_as(&mut self) {
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("Map file", &["toml"])
|
||||
.save_file()
|
||||
{
|
||||
if let Err(e) = map_file::save(&self.state.board, &path) {
|
||||
eprintln!("save error: {e}");
|
||||
} else {
|
||||
self.current_file_path = Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Play-mode input: arrow keys move the player one cell.
|
||||
fn handle_input(&mut self, ctx: &egui::Context) {
|
||||
if self.mode != AppMode::Play {
|
||||
return;
|
||||
}
|
||||
// (key, dx, dy) movement table — iterated instead of four near-identical ifs.
|
||||
const MOVES: [(Key, i32, i32); 4] = [
|
||||
(Key::ArrowUp, 0, -1),
|
||||
(Key::ArrowDown, 0, 1),
|
||||
(Key::ArrowLeft, -1, 0),
|
||||
(Key::ArrowRight, 1, 0),
|
||||
];
|
||||
ctx.input(|i| {
|
||||
for (key, dx, dy) in MOVES {
|
||||
if i.key_pressed(key) {
|
||||
self.state.try_move(dx, dy);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Top menu bar: File (Save / Save As / Exit) and the Play/Edit mode toggle.
|
||||
fn menu_bar(&mut self, ctx: &egui::Context) {
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Save").clicked() {
|
||||
ui.close();
|
||||
let path = self.current_file_path.clone();
|
||||
if let Some(path) = path {
|
||||
if let Err(e) = map_file::save(&self.state.board, &path) {
|
||||
eprintln!("save error: {e}");
|
||||
}
|
||||
} else if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("Map file", &["toml"])
|
||||
.save_file()
|
||||
{
|
||||
if let Err(e) = map_file::save(&self.state.board, &path) {
|
||||
eprintln!("save error: {e}");
|
||||
} else {
|
||||
self.current_file_path = Some(path);
|
||||
}
|
||||
// Save to the known path if we have one; otherwise behave like Save As.
|
||||
match self.current_file_path.clone() {
|
||||
Some(path) => self.save_to(&path),
|
||||
None => self.save_as(),
|
||||
}
|
||||
}
|
||||
if ui.button("Save As…").clicked() {
|
||||
ui.close();
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("Map file", &["toml"])
|
||||
.save_file()
|
||||
{
|
||||
if let Err(e) = map_file::save(&self.state.board, &path) {
|
||||
eprintln!("save error: {e}");
|
||||
} else {
|
||||
self.current_file_path = Some(path);
|
||||
}
|
||||
}
|
||||
self.save_as();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Exit").clicked() {
|
||||
@@ -172,44 +181,51 @@ impl eframe::App for App {
|
||||
ui.selectable_value(&mut self.mode, AppMode::Edit, "Edit");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Editor side panel ---
|
||||
// Must be declared before CentralPanel so egui allocates its space first.
|
||||
// Track whether font_spec changed so we can reload the board font.
|
||||
let font_spec_before = self.state.board.font.clone();
|
||||
if self.mode == AppMode::Edit {
|
||||
// Split borrows explicitly: active_font borrows board_font/default_font,
|
||||
// while show_editor_panel takes &mut editor and &mut board separately.
|
||||
let active_font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
show_editor_panel(
|
||||
ctx,
|
||||
&mut self.editor,
|
||||
&mut self.state.board,
|
||||
active_font,
|
||||
);
|
||||
/// If a script is open for editing, fills the central panel with the code editor.
|
||||
///
|
||||
/// Returns `true` when it consumed the frame (the caller should early-return so
|
||||
/// the side panel and board are skipped). On "Back", writes the edited body back
|
||||
/// to `board.scripts` and closes the editor.
|
||||
fn try_show_script_editor(&mut self, ctx: &egui::Context) -> bool {
|
||||
if self.mode != AppMode::Edit {
|
||||
return false;
|
||||
}
|
||||
// If the board's font spec was changed by the editor, reload the board font.
|
||||
if self.state.board.font != font_spec_before {
|
||||
let spec = self.state.board.font.clone();
|
||||
self.apply_font_spec(ctx, spec.as_ref());
|
||||
}
|
||||
|
||||
// Snapshot the active font for this frame so all rendering uses the same one.
|
||||
// We re-borrow active_font here since apply_font_spec may have changed board_font.
|
||||
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
let zoom = self.state.board.zoom;
|
||||
let tw = font.tile_w as f32 * zoom as f32;
|
||||
let th = font.tile_h as f32 * zoom as f32;
|
||||
|
||||
// --- Board rendering ---
|
||||
// The board is drawn using the egui Painter API (direct 2D drawing).
|
||||
let Some(name) = self.editor.scripts.editing.clone() else {
|
||||
return false;
|
||||
};
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let board_w = self.state.board.width;
|
||||
let board_h = self.state.board.height;
|
||||
let board_px = vec2(board_w as f32 * tw, board_h as f32 * th);
|
||||
if script_editor::show(ui, &name, &mut self.editor.scripts.content) {
|
||||
self.state
|
||||
.board
|
||||
.scripts
|
||||
.insert(name, self.editor.scripts.content.clone());
|
||||
self.editor.scripts.editing = None;
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws the board viewport and returns the clicked cell in Edit mode, if any.
|
||||
///
|
||||
/// Play mode centers/player-tracks the viewport and never returns a click.
|
||||
/// Edit mode wraps the board in a `ScrollArea`, draws the object overlay on the
|
||||
/// Objects tab, and returns validated `(cx, cy)` cell coordinates on a click so
|
||||
/// the caller can dispatch to [`App::handle_board_click`] without holding the
|
||||
/// font borrow.
|
||||
fn show_board(&self, ctx: &egui::Context) -> Option<(usize, usize)> {
|
||||
let font = self.active_font();
|
||||
let zoom = self.state.board.zoom;
|
||||
let cell = font.cell_size(zoom);
|
||||
let board_w = self.state.board.width;
|
||||
let board_h = self.state.board.height;
|
||||
|
||||
let mut clicked_cell = None;
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
if self.mode == AppMode::Edit {
|
||||
// Edit mode: ScrollArea lets the user scroll when the board overflows.
|
||||
let board_px = vec2(board_w as f32 * cell.x, board_h as f32 * cell.y);
|
||||
egui::ScrollArea::new([true, true]).show(ui, |ui| {
|
||||
let (rect, response) = ui.allocate_exact_size(board_px, Sense::click());
|
||||
draw_board(ui.painter(), rect.min, &self.state.board, font, zoom);
|
||||
@@ -221,7 +237,7 @@ impl eframe::App for App {
|
||||
rect.min,
|
||||
&self.state.board,
|
||||
font,
|
||||
self.editor.selected_object,
|
||||
self.editor.objects.selected,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
@@ -229,35 +245,11 @@ impl eframe::App for App {
|
||||
if response.clicked()
|
||||
&& let Some(pos) = response.interact_pointer_pos()
|
||||
{
|
||||
let (cx, cy) = pos_to_cell(rect.min, pos, tw, th);
|
||||
let (cx, cy) = pos_to_cell(rect.min, pos, cell.x, cell.y);
|
||||
if cx >= 0 && cy >= 0 {
|
||||
let cx = cx as usize;
|
||||
let cy = cy as usize;
|
||||
let board = &mut self.state.board;
|
||||
if cx < board.width && cy < board.height {
|
||||
if self.editor.tab == EditorTab::Objects {
|
||||
if self.editor.placing_object {
|
||||
// Placement mode: create a new object here if none exists.
|
||||
if board.object_index_at(cx, cy).is_none() {
|
||||
board.objects.push(ObjectDef::new(cx, cy));
|
||||
}
|
||||
self.editor.placing_object = false;
|
||||
} else {
|
||||
// Selection mode: click to select an existing object.
|
||||
let found = board.object_index_at(cx, cy);
|
||||
self.editor.selected_object = found;
|
||||
self.editor.object_glyph_picker_open = false;
|
||||
if let Some(i) = found {
|
||||
// Snapshot the object's glyph for the picker.
|
||||
self.editor.object_editing_glyph =
|
||||
board.objects[i].glyph;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Other tabs: paint the grid cell (floor only).
|
||||
// Objects on this cell are left untouched — they sit on top.
|
||||
*board.get_mut(cx, cy) = (self.editor.glyph, self.editor.selected);
|
||||
}
|
||||
let (cx, cy) = (cx as usize, cy as usize);
|
||||
if cx < board_w && cy < board_h {
|
||||
clicked_cell = Some((cx, cy));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,39 +262,109 @@ impl eframe::App for App {
|
||||
draw_board(ui.painter(), origin, &self.state.board, font, zoom);
|
||||
}
|
||||
});
|
||||
clicked_cell
|
||||
}
|
||||
|
||||
// --- Glyph picker dialog (Palette tab) ---
|
||||
// Rendered after all panels so it floats on top.
|
||||
if self.editor.glyph_picker_open {
|
||||
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
glyph_picker::show(
|
||||
ctx,
|
||||
&mut self.editor.glyph_picker_open,
|
||||
&mut self.editor.glyph,
|
||||
&self.state.board.cells(),
|
||||
font,
|
||||
);
|
||||
/// Applies an Edit-mode board click at validated cell `(cx, cy)`.
|
||||
///
|
||||
/// On the Objects tab the click places a new object (placement mode) or selects
|
||||
/// an existing one; on any other tab it paints the grid cell (floor only),
|
||||
/// leaving objects that sit on top untouched.
|
||||
fn handle_board_click(&mut self, cx: usize, cy: usize) {
|
||||
let board = &mut self.state.board;
|
||||
if self.editor.tab != EditorTab::Objects {
|
||||
*board.get_mut(cx, cy) = (self.editor.palette.glyph, self.editor.palette.selected);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Object glyph picker (Objects tab) ---
|
||||
// Uses object_editing_glyph as the target to avoid aliasing board.cells.
|
||||
if self.editor.object_glyph_picker_open && self.editor.tab == EditorTab::Objects {
|
||||
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
glyph_picker::show(
|
||||
ctx,
|
||||
&mut self.editor.object_glyph_picker_open,
|
||||
&mut self.editor.object_editing_glyph,
|
||||
&self.state.board.cells(),
|
||||
font,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Write back the object editing glyph to the board each frame ---
|
||||
// Any change the picker made to object_editing_glyph is flushed here.
|
||||
if let Some(i) = self.editor.selected_object {
|
||||
if i < self.state.board.objects.len() {
|
||||
self.state.board.objects[i].glyph = self.editor.object_editing_glyph;
|
||||
if self.editor.objects.placing {
|
||||
// Placement mode: create a new object here if none exists.
|
||||
if board.object_index_at(cx, cy).is_none() {
|
||||
board.objects.push(ObjectDef::new(cx, cy));
|
||||
}
|
||||
self.editor.objects.placing = false;
|
||||
} else {
|
||||
// Selection mode: click to select an existing object.
|
||||
let found = board.object_index_at(cx, cy);
|
||||
self.editor.objects.selected = found;
|
||||
self.editor.objects.picker_open = false;
|
||||
if let Some(i) = found {
|
||||
// Snapshot the object's glyph for the picker.
|
||||
self.editor.objects.editing_glyph = board.objects[i].glyph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the floating glyph pickers (Palette and Objects tabs) on top of the board.
|
||||
fn show_glyph_pickers(&mut self, ctx: &egui::Context) {
|
||||
// Disjoint-field borrow: `font` reads board_font/default_font while the pickers
|
||||
// mutate editor fields and read board cells — all distinct fields, so allowed.
|
||||
// (`active_font()` can't be used here: it borrows all of `self`.)
|
||||
let font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
|
||||
if self.editor.palette.picker_open {
|
||||
glyph_picker::show(
|
||||
ctx,
|
||||
&mut self.editor.palette.picker_open,
|
||||
&mut self.editor.palette.glyph,
|
||||
self.state.board.cells(),
|
||||
font,
|
||||
);
|
||||
}
|
||||
|
||||
// Uses objects.editing_glyph as the target to avoid aliasing board.cells.
|
||||
if self.editor.objects.picker_open && self.editor.tab == EditorTab::Objects {
|
||||
glyph_picker::show(
|
||||
ctx,
|
||||
&mut self.editor.objects.picker_open,
|
||||
&mut self.editor.objects.editing_glyph,
|
||||
self.state.board.cells(),
|
||||
font,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Each phase is a focused method; this reads as the sequence of frame steps.
|
||||
self.handle_input(ctx);
|
||||
self.menu_bar(ctx);
|
||||
|
||||
// When a script is open, the code editor takes over the frame entirely.
|
||||
if self.try_show_script_editor(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Editor side panel (Edit mode) ---
|
||||
// Must be shown before CentralPanel so egui allocates its space first.
|
||||
// Snapshot the font spec so we can reload the board font if the panel changed it.
|
||||
let font_spec_before = self.state.board.font.clone();
|
||||
if self.mode == AppMode::Edit {
|
||||
// Disjoint-field borrow: active_font reads board_font/default_font while
|
||||
// show_editor_panel takes &mut editor and &mut board (distinct fields).
|
||||
let active_font = self.board_font.as_ref().unwrap_or(&self.default_font);
|
||||
show_editor_panel(ctx, &mut self.editor, &mut self.state.board, active_font);
|
||||
}
|
||||
if self.state.board.font != font_spec_before {
|
||||
let spec = self.state.board.font.clone();
|
||||
self.apply_font_spec(ctx, spec.as_ref());
|
||||
}
|
||||
|
||||
// --- Board viewport ---
|
||||
// show_board draws and reports the clicked cell; the click is dispatched
|
||||
// afterward so the &mut handler doesn't conflict with the font borrow.
|
||||
if let Some((cx, cy)) = self.show_board(ctx) {
|
||||
self.handle_board_click(cx, cy);
|
||||
}
|
||||
|
||||
// --- Floating glyph pickers, drawn last so they sit on top ---
|
||||
self.show_glyph_pickers(ctx);
|
||||
|
||||
// Flush any picker edit of the selected object's glyph back to the board.
|
||||
if let Some(i) = self.editor.objects.selected
|
||||
&& i < self.state.board.objects.len()
|
||||
{
|
||||
self.state.board.objects[i].glyph = self.editor.objects.editing_glyph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-15
@@ -35,6 +35,19 @@ pub(crate) fn rgba8_to_color32(c: color::Rgba8) -> Color32 {
|
||||
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||
}
|
||||
|
||||
/// Converts an egui [`Color32`] back to a core [`color::Rgba8`].
|
||||
///
|
||||
/// Inverse of [`rgba8_to_color32`]; used when reading colors out of egui's
|
||||
/// color-picker widgets back into a [`Glyph`].
|
||||
pub(crate) fn color32_to_rgba8(c: Color32) -> color::Rgba8 {
|
||||
color::Rgba8 {
|
||||
r: c.r(),
|
||||
g: c.g(),
|
||||
b: c.b(),
|
||||
a: c.a(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate height of the egui menu bar in pixels, used for window sizing.
|
||||
pub(crate) const MENU_H: f32 = 24.0;
|
||||
/// Default inner margin that egui's CentralPanel adds on all sides, in pixels.
|
||||
@@ -61,6 +74,21 @@ pub(crate) fn paint_glyph(painter: &egui::Painter, rect: Rect, glyph: &Glyph, fo
|
||||
painter.image(font.texture.id(), rect, uv, rgba8_to_color32(glyph.fg));
|
||||
}
|
||||
|
||||
/// Allocates a one-cell preview swatch showing `glyph` and returns its click [`Response`].
|
||||
///
|
||||
/// The cell is sized to the font's unzoomed tile dimensions and painted with
|
||||
/// `glyph`. Editor tabs use this for the editable glyph swatch; the caller
|
||||
/// inspects the returned [`Response`] to open a glyph picker on click.
|
||||
pub(crate) fn glyph_preview_button(
|
||||
ui: &mut egui::Ui,
|
||||
glyph: &Glyph,
|
||||
font: &BitmapFont,
|
||||
) -> egui::Response {
|
||||
let (rect, response) = ui.allocate_exact_size(font.cell_size(1), egui::Sense::click());
|
||||
paint_glyph(ui.painter(), rect, glyph, font);
|
||||
response
|
||||
}
|
||||
|
||||
/// Draws a single cell at grid position `(x, y)` using the given glyph and font.
|
||||
///
|
||||
/// `origin` is the pixel position of cell `(0, 0)` — the top-left corner of
|
||||
@@ -75,10 +103,9 @@ pub(crate) fn draw_glyph(
|
||||
font: &BitmapFont,
|
||||
zoom: u32,
|
||||
) {
|
||||
let tw = font.tile_w as f32 * zoom as f32;
|
||||
let th = font.tile_h as f32 * zoom as f32;
|
||||
let tl = origin + vec2(x as f32 * tw, y as f32 * th);
|
||||
let rect = Rect::from_min_size(tl, vec2(tw, th));
|
||||
let cell = font.cell_size(zoom);
|
||||
let tl = origin + vec2(x as f32 * cell.x, y as f32 * cell.y);
|
||||
let rect = Rect::from_min_size(tl, cell);
|
||||
paint_glyph(painter, rect, glyph, font);
|
||||
}
|
||||
|
||||
@@ -133,20 +160,19 @@ pub(crate) fn draw_object_overlays(
|
||||
selected: Option<usize>,
|
||||
zoom: u32,
|
||||
) {
|
||||
let tw = font.tile_w as f32 * zoom as f32;
|
||||
let th = font.tile_h as f32 * zoom as f32;
|
||||
let cell = font.cell_size(zoom);
|
||||
|
||||
// Dim the whole board so non-object cells recede visually.
|
||||
let board_rect = Rect::from_min_size(
|
||||
origin,
|
||||
vec2(board.width as f32 * tw, board.height as f32 * th),
|
||||
vec2(board.width as f32 * cell.x, board.height as f32 * cell.y),
|
||||
);
|
||||
painter.rect_filled(board_rect, 0.0, Color32::from_black_alpha(160));
|
||||
|
||||
// Redraw each object cell at full brightness and surround it with an outline.
|
||||
for (i, obj) in board.objects.iter().enumerate() {
|
||||
let tl = origin + vec2(obj.x as f32 * tw, obj.y as f32 * th);
|
||||
let rect = Rect::from_min_size(tl, vec2(tw, th));
|
||||
let tl = origin + vec2(obj.x as f32 * cell.x, obj.y as f32 * cell.y);
|
||||
let rect = Rect::from_min_size(tl, cell);
|
||||
|
||||
// Redraw on top of the dim overlay so this cell remains bright.
|
||||
paint_glyph(painter, rect, &obj.glyph, font);
|
||||
@@ -186,17 +212,16 @@ pub(crate) fn board_origin(
|
||||
font: &BitmapFont,
|
||||
zoom: u32,
|
||||
) -> Pos2 {
|
||||
let tw = font.tile_w as f32 * zoom as f32;
|
||||
let th = font.tile_h as f32 * zoom as f32;
|
||||
let board_px_w = board_w as f32 * tw;
|
||||
let board_px_h = board_h as f32 * th;
|
||||
let cell = font.cell_size(zoom);
|
||||
let board_px_w = board_w as f32 * cell.x;
|
||||
let board_px_h = board_h as f32 * cell.y;
|
||||
|
||||
let origin_x = if board_px_w <= available.width() {
|
||||
// Board fits: center it horizontally.
|
||||
available.min.x + (available.width() - board_px_w) / 2.0
|
||||
} else {
|
||||
// Board overflows: keep player at viewport center, clamp to board edges.
|
||||
let player_px_x = player.x as f32 * tw + tw / 2.0;
|
||||
let player_px_x = player.x as f32 * cell.x + cell.x / 2.0;
|
||||
(available.center().x - player_px_x)
|
||||
.max(available.max.x - board_px_w) // right edge must reach viewport right
|
||||
.min(available.min.x) // left edge must reach viewport left
|
||||
@@ -205,7 +230,7 @@ pub(crate) fn board_origin(
|
||||
let origin_y = if board_px_h <= available.height() {
|
||||
available.min.y + (available.height() - board_px_h) / 2.0
|
||||
} else {
|
||||
let player_px_y = player.y as f32 * th + th / 2.0;
|
||||
let player_px_y = player.y as f32 * cell.y + cell.y / 2.0;
|
||||
(available.center().y - player_px_y)
|
||||
.max(available.max.y - board_px_h)
|
||||
.min(available.min.y)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use eframe::egui;
|
||||
use egui_code_editor::{CodeEditor, ColorTheme, Syntax};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Returns a [`Syntax`] definition for the Rhai scripting language.
|
||||
///
|
||||
/// Keyword sets are sourced from the official
|
||||
/// [rhaiscript/sublime-rhai](https://github.com/rhaiscript/sublime-rhai) grammar (MPL-2.0).
|
||||
pub(crate) fn rhai_syntax() -> Syntax {
|
||||
Syntax {
|
||||
language: "Rhai",
|
||||
case_sensitive: true,
|
||||
comment: "//",
|
||||
comment_multiline: ["/*", "*/"],
|
||||
hyperlinks: BTreeSet::new(),
|
||||
keywords: BTreeSet::from([
|
||||
"if", "else", "switch", "for", "in", "loop", "do", "while", "until", "break",
|
||||
"continue", "return", "throw", "try", "catch", "let", "const", "fn", "import",
|
||||
"export", "as", "private",
|
||||
]),
|
||||
// Boolean literals and context keywords get a distinct type color.
|
||||
types: BTreeSet::from(["true", "false", "this", "global"]),
|
||||
// Built-in Rhai functions highlighted as special identifiers.
|
||||
special: BTreeSet::from([
|
||||
"print",
|
||||
"debug",
|
||||
"type_of",
|
||||
"is_def_var",
|
||||
"is_def_fn",
|
||||
"is_shared",
|
||||
"call",
|
||||
"curry",
|
||||
"eval",
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the script code editor and header bar inside `ui`.
|
||||
///
|
||||
/// Returns `true` when the user clicks "Back", signalling the caller to save
|
||||
/// `content` back to `board.scripts` and close the editor.
|
||||
pub(crate) fn show(ui: &mut egui::Ui, name: &str, content: &mut String) -> bool {
|
||||
let mut done = false;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("◄ Back").clicked() {
|
||||
done = true;
|
||||
}
|
||||
ui.label(format!("Editing: {name}"));
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
CodeEditor::default()
|
||||
.id_source("script_editor")
|
||||
.with_rows(30)
|
||||
.with_fontsize(14.0)
|
||||
.with_theme(ColorTheme::GRUVBOX)
|
||||
.with_syntax(rhai_syntax())
|
||||
.show(ui, content);
|
||||
|
||||
done
|
||||
}
|
||||
Reference in New Issue
Block a user