feat: v0.4.0 migrate to lib-core.panel persistent widgets

Migrate the top toolbar, right-side Layers panel, and bottom palette
strip to lib-core.panel v0.3.0 persistent widgets (W.2 of the
panel-window-manager plan):

  map-editor.toolbar -- top strip (full width, ~290 px tall)
  map-editor.layers  -- right side-panel (160 px wide)
  map-editor.palette -- bottom strip (full width, 64 px tall)

All three registered as persistent=true (open from module-init, not
dismissed by ESC), input_block='self' (hit-test claims clicks within
own bounds; misses fall through to canvas), and chromeless=true
(panel-lib skips its title-bar/border/X — widgets keep their own
visual style).

Existing draw_menu_bar / draw_side_panel / draw_palette and
handle_click logic is wrapped via thin widget render + handle_input
fns (M.draw_toolbar_widget / M.handle_toolbar_click etc.); no
behavioural change to the widget interiors.

Map-canvas remains module-rendered. Canvas drag-paint is now gated on
panel.point_in_any_panel(mx, my) so widget clicks no longer bleed
through into map paint.

Removed state.show_layers_panel / show_palette and their toggle
helpers (panel-lib manages widget visibility; Window menu items now
flip panel.is_open/toggle on the widget ids directly). Modals stay
module-rendered + module-routed (handled by handle_modal_click
before panel.update).

Manifest bumped 0.3.4 -> 0.4.0; lib-core.panel dep added at 0.3.0.
This commit is contained in:
Calic
2026-06-15 02:26:20 +02:00
parent a150dd8dc5
commit 176305e35b
3 changed files with 281 additions and 92 deletions

342
init.lua
View File

