Files
kiln/kiln-egui/src/editor.rs
T
2026-06-03 17:45:43 -05:00

343 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use eframe::egui;
use egui::{Sense, vec2};
use crate::font::BitmapFont;
use crate::font_dialog::{FontDialogState, show as show_font_dialog};
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.
#[derive(Copy, Clone, PartialEq, Eq)]
pub(crate) enum EditorTab {
/// Archetype + glyph palette for painting cells.
Palette,
/// Object placement and script assignment.
Objects,
/// Board-level properties (font, board script).
Board,
/// Named script library for this board.
Scripts,
/// World-level properties (placeholder).
World,
}
/// 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) 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) open: bool,
/// In-progress state for the font dialog while it is open.
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: Option<usize>,
/// Whether the glyph picker is open for editing the selected object's glyph.
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) 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: 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 {
tab: EditorTab::Palette,
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(),
},
}
}
}
/// Renders the editor side panel (must be called before `CentralPanel`).
///
/// 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.
pub(crate) fn show_editor_panel(
ctx: &egui::Context,
editor: &mut EditorState,
board: &mut Board,
active_font: &BitmapFont,
) {
egui::SidePanel::right("palette_panel")
.resizable(true)
.default_width(PALETTE_PANEL_W)
.show(ctx, |ui| {
// Tab bar — wraps automatically if the panel is narrow.
ui.horizontal_wrapped(|ui| {
ui.selectable_value(&mut editor.tab, EditorTab::Palette, "Palette");
ui.selectable_value(&mut editor.tab, EditorTab::Objects, "Objects");
ui.selectable_value(&mut editor.tab, EditorTab::Board, "Board");
ui.selectable_value(&mut editor.tab, EditorTab::Scripts, "Scripts");
ui.selectable_value(&mut editor.tab, EditorTab::World, "World");
});
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 => 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 {
show_font_dialog(
ctx,
&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(&current)
.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());
}
});
}
});
}