2026-05-17 19:09:16 -05:00
|
|
|
|
use eframe::egui;
|
2026-05-18 22:01:21 -05:00
|
|
|
|
use egui::{Sense, vec2};
|
2026-05-17 19:09:16 -05:00
|
|
|
|
|
2026-05-19 00:07:04 -05:00
|
|
|
|
use crate::font::BitmapFont;
|
|
|
|
|
|
use crate::font_dialog::{FontDialogState, show as show_font_dialog};
|
2026-06-03 17:45:43 -05:00
|
|
|
|
use crate::render::{PALETTE_PANEL_W, glyph_preview_button, paint_glyph};
|
2026-05-30 20:20:09 -05:00
|
|
|
|
use kiln_core::game::{ALL_ARCHETYPES, Archetype, Board, Glyph};
|
2026-05-17 19:09:16 -05:00
|
|
|
|
|
|
|
|
|
|
/// Which tab is active in the editor side panel.
|
|
|
|
|
|
#[derive(Copy, Clone, PartialEq, Eq)]
|
|
|
|
|
|
pub(crate) enum EditorTab {
|
|
|
|
|
|
/// Archetype + glyph palette for painting cells.
|
|
|
|
|
|
Palette,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// Object placement and script assignment.
|
|
|
|
|
|
Objects,
|
|
|
|
|
|
/// Board-level properties (font, board script).
|
2026-05-17 19:09:16 -05:00
|
|
|
|
Board,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// Named script library for this board.
|
|
|
|
|
|
Scripts,
|
2026-05-17 19:09:16 -05:00
|
|
|
|
/// World-level properties (placeholder).
|
|
|
|
|
|
World,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
/// Palette-tab state: what gets stamped when painting cells.
|
|
|
|
|
|
pub(crate) struct PaletteState {
|
2026-05-17 19:09:16 -05:00
|
|
|
|
/// The archetype that will be stamped when the user clicks a cell.
|
|
|
|
|
|
pub(crate) selected: Archetype,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// The glyph (tile index + colors) to stamp. Independent of the archetype's default.
|
2026-05-17 19:09:16 -05:00
|
|
|
|
pub(crate) glyph: Glyph,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// Whether the glyph picker dialog is currently open (Palette tab).
|
2026-06-03 17:45:43 -05:00
|
|
|
|
pub(crate) picker_open: bool,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Font-dialog state: the floating font picker and its in-progress edit.
|
|
|
|
|
|
pub(crate) struct FontDialog {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// Whether the font picker dialog is currently open.
|
2026-06-03 17:45:43 -05:00
|
|
|
|
pub(crate) open: bool,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// In-progress state for the font dialog while it is open.
|
2026-06-03 17:45:43 -05:00
|
|
|
|
pub(crate) state: FontDialogState,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Objects-tab state: selection, placement, and the glyph being edited.
|
|
|
|
|
|
pub(crate) struct ObjectEdit {
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// Index into `board.objects` of the currently selected object, or `None`.
|
2026-06-03 17:45:43 -05:00
|
|
|
|
pub(crate) selected: Option<usize>,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// Whether the glyph picker is open for editing the selected object's glyph.
|
2026-06-03 17:45:43 -05:00
|
|
|
|
pub(crate) picker_open: bool,
|
2026-05-30 19:25:10 -05:00
|
|
|
|
/// Local copy of the selected object's glyph for the picker to edit.
|
2026-05-30 18:48:41 -05:00
|
|
|
|
///
|
2026-05-30 19:25:10 -05:00
|
|
|
|
/// 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.
|
2026-06-03 17:45:43 -05:00
|
|
|
|
pub(crate) editing_glyph: Glyph,
|
2026-05-30 19:25:10 -05:00
|
|
|
|
/// 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.
|
2026-06-03 17:45:43 -05:00
|
|
|
|
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,
|
2026-05-17 19:09:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl EditorState {
|
2026-05-19 00:07:04 -05:00
|
|
|
|
/// Creates editor state with `Wall` selected and all dialogs closed.
|
2026-05-17 19:09:16 -05:00
|
|
|
|
pub(crate) fn new() -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
tab: EditorTab::Palette,
|
2026-06-03 17:45:43 -05:00
|
|
|
|
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(),
|
|
|
|
|
|
},
|
2026-05-17 19:09:16 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Renders the editor side panel (must be called before `CentralPanel`).
|
|
|
|
|
|
///
|
2026-05-30 18:48:41 -05:00
|
|
|
|
/// Shows tab bar (Palette / Objects / Board / Scripts / World) and the content
|
|
|
|
|
|
/// for the active tab. `board` is the current board, used by the Objects and
|
|
|
|
|
|
/// Scripts tabs and to read/write the board font. `active_font` is the resolved
|
|
|
|
|
|
/// font currently in use for rendering.
|
2026-05-19 00:07:04 -05:00
|
|
|
|
pub(crate) fn show_editor_panel(
|
|
|
|
|
|
ctx: &egui::Context,
|
|
|
|
|
|
editor: &mut EditorState,
|
2026-05-30 18:48:41 -05:00
|
|
|
|
board: &mut Board,
|
2026-05-19 00:07:04 -05:00
|
|
|
|
active_font: &BitmapFont,
|
|
|
|
|
|
) {
|
2026-05-17 19:09:16 -05:00
|
|
|
|
egui::SidePanel::right("palette_panel")
|
|
|
|
|
|
.resizable(true)
|
|
|
|
|
|
.default_width(PALETTE_PANEL_W)
|
|
|
|
|
|
.show(ctx, |ui| {
|
2026-05-30 18:48:41 -05:00
|
|
|
|
// Tab bar — wraps automatically if the panel is narrow.
|
|
|
|
|
|
ui.horizontal_wrapped(|ui| {
|
2026-05-17 19:09:16 -05:00
|
|
|
|
ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette");
|
2026-05-30 18:48:41 -05:00
|
|
|
|
ui.selectable_value(&mut editor.tab, EditorTab::Objects, "Objects");
|
2026-05-19 00:07:04 -05:00
|
|
|
|
ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board");
|
2026-05-30 18:48:41 -05:00
|
|
|
|
ui.selectable_value(&mut editor.tab, EditorTab::Scripts, "Scripts");
|
2026-05-19 00:07:04 -05:00
|
|
|
|
ui.selectable_value(&mut editor.tab, EditorTab::World, "World");
|
2026-05-17 19:09:16 -05:00
|
|
|
|
});
|
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
// Dispatch to the active tab's renderer, passing only the sub-struct each
|
|
|
|
|
|
// tab needs. World is an empty placeholder.
|
2026-05-17 19:09:16 -05:00
|
|
|
|
match editor.tab {
|
2026-06-03 17:45:43 -05:00
|
|
|
|
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 => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-05-19 00:07:04 -05:00
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
// Font dialog floats outside the side panel; show it here so it renders on top.
|
|
|
|
|
|
if editor.font_dialog.open {
|
|
|
|
|
|
show_font_dialog(
|
|
|
|
|
|
ctx,
|
|
|
|
|
|
&mut editor.font_dialog.open,
|
|
|
|
|
|
&mut editor.font_dialog.state,
|
|
|
|
|
|
&mut board.font,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-30 19:25:10 -05:00
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
/// 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");
|
2026-05-30 18:48:41 -05:00
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
// 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();
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|
2026-06-03 17:45:43 -05:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-05-31 23:51:51 -05:00
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
ui.separator();
|
2026-05-19 00:07:04 -05:00
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-30 18:48:41 -05:00
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
/// 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;
|
2026-05-17 19:09:16 -05:00
|
|
|
|
}
|
2026-06-03 17:45:43 -05:00
|
|
|
|
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...)");
|
2026-05-17 19:09:16 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-03 17:45:43 -05:00
|
|
|
|
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;
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|
2026-06-03 17:45:43 -05:00
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-05-19 00:07:04 -05:00
|
|
|
|
}
|