@@ -1,4 +1,4 @@
-- sporel-module-map-editor v0.3.1
-- sporel-module-map-editor v0.4.0
-- Interactive map editor — Rev 4 UX architecture (see design paper
-- 2026-05-28-autotile-blob-styles-design.md §11 + plan stub
-- 2026-05-28-map-editor-blob-v2.md). 0.2.0b adds the bottom palette
@@ -22,7 +22,14 @@
-- active layer renders "S<slot>r<rot>" (and "f" when flip=1) via the
-- new lib-core.maps 0.5.5 cell_material_slot getter. Inline today;
-- extract to lib-sporel.debug-overlay when a 2nd consumer appears.
-- Decal/Entity sub-selectors + Material-Properties modal land in 0.2.0d.
-- 0.4.0 migrates the top-toolbar / right side-panel / bottom palette
-- to lib-core.panel v0.3.0 persistent widgets (z_tier="normal",
-- chromeless, input_block="self"). Canvas + status + modals stay
-- module-rendered. Canvas-paint is gated on
-- panel.point_in_any_panel(mx, my) so panel-widget clicks no longer
-- bleed through to drag-paint. Removed Window > Layers Panel + Palette
-- toggle items (panel-lib manages visibility; persistent widgets are
-- always on).
--
-- All editor logic in this single file: engine sandbox only allows
-- require() for declared lib-deps; multi-file modules either use
@@ -33,6 +40,7 @@
local maps = require("lib-core.maps")
local camera = require("lib-core.camera")
local input = require("lib-core.input")
local panel = require("lib-core.panel")
-- CI-mode auto-exit (matches vagrant-skeleton pattern). Manifest declares
-- ci_frames=30 but engine does not auto-enforce for kind:"module"; we count
@@ -70,8 +78,9 @@ local state = (function()
submenu_open = nil, -- 0.3.0: label of the open submenu item within menu_open
modal_open = nil, -- 0.3.0: nil | <modal_id> (single-stack)
modal_text_input = "", -- 0.3.0: scratch buffer for modal text inputs
show_layers_panel = true, -- 0.3.0: Window > Layers Panel toggle
show_palette = true, -- 0.3.0: Window > Palette toggle
-- 0.4.0: visibility of layers-panel + palette is now managed by
-- lib-core.panel (persistent widgets). The Window-menu items
-- flip panel.is_open("map-editor.{layers,palette}") directly.
mouse_cell = nil, -- {x, y} or nil if mouse off-map
snap_vertex = nil, -- 0.2.0b: {vx, vy} or nil (auto-tile mode snap target)
drag_paint = nil, -- 0.2.0c.1: nil | "paint" | "erase" (active drag-stroke mode)
@@ -179,11 +188,9 @@ local state = (function()
function M.get_submenu_open() return state.submenu_open end
function M.close_submenu() state.submenu_open = nil end
function M.is_layers_panel_shown() return state.show_layers_panel end
function M.toggle_layers_panel() state.show_layers_panel = not state.show_layers_panel end
function M.is_palette_shown() return state.show_palette end
function M.toggle_palette() state.show_palette = not state.show_palette end
-- 0.4.0: layers-panel + palette visibility moved to lib-core.panel
-- (persistent widgets managed via panel.is_open/toggle on the
-- "map-editor.layers" + "map-editor.palette" widget ids).
function M.set_mouse_cell(x, y)
if x == nil then
@@ -231,8 +238,6 @@ local state = (function()
state.submenu_open = nil
state.modal_open = nil
state.modal_text_input = ""
state.show_layers_panel = true
state.show_palette = true
state.debug_overlay = false
state.world_overlay = true
for name in pairs(state.layer_visible) do
@@ -586,13 +591,15 @@ local ui = (function()
end
end
-- 0.4.0: panel-lib manages widget visibility via panel.is_open/toggle.
-- The Window-menu items now flip the persistent widgets' open-state.
local WINDOW_ITEMS = {
{ type = "toggle", label = "Layers Panel",
is_on = function() return state.is_layers_panel_shown() end,
fire = function() state.toggle_layers_panel() end },
is_on = function() return panel.is_open("map-editor.layers") end,
fire = function() panel.toggle("map-editor.layers") end },
{ type = "toggle", label = "Palette",
is_on = function() return state.is_palette_shown() end,
fire = function() state.toggle_palette() end },
is_on = function() return panel.is_open("map-editor.palette") end,
fire = function() panel.toggle("map-editor.palette") end },
}
local HELP_ITEMS = {
@@ -1211,10 +1218,18 @@ local ui = (function()
end
-- =====================================================================
-- Public API
-- Public API (v0.4.0 split for panel-lib persistent widgets)
-- =====================================================================
--
-- The 3 persistent widgets (toolbar / layers / palette) get their own
-- render + handle_input pair. M.draw() now only paints the chrome that
-- is NOT a panel-widget: the bottom-left status chip and any open
-- modal overlay. Modals stay module-rendered + module-routed (they
-- are top-tier transient and need to fully absorb input via the
-- existing dispatch_modal_click path).
function M.draw()
-- Toolbar widget: menu-bar + mode-pill + open-dropdown render.
function M.draw_toolbar_widget(_ctx)
local mx, my = engine.input.get_mouse_pos()
draw_menu_bar(mx, my)
local open_name = state.get_menu_open()
@@ -1227,12 +1242,25 @@ local ui = (function()
end
end
draw_mode_pill()
if state.is_layers_panel_shown() then
draw_side_panel(mx, my)
end
if state.is_palette_shown() then
draw_palette(mx, my)
end
end
-- Layers widget: right-side panel with per-layer rows + roof toggle.
function M.draw_layers_widget(_ctx)
local mx, my = engine.input.get_mouse_pos()
draw_side_panel(mx, my)
end
-- Palette widget: bottom strip with tile-thumbnail swatches.
function M.draw_palette_widget(_ctx)
local mx, my = engine.input.get_mouse_pos()
draw_palette(mx, my)
end
-- M.draw() draws non-widget chrome: status chip + modal overlay.
-- Called by init.lua's render() AFTER panel.render(), so modals
-- correctly appear on top of all widget panels.
function M.draw()
local mx, my = engine.input.get_mouse_pos()
draw_status(mx, my)
local mid = state.get_modal_open()
if mid and MODALS[mid] then
@@ -1334,14 +1362,22 @@ local ui = (function()
end
end
-- Hit-test mouse click. Returns true if the click was consumed by UI.
-- button: "left" | "right"
function M.handle_click(mx, my, button)
-- Modal absorbs all clicks while open.
if dispatch_modal_click(mx, my, button) then
return true
end
-- v0.4.0: hit-test split into per-widget handlers + a module-level
-- modal dispatcher. The 3 widget panels (toolbar / layers / palette)
-- get their own handle_input fns; the modal-click flow stays at
-- the M.update level since modals are NOT panel widgets.
--- M.handle_modal_click(mx, my, button) — returns true if modal
--- consumed the click. Called BEFORE panel.update() so a modal can
--- swallow clicks before they reach widget dispatch.
function M.handle_modal_click(mx, my, button)
return dispatch_modal_click(mx, my, button)
end
--- M.handle_toolbar_click(mx, my, button) — handles menu-bar +
--- mode-pill + dropdown clicks. Wired into the toolbar widget's
--- handle_input via panel-lib. Returns true if consumed.
function M.handle_toolbar_click(mx, my, button)
-- Mode-pill
local pill = mode_pill_layout()
for _, seg in ipairs({ pill.auto, pill.cell, pill.direct }) do
@@ -1426,52 +1462,58 @@ local ui = (function()
end
end
-- Click outside the dropdown closes it (no return — fall through so
-- the click can also hit other UI like canvas).
-- Click outside the dropdown closes it (no return — fall
-- through so other UI can still hit-test the click).
state.close_menu()
end
-- Bottom palette swatches: left = select slot
if state.is_palette_shown() then
for _, sw in ipairs(palette_swatches_layout()) do
if in_rect(mx, my, sw.x, sw.y, sw.w, sw.h) then
if button == "left" then
actions.set_active_tile(sw.slot_id)
end
return true
end
end
end
-- Side-panel layer rows: left = set active; right = toggle visibility
if state.is_layers_panel_shown() then
for _, row in ipairs(side_panel_rows_layout()) do
if in_rect(mx, my, row.eye_x, row.eye_y, row.eye_w, row.eye_h) then
if button == "left" then
actions.toggle_layer_visible(row.name)
end
return true
end
if in_rect(mx, my, row.x, row.y, row.w, row.h) then
if button == "left" then
actions.set_active_layer(row.name)
elseif button == "right" then
actions.toggle_layer_visible(row.name)
end
return true
end
end
end
-- Status chip — no-op fallback to claim the click area
local s = status_layout()
if in_rect(mx, my, s.x, s.y, s.w, s.h) then
return true
end
return false -- click falls through to map-area handler in init.lua
return false
end
--- M.handle_layers_click(mx, my, button) — left = set active layer;
--- right = toggle visibility. Wired into the layers widget.
function M.handle_layers_click(mx, my, button)
for _, row in ipairs(side_panel_rows_layout()) do
if in_rect(mx, my, row.eye_x, row.eye_y, row.eye_w, row.eye_h) then
if button == "left" then
actions.toggle_layer_visible(row.name)
end
return true
end
if in_rect(mx, my, row.x, row.y, row.w, row.h) then
if button == "left" then
actions.set_active_layer(row.name)
elseif button == "right" then
actions.toggle_layer_visible(row.name)
end
return true
end
end
return false
end
--- M.handle_palette_click(mx, my, button) — left = select slot.
--- Wired into the palette widget.
function M.handle_palette_click(mx, my, button)
for _, sw in ipairs(palette_swatches_layout()) do
if in_rect(mx, my, sw.x, sw.y, sw.w, sw.h) then
if button == "left" then
actions.set_active_tile(sw.slot_id)
end
return true
end
end
return false
end
-- Expose layout fns for the widget custom layout-fns registered in
-- the Setup section. Each widget needs its bounds to match the
-- absolute screen coords the existing draw_* helpers use, so we
-- expose the layout fns directly.
M._side_panel_layout = side_panel_layout
M._palette_layout = palette_layout
M._MENU_BAR_H_const = MENU_BAR_H
-- Expose internal tables needed by self_test (test-only; not part of
-- the public draw/click API).
M._MENU_BAR_H = MENU_BAR_H
@@ -1513,7 +1555,88 @@ input.bind("reset_transform", { "0" }) -- 0.2.0c
input.bind("toggle_debug_overlay", { "d" }) -- 0.2.0c.3
input.bind("toggle_world_overlay", { "g" }) -- 0.2.0c.4
engine.print("map-editor v0.3.1: ready. Tab=cycle mode (Auto/Cell/Direct), I=help, G=grid, D=debug, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit / close")
-- =====================================================================
-- 0.4.0: panel-lib persistent widget registration (W.2)
-- =====================================================================
-- Three persistent + chromeless + input_block="self" widgets cover the
-- top toolbar, right-side layers panel, and bottom palette strip. Each
-- widget's bounds match what the existing draw_* helpers expect — they
-- still draw at absolute screen coords via engine.window.size(). The
-- panel-lib's chrome (bg/border/title/X) is suppressed via
-- chromeless=true; the widgets draw their own visual style.
--
-- Map-canvas is NOT a widget. Module renders it directly inside
-- camera.begin/finish; module's drag-paint dispatch is gated on
-- panel.point_in_any_panel(mx, my) so widget clicks never bleed
-- through into canvas paint.
-- Toolbar bounds = top strip (menu-bar height + room for open
-- dropdowns). Use a custom layout fn that covers the union of
-- menu-bar + worst-case dropdown extent.
local TOOLBAR_MAX_H = 290 -- menu-bar (24) + max dropdown rows (12 * 22 + pad)
local toolbar_widget = {
title = "Toolbar",
pause_on_open = false,
render = function(ctx) ui.draw_toolbar_widget(ctx) end,
handle_input = function(_ctx, event)
if event.kind == "click" then
ui.handle_toolbar_click(event.x, event.y, event.button)
end
end,
}
panel.register("map-editor.toolbar", toolbar_widget, {
layout = function(sw, _sh)
return { x = 0, y = 0, w = sw, h = TOOLBAR_MAX_H }
end,
z_tier = "normal",
persistent = true,
input_block = "self",
chromeless = true,
})
local layers_widget = {
title = "Layers",
pause_on_open = false,
render = function(ctx) ui.draw_layers_widget(ctx) end,
handle_input = function(_ctx, event)
if event.kind == "click" then
ui.handle_layers_click(event.x, event.y, event.button)
end
end,
}
panel.register("map-editor.layers", layers_widget, {
layout = function(_sw, _sh)
local p = ui._side_panel_layout()
return { x = p.x, y = p.y, w = p.w, h = p.h }
end,
z_tier = "normal",
persistent = true,
input_block = "self",
chromeless = true,
})
local palette_widget = {
title = "Palette",
pause_on_open = false,
render = function(ctx) ui.draw_palette_widget(ctx) end,
handle_input = function(_ctx, event)
if event.kind == "click" then
ui.handle_palette_click(event.x, event.y, event.button)
end
end,
}
panel.register("map-editor.palette", palette_widget, {
layout = function(_sw, _sh)
local p = ui._palette_layout()
return { x = p.x, y = p.y, w = p.w, h = p.h }
end,
z_tier = "normal",
persistent = true,
input_block = "self",
chromeless = true,
})
engine.print("map-editor v0.4.0: ready. Tab=cycle mode (Auto/Cell/Direct), I=help, G=grid, D=debug, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit / close")
-- =====================================================================
-- 0.3.0: self-tests (Spec §7.1). Run once on the first CI frame.
@@ -1527,7 +1650,9 @@ local function self_test()
-- t1: opening a menu then clicking outside closes it.
state.set_menu_open("File")
engine.test.equals(state.get_menu_open(), "File", "t1: menu opened by setter")
ui.handle_click(2, MENU_BAR_H + 10, "left")
-- 0.4.0: handle_toolbar_click + handle_modal_click split out of
-- handle_click; call the toolbar-specific path here.
ui.handle_toolbar_click(2, MENU_BAR_H + 10, "left")
engine.test.equals(state.get_menu_open(), nil, "t1: outside click closes menu")
-- t2: toggle item flips bound state.
@@ -1549,7 +1674,12 @@ local function self_test()
"t3: cheatsheet modal id set")
engine.test.equals(state.get_menu_open(), nil,
"t3: menu closed when modal opens")
ui.handle_click(2, MENU_BAR_H + 10, "left")
-- 0.4.0: modal-priority is enforced by handle_modal_click (called
-- before panel.update() in M.update). Verify it returns true so
-- a click would be swallowed before reaching the toolbar widget.
local modal_consumed = ui.handle_modal_click(2, MENU_BAR_H + 10, "left")
engine.test.equals(modal_consumed, true,
"t3: modal swallows clicks while open")
engine.test.equals(state.get_modal_open(), "cheatsheet",
"t3: outside click does not close modal")
state.close_modal()
@@ -1772,23 +1902,47 @@ function update(ctx, dt)
actions.toggle_world_overlay()
end
-- Mouse clicks + drag: hit-test UI on press; if consumed we never
-- enter drag-mode for this stroke. Otherwise the press initiates a
-- drag-stroke whose paint/erase action repeats for every subsequent
-- snap_vertex (or cell) while the button stays down. set_vertex /
-- set_override are idempotent + no-op when state is unchanged so
-- repeating per-frame is cheap.
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
local consumed = ui.handle_click(mx, my, "left")
if not consumed then
state.set_drag_paint("paint")
end
-- 0.4.0: input dispatch is split between module-level + panel-lib:
-- 1. handle_modal_click: module-level, ALWAYS runs first (modals
-- are top-most overlays and not panel widgets).
-- 2. panel.update(dt): routes clicks to persistent widgets via
-- their input_block="self" hit-tests. Widget handle_input
-- delegates to ui.handle_toolbar_click / handle_layers_click /
-- handle_palette_click.
-- 3. Canvas drag-paint: gated on (no modal AND point not inside
-- any panel widget) to prevent widget-clicks bleeding through
-- to the map canvas underneath.
--
-- Modal precedence: when a modal is open, we still consume the
-- click here (so it doesn't reach panel widgets), but we DON'T
-- start a drag-paint. panel.update still runs but its dispatch
-- routes the press as normal (mostly a no-op for modals since the
-- modal isn't a widget).
local lmb_pressed = engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT)
local rmb_pressed = engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT)
local modal_open = state.is_modal_open()
if lmb_pressed and modal_open then
ui.handle_modal_click(mx, my, "left")
end
if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then
local consumed = ui.handle_click(mx, my, "right")
if not consumed then
state.set_drag_paint("erase")
end
if rmb_pressed and modal_open then
ui.handle_modal_click(mx, my, "right")
end
-- Panel-lib dispatch: reads engine.input mouse state directly,
-- emits click + wheel events to widget handle_input fns. Widgets
-- routed by tier (top → normal → hud) with input_block="self".
panel.update(dt)
-- Canvas drag-paint trigger. Only initiate when no modal open AND
-- the click did not land within any panel widget area. Drag-paint
-- still continues per-frame as long as the mouse button stays
-- down (no per-frame panel-bounds check — the originating press
-- already established the click as a canvas-stroke).
if lmb_pressed and not modal_open and not panel.point_in_any_panel(mx, my) then
state.set_drag_paint("paint")
end
if rmb_pressed and not modal_open and not panel.point_in_any_panel(mx, my) then
state.set_drag_paint("erase")
end
-- Per-frame drag-paint dispatch (Auto-Tile uses snap_vertex; Direct
@@ -1837,5 +1991,11 @@ function render(ctx)
ui.draw_world_overlay(m_size.w, m_size.h, t_size, state.get_snap_vertex())
ui.draw_debug_overlay(m_size.w, m_size.h, t_size)
camera.finish()
ui.draw() -- screen-space overlays after camera ends
-- 0.4.0: panel.render() draws the 3 persistent widgets (toolbar +
-- layers + palette) on top of the canvas. ui.draw() then draws the
-- remaining non-widget chrome (status chip + open modal overlay)
-- ON TOP of the widgets — modals are last so they appear above any
-- widget panel.
panel.render()
ui.draw()
end