diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 36cacf4..87c17c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` (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` — 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` (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` (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`. --- diff --git a/CLAUDE.md b/CLAUDE.md index 90661ea..6aa7c80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` - `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`, `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`, `picker_open: bool`, `editing_glyph: Glyph`, `placing: bool` + - `scripts: ScriptEdit` — `editing: Option`, `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` diff --git a/Cargo.lock b/Cargo.lock index 3b1eebc..f9c8316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/kiln-egui/Cargo.toml b/kiln-egui/Cargo.toml index 5f2c05a..921e11c 100644 --- a/kiln-egui/Cargo.toml +++ b/kiln-egui/Cargo.toml @@ -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" diff --git a/kiln-egui/src/editor.rs b/kiln-egui/src/editor.rs index a9d4836..4bcdfde 100644 --- a/kiln-egui/src/editor.rs +++ b/kiln-egui/src/editor.rs @@ -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, + pub(crate) selected: Option, /// 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, + /// 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 = - 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 = 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 = 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()); + } + }); + } + }); +} diff --git a/kiln-egui/src/font.rs b/kiln-egui/src/font.rs index f80681a..35cc607 100644 --- a/kiln-egui/src/font.rs +++ b/kiln-egui/src/font.rs @@ -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) diff --git a/kiln-egui/src/glyph_picker.rs b/kiln-egui/src/glyph_picker.rs index 4d622e9..93e2700 100644 --- a/kiln-egui/src/glyph_picker.rs +++ b/kiln-egui/src/glyph_picker.rs @@ -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(); diff --git a/kiln-egui/src/main.rs b/kiln-egui/src/main.rs index 71b2f13..09a99ad 100644 --- a/kiln-egui/src/main.rs +++ b/kiln-egui/src/main.rs @@ -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; + } + } } diff --git a/kiln-egui/src/render.rs b/kiln-egui/src/render.rs index e5a4f06..8f95831 100644 --- a/kiln-egui/src/render.rs +++ b/kiln-egui/src/render.rs @@ -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, 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) diff --git a/kiln-egui/src/script_editor.rs b/kiln-egui/src/script_editor.rs new file mode 100644 index 0000000..5e1fc5a --- /dev/null +++ b/kiln-egui/src/script_editor.rs @@ -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 +}