diff --git a/README.md b/README.md index 90ca027..aeee8cd 100644 --- a/README.md +++ b/README.md @@ -153,3 +153,31 @@ cells are structurally blind to their cell-tile neighbours. See `sporel-meta/docs/superpowers/specs/2026-06-01-map-editor-cell-tile-mode-design.md` for the full design rationale and the Boris-taxonomy background. + +## Panel-Lib Migration (v0.4.0) + +The top toolbar, right-side Layers panel, and bottom palette strip +have been migrated to `lib-core.panel` v0.3.0 persistent widgets +(W.2 of the panel-window-manager plan): + +| Widget ID | Layout slot | Role | +|-----------|-------------|------| +| `map-editor.toolbar` | top strip (full width, 290 px) | Menu bar + mode-pill + open-dropdown | +| `map-editor.layers` | right side-panel (160 px wide) | Layer rows + roof toggle | +| `map-editor.palette` | bottom strip (full width, 64 px) | Tile-thumbnail swatches | + +All three are 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 / close-X — the widgets paint +their own existing style). The map-canvas is NOT a panel widget — it +remains module-rendered between `camera.begin/finish`. Canvas drag-paint +is gated on `panel.point_in_any_panel(mx, my)` so widget clicks no +longer bleed through into the underlying map cells. + +The Window menu's `Layers Panel` / `Palette` toggles now flip the +persistent widgets' open-state via `panel.toggle(id)` directly. + +Modals (cheatsheet, save-as, map-properties, about) and the bottom-left +status chip stay module-rendered (drawn AFTER `panel.render()` so they +appear on top of widget panels). diff --git a/init.lua b/init.lua index 219da21..a7128ee 100644 --- a/init.lua +++ b/init.lua @@ -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 "Sr" (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 | (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 diff --git a/manifest.module b/manifest.module index d699b65..c4de0d6 100644 --- a/manifest.module +++ b/manifest.module @@ -1,6 +1,6 @@ { "id": "map-editor", - "version": "0.3.4", + "version": "0.4.0", "kind": "module", "api": "^0.1", "ci_frames": 30, @@ -14,6 +14,7 @@ {"id":"lib-core.composition","version":"0.3.0"}, {"id":"lib-core.camera","version":"0.3.0"}, {"id":"lib-core.input","version":"0.4.0"}, + {"id":"lib-core.panel","version":"0.3.0"}, {"id":"lib-asset.prototype-blob-geom","version":"0.1.0"} ] }