From a4b7a4968c85641dc7317dfdb5bca61fee2c89cf Mon Sep 17 00:00:00 2001 From: Ross Andrews Date: Sun, 17 May 2026 17:48:36 -0500 Subject: [PATCH] Add glyph chooser and redesign editor side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glyph chooser: a painted preview button in the Palette tab opens a floating dialog with a board-palette picker, fg/bg color pickers (egui built-in), and a 16×6 printable ASCII character grid. The stamped glyph is now independent of the archetype's default. Side panel: widened to 200 px (resizable), tabbed (Palette / Board / World), archetype rows show their default glyph cell, list scrolls after 5 entries, labels use ui.label() for consistent sizing. Co-Authored-By: Claude Sonnet 4.6 --- src/game.rs | 2 +- src/main.rs | 219 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 205 insertions(+), 16 deletions(-) diff --git a/src/game.rs b/src/game.rs index ea1330d..b951ba8 100644 --- a/src/game.rs +++ b/src/game.rs @@ -12,7 +12,7 @@ use serde::Deserialize; /// `Glyph` values come from the map file palette and are set at load time. /// The player is the only entity whose glyph is hardcoded at runtime /// (see [`Glyph::player`]). -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq)] pub struct Glyph { /// The character to display in this cell. pub ch: char, diff --git a/src/main.rs b/src/main.rs index 7c6154f..47703e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use eframe::egui; -use egui::{Align2, FontId, Key, Pos2, Rect, Sense, vec2}; +use egui::{Align2, Color32, FontId, Key, Pos2, Rect, Sense, Stroke, vec2}; mod game; mod map_file; @@ -14,8 +14,8 @@ const CELL_H: f32 = 20.0; const MENU_H: f32 = 24.0; /// Default inner margin that egui's CentralPanel adds on all sides, in pixels. const PANEL_MARGIN: f32 = 8.0; -/// Width of the editor palette side panel in pixels. -const PALETTE_PANEL_W: f32 = 120.0; +/// Default width of the editor side panel in pixels. The panel is resizable. +const PALETTE_PANEL_W: f32 = 200.0; /// Default window width in pixels. Independent of map size. const DEFAULT_WINDOW_W: f32 = 840.0; /// Default window height in pixels. Independent of map size. @@ -38,15 +38,37 @@ enum AppMode { Edit, } +/// Which tab is active in the editor side panel. +#[derive(Copy, Clone, PartialEq, Eq)] +enum EditorTab { + /// Archetype + glyph palette for painting cells. + Palette, + /// Board-level properties (placeholder). + Board, + /// World-level properties (placeholder). + World, +} + /// Transient editor state. Only meaningful when [`AppMode::Edit`] is active. struct EditorState { /// The archetype that will be stamped when the user clicks a cell. selected: Archetype, + /// The glyph (character + colors) to stamp. Independent of the archetype's default. + glyph: Glyph, + /// Whether the glyph picker dialog is currently open. + glyph_picker_open: bool, + /// Which side-panel tab is currently shown. + tab: EditorTab, } impl EditorState { fn new() -> Self { - Self { selected: Archetype::Wall } + Self { + selected: Archetype::Wall, + glyph: Archetype::Wall.default_glyph(), + glyph_picker_open: false, + tab: EditorTab::Palette, + } } } @@ -114,21 +136,78 @@ impl eframe::App for App { }); }); - // --- Editor palette panel --- + // --- Editor side panel --- // Must be declared before CentralPanel so egui allocates its space first. if self.mode == AppMode::Edit { egui::SidePanel::right("palette_panel") - .resizable(false) - .exact_width(PALETTE_PANEL_W) + .resizable(true) + .default_width(PALETTE_PANEL_W) .show(ctx, |ui| { - ui.heading("Elements"); + // Tab bar + ui.horizontal(|ui| { + ui.selectable_value(&mut self.editor.tab, EditorTab::Palette, "Palette"); + ui.selectable_value(&mut self.editor.tab, EditorTab::Board, "Board"); + ui.selectable_value(&mut self.editor.tab, EditorTab::World, "World"); + }); ui.separator(); - for archetype in ALL_ARCHETYPES { - ui.selectable_value( - &mut self.editor.selected, - *archetype, - archetype.name(), - ); + + match self.editor.tab { + EditorTab::Palette => { + ui.label("Element"); + + // Scrollable archetype list; shows at most 5 rows before scrolling. + let row_h = CELL_H + 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 = self.editor.selected == archetype; + let glyph = archetype.default_glyph(); + ui.horizontal(|ui| { + let (r, cell_resp) = ui.allocate_exact_size( + vec2(CELL_W, CELL_H), Sense::click()); + { + let p = ui.painter(); + p.rect_filled(r, 0.0, glyph.bg); + p.text(r.center(), Align2::CENTER_CENTER, + glyph.ch, FontId::monospace(CELL_H), glyph.fg); + } + if cell_resp.clicked() { + self.editor.selected = archetype; + } + if ui.selectable_label(is_selected, archetype.name()) + .clicked() + { + self.editor.selected = archetype; + } + }); + } + }); + + 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(CELL_W, CELL_H), Sense::click()); + { + let p = ui.painter(); + p.rect_filled(cell_rect, 0.0, self.editor.glyph.bg); + p.text( + cell_rect.center(), + Align2::CENTER_CENTER, + self.editor.glyph.ch, + FontId::monospace(CELL_H), + self.editor.glyph.fg, + ); + } + if glyph_btn.clicked() { + self.editor.glyph_picker_open = true; + } + } + EditorTab::Board => {} + EditorTab::World => {} } }); } @@ -176,7 +255,7 @@ impl eframe::App for App { let board = &mut self.state.board; if cx < board.width && cy < board.height { let arch = self.editor.selected; - *board.get_mut(cx, cy) = (arch.default_glyph(), arch); + *board.get_mut(cx, cy) = (self.editor.glyph, arch); } } } @@ -211,6 +290,116 @@ impl eframe::App for App { } }); + // --- Glyph picker dialog --- + // Rendered after all panels so it floats on top. + if self.editor.glyph_picker_open { + // Collect unique glyphs from the board before the window closure + // borrows self mutably, to avoid a conflict with self.state.board. + let board_glyphs: Vec = { + let mut seen: Vec<(u32, [u8; 4], [u8; 4])> = Vec::new(); + let mut out: Vec = Vec::new(); + for (glyph, _) in &self.state.board.cells { + let key = (glyph.ch as u32, glyph.fg.to_array(), glyph.bg.to_array()); + if !seen.contains(&key) { + seen.push(key); + out.push(*glyph); + } + } + out + }; + + // Use a local bool so the window's close button can write to it + // without conflicting with the closure's mutable borrow of self. + let mut picker_open = true; + egui::Window::new("Glyph") + .collapsible(false) + .resizable(false) + .open(&mut picker_open) + .show(ctx, |ui| { + // ----- Board palette ----- + ui.label("Board palette"); + ui.horizontal_wrapped(|ui| { + for &g in &board_glyphs { + let (r, resp) = + ui.allocate_exact_size(vec2(CELL_W, CELL_H), Sense::click()); + { + let p = ui.painter(); + p.rect_filled(r, 0.0, g.bg); + p.text(r.center(), Align2::CENTER_CENTER, g.ch, + FontId::monospace(CELL_H), g.fg); + if g == self.editor.glyph { + p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), + egui::epaint::StrokeKind::Middle); + } + } + if resp.clicked() { + self.editor.glyph = g; + } + } + }); + + ui.separator(); + + // ----- Colors ----- + ui.label("Colors"); + ui.horizontal(|ui| { + ui.label("FG"); + egui::color_picker::color_edit_button_srgba( + ui, &mut self.editor.glyph.fg, egui::color_picker::Alpha::Opaque); + ui.label("BG"); + egui::color_picker::color_edit_button_srgba( + ui, &mut self.editor.glyph.bg, egui::color_picker::Alpha::Opaque); + }); + + ui.separator(); + + // ----- Character grid ----- + // 16 columns × 6 rows covering printable ASCII 0x20–0x7E. + ui.label("Character"); + const CHAR_COLS: usize = 16; + let chars: Vec = + (0x20u32..=0x7eu32).filter_map(char::from_u32).collect(); + let rows = (chars.len() + CHAR_COLS - 1) / CHAR_COLS; + let grid_size = vec2(CHAR_COLS as f32 * CELL_W, rows as f32 * CELL_H); + let (grid_rect, grid_resp) = + ui.allocate_exact_size(grid_size, Sense::click()); + // Copy colors and selected char before painter borrow. + let fg = self.editor.glyph.fg; + let bg = self.editor.glyph.bg; + let cur_ch = self.editor.glyph.ch; + { + let p = ui.painter(); + for (i, &ch) in chars.iter().enumerate() { + let col = i % CHAR_COLS; + let row = i / CHAR_COLS; + let tl = grid_rect.min + + vec2(col as f32 * CELL_W, row as f32 * CELL_H); + let cell = Rect::from_min_size(tl, vec2(CELL_W, CELL_H)); + p.rect_filled(cell, 0.0, bg); + p.text(cell.center(), Align2::CENTER_CENTER, ch, + FontId::monospace(CELL_H), fg); + if ch == cur_ch { + p.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::WHITE), + egui::epaint::StrokeKind::Middle); + } + } + } + if grid_resp.clicked() { + if let Some(pos) = grid_resp.interact_pointer_pos() { + let rel = pos - grid_rect.min; + let col = (rel.x / CELL_W).floor() as usize; + let row = (rel.y / CELL_H).floor() as usize; + let idx = row * CHAR_COLS + col; + if let Some(&ch) = chars.get(idx) { + self.editor.glyph.ch = ch; + } + } + } + }); + // Window's close button sets picker_open to false. + self.editor.glyph_picker_open = picker_open; + } + // egui repaints automatically on input events, so no explicit // request_repaint() is needed while the game is purely event-driven. // Add it back (or call it from game logic) when continuous animation is needed.