feat: v0.3.0 Persistent + Z-Tiers + Input-Block

Layer z_tiers (hud/normal/top), persistent windows, and input_block
routing (none/self/all) on top of v0.2.0's multi-active + layout-slot
model. Render iterates per-tier (hud->normal->top); input dispatch
iterates reverse (top->hud, within-tier reverse-open-order); modal
input_block='all' swallows misses. Persistent windows open at
register-time and are exempt from arg-less close() (so ESC and default
triggers cannot dismiss persistent toolbars/HUDs).

Adds chromeless=true opt (suppresses panel-lib's own decoration so
in-game HUD widgets like map-editor's toolbar/layers/palette can paint
their own visual style over the full widget bounds).

Adds public panel.point_in_any_panel(x, y) — module-side canvas-click
gate that returns true if (x, y) falls within any open panel's
bounds. Modules read input directly via engine.input.*; they can use
this helper to skip canvas-paint when the click landed on a panel
widget area.
This commit is contained in:
Calic
2026-06-15 02:25:46 +02:00
parent cdf1f63895
commit 1fcdbd192b
3 changed files with 422 additions and 126 deletions

361
init.lua
View File

@@ -1,17 +1,22 @@
-- =====================================================================
-- lib-core.panel v0.2.0 — Generic overlay-panel framework (Multi-Active)
-- lib-core.panel v0.3.0 — Generic overlay-panel framework (Window-Manager)
--
-- v0.2.0 evolves the v0.1 single-active model into a multi-active
-- window-manager with 11 named layout-slots + custom-fn hook. All
-- v0.1.1 callers keep their semantics via a backward-compatibility
-- shim (open/close/is_open without id-arg operate on the focused panel
-- = last-opened).
-- v0.3.0 layers z_tiers (hud → normal → top), persistent windows, and
-- input_block routing (none/self/all) on top of v0.2.0's multi-active +
-- layout-slot model. Render iterates per-z-tier (hud → normal → top);
-- input dispatch iterates reverse (top → hud, within-tier reverse-open-
-- order). `persistent=true` registers a window as open-from-register-time
-- and exempts it from bw-compat close() (modder must explicit-close).
--
-- Provides:
-- - Widget registry + lifecycle (register, open, close, toggle)
-- - Multi-active open_order (last-opened = focused = topmost)
-- - Multi-active open_order (last-opened = focused = topmost within tier)
-- - 11 named layout-slot templates + opts.layout = function(sw, sh)
-- - Per-frame input dispatch with reverse-open-order hit-test
-- - Z-tier rendering: hud (bottom) → normal → top (drawn last)
-- - Persistent windows: open at register-time; ESC-equivalent no-op
-- - Input-block routing: none / self / all (modal swallows misses)
-- - panel.point_in_any_panel(x,y): public hit-test for module-side canvas
-- - Per-frame input dispatch with reverse-z-tier reverse-open-order hit
-- - Context-menu (show, hit-test, auto-close) on top of windows
-- - Default-trigger binding via lib-core.input (lazy-required)
-- - Optional pause-gate (any open widget with pause_on_open=true)
@@ -27,12 +32,11 @@
-- - engine.render.measure_text(text, font_size) → w, h
--
-- Screen-size limitation: engine.render does NOT expose get_screen_size()
-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.2 still falls
-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.3 still falls
-- back to 1280x720 constants matching the default Sporel window config.
--
-- DEFERRED (v0.2 non-goals):
-- - Persistent panels (always-on HUD widgets) → v0.3
-- - z_tier other than "normal" (modal, hud, system) → v0.4
-- DEFERRED (v0.3 non-goals):
-- - Focus API (focus / raise / lower) → v0.4
-- - Drag + resize → v0.4
-- - Keyboard navigation within widgets
-- - Panel animation (fade in/out)
@@ -125,6 +129,28 @@ local function estimate_menu_width(actions, padding)
return max_w
end
-- =====================================================================
-- v0.3.0 — Z-Tiers + Input-Block defaults
-- =====================================================================
-- Z-Tier constants. Bottom-to-top render order = HUD (drawn first =
-- background), then normal (toolbars/persistent panels), then top
-- (drawn last = modal dialogs). Input dispatch iterates reverse.
local VALID_Z_TIERS = { hud = true, normal = true, top = true }
local Z_TIER_ORDER = { "hud", "normal", "top" }
-- Default input_block per tier when widget does not specify one.
-- hud: pure cosmetic overlay, never absorbs input (HUD bars, status).
-- normal: absorbs clicks within own bounds (persistent toolbars).
-- top: modal; swallows clicks that miss it too (blocking dialogs).
local DEFAULT_INPUT_BLOCK_BY_TIER = {
hud = "none",
normal = "self",
top = "all",
}
local VALID_INPUT_BLOCK = { none = true, self = true, all = true }
-- =====================================================================
-- v0.2.0 — Layout slots (per Phase Panel-WM spec §4.1)
-- =====================================================================
@@ -235,6 +261,25 @@ local function render_one_window(widget_id, win, sw, sh)
local panel_x, panel_y, panel_w, panel_h = bounds.x, bounds.y, bounds.w, bounds.h
local padding = theme.padding
-- v0.3.0: chromeless widgets skip the panel-lib decorations (bg /
-- border / title bar / close-X) entirely. The widget gets the FULL
-- panel bounds as ctx.bounds (no title-bar inset). Intended for
-- in-game persistent HUD widgets (toolbars, layer-pickers, palettes)
-- that want to render their own chrome.
if win.opts.chromeless then
win._content_bounds = {
x = panel_x, y = panel_y, w = panel_w, h = panel_h,
}
win._close_button_rect = nil
local widget_ctx = {
bounds = win._content_bounds,
theme = M.get_theme(),
is_focused = (widget_id == open_order[#open_order]),
}
widget_def.render(widget_ctx)
return
end
-- Background
engine.render.draw_rect(panel_x, panel_y, panel_w, panel_h, theme.bg_color)
@@ -334,89 +379,119 @@ end
--- _dispatch_event(event): route a synthetic or real input event.
--- event = {kind="click", x, y, button="left"|"right"} or {kind="wheel", dy}
---
--- v0.3.0 routing:
--- 1. Context-menu (if open) always wins.
--- 2. Iterate reverse z_tier (top → normal → hud), within tier
--- reverse-open-order. For each open window:
--- - input_block="none": skip hit-test entirely (HUD passes through).
--- - else: hit-test bounds; on hit route to widget.
--- 3. If no window claimed the event AND any open window has
--- input_block="all": swallow (modal block).
--- 4. Else drop (no game-routing — module handles its own input).
local function _dispatch_event(event)
-- Context-menu always wins if open (consumes the next click).
if ctx_menu then
if dispatch_context_menu_event(event) then return end
end
-- Iterate reverse-open-order (last-opened = topmost gets first crack).
local sw, sh = get_screen_size()
for i = #open_order, 1, -1 do
local widget_id = open_order[i]
local win = windows[widget_id]
if win and win.open then
local b = resolve_bounds(win.opts.layout, sw, sh)
local saw_modal = false -- track if any input_block="all" window is open
-- Click events use hit-test against window bounds.
if event.kind == "click" then
if event.x and event.y
and event.x >= b.x and event.x < b.x + b.w
and event.y >= b.y and event.y < b.y + b.h then
-- Close-button intercept (top-right X): left-click closes
-- the window before forwarding to the widget.
if event.button == "left" then
local cb = win._close_button_rect
if not cb then
-- Render hasn't run yet (e.g. test-mode); compute now
cb = close_button_rect(b.x, b.y, b.w, theme.padding, theme.font_size_title)
-- Iterate reverse z-tier (top first), within tier reverse-open-order.
for ti = #Z_TIER_ORDER, 1, -1 do
local tier_name = Z_TIER_ORDER[ti]
for i = #open_order, 1, -1 do
local widget_id = open_order[i]
local win = windows[widget_id]
if win and win.open and win.opts.z_tier == tier_name then
if win.opts.input_block == "all" then
saw_modal = true
end
if win.opts.input_block ~= "none" then
local b = resolve_bounds(win.opts.layout, sw, sh)
if event.kind == "click" then
if event.x and event.y
and event.x >= b.x and event.x < b.x + b.w
and event.y >= b.y and event.y < b.y + b.h then
-- Close-button intercept (top-right X): left-click
-- closes the window before forwarding to the widget.
if event.button == "left" then
local cb = win._close_button_rect
if not cb then
cb = close_button_rect(b.x, b.y, b.w,
theme.padding, theme.font_size_title)
end
if event.x >= cb.x and event.x < cb.x + cb.w
and event.y >= cb.y and event.y < cb.y + cb.h then
M.close(widget_id)
return
end
end
local content = win._content_bounds
if not content then
if win.opts.chromeless then
content = { x = b.x, y = b.y, w = b.w, h = b.h }
else
local padding = theme.padding
local title_area_h = theme.font_size_title + padding * 2
content = {
x = b.x + padding,
y = b.y + title_area_h,
w = b.w - padding * 2,
h = b.h - title_area_h - padding,
}
end
end
local widget_ctx = {
bounds = content,
theme = M.get_theme(),
is_focused = (widget_id == open_order[#open_order]),
}
win.widget_def.handle_input(widget_ctx, event)
return
end
if event.x >= cb.x and event.x < cb.x + cb.w
and event.y >= cb.y and event.y < cb.y + cb.h then
M.close(widget_id)
elseif event.kind == "wheel" then
-- Wheel routes to the focused (last-opened) window
-- regardless of tier — once we reach the focused id
-- in tier-walk order we forward and stop.
if widget_id == open_order[#open_order] then
local content = win._content_bounds
if not content then
if win.opts.chromeless then
content = { x = b.x, y = b.y, w = b.w, h = b.h }
else
local padding = theme.padding
local title_area_h = theme.font_size_title + padding * 2
content = {
x = b.x + padding,
y = b.y + title_area_h,
w = b.w - padding * 2,
h = b.h - title_area_h - padding,
}
end
end
local widget_ctx = {
bounds = content,
theme = M.get_theme(),
is_focused = true,
}
win.widget_def.handle_input(widget_ctx, event)
return
end
end
-- Route click to widget
local content = win._content_bounds
if not content then
-- Render hasn't run; compute content bounds now
local padding = theme.padding
local title_area_h = theme.font_size_title + padding * 2
content = {
x = b.x + padding,
y = b.y + title_area_h,
w = b.w - padding * 2,
h = b.h - title_area_h - padding,
}
end
local widget_ctx = {
bounds = content,
theme = M.get_theme(),
is_focused = (widget_id == open_order[#open_order]),
}
win.widget_def.handle_input(widget_ctx, event)
return
end
-- v0.2.0: no input_block tiers yet — miss falls through to next window
-- (v0.3.0 will add modal-blocking)
elseif event.kind == "wheel" then
-- Wheel events route to the focused window (last-opened).
if widget_id == open_order[#open_order] then
local content = win._content_bounds
if not content then
local padding = theme.padding
local title_area_h = theme.font_size_title + padding * 2
content = {
x = b.x + padding,
y = b.y + title_area_h,
w = b.w - padding * 2,
h = b.h - title_area_h - padding,
}
end
local widget_ctx = {
bounds = content,
theme = M.get_theme(),
is_focused = true,
}
win.widget_def.handle_input(widget_ctx, event)
return
end
end
end
end
-- v0.2.0: events that hit no panel are dropped (no game-routing yet)
-- No window claimed the event. If any modal-block ("all") is open,
-- swallow the event so it does not reach the game-layer. Otherwise
-- drop it silently (modules read engine.input directly for canvas
-- interactions and can call panel.point_in_any_panel(x,y) to skip
-- clicks that fall within a panel area).
if saw_modal then return end
end
-- -----------------------------------------------------------------------
@@ -437,7 +512,12 @@ end
--- layout = "center" (default) | "left" | "right" | "top" | "bottom" |
--- "top-left" | "top-right" | "bottom-left" | "bottom-right" |
--- "left-half" | "right-half" | function(sw, sh) -> {x,y,w,h}
--- z_tier = "normal" (default; v0.2.0 only "normal" exposed)
--- z_tier = "hud" | "normal" (default) | "top"
--- persistent = bool (default false) — if true, window auto-opens at
--- register-time and panel.close() without arg becomes a
--- no-op when it's focused (modder must close(id) explicit).
--- input_block = "none" | "self" | "all" — defaults per tier:
--- hud → "none", normal → "self", top → "all".
function M.register(widget_id, widget_def, opts)
if type(widget_id) ~= "string" then
error("panel.register: widget_id must be a string, got " .. type(widget_id))
@@ -458,20 +538,49 @@ function M.register(widget_id, widget_def, opts)
error("panel.register: duplicate widget_id '" .. widget_id .. "'")
end
opts = opts or {}
-- v0.3.0: validate z_tier + input_block eagerly so typos loud-error
-- at register-time (not at the next frame's render/dispatch).
if opts.z_tier ~= nil and not VALID_Z_TIERS[opts.z_tier] then
error(string.format(
"panel.register: unknown z_tier '%s' (must be hud|normal|top)",
tostring(opts.z_tier)), 2)
end
if opts.input_block ~= nil and not VALID_INPUT_BLOCK[opts.input_block] then
error(string.format(
"panel.register: unknown input_block '%s' (must be none|self|all)",
tostring(opts.input_block)), 2)
end
-- Validate opts.layout eagerly (so unknown-slot errors surface at register, not later).
local sw, sh = get_screen_size()
local _validation_bounds = resolve_bounds(opts.layout, sw, sh)
_ = _validation_bounds -- discard; bounds re-resolved per-frame to react to screen-resize
local z_tier = opts.z_tier or "normal"
local input_block = opts.input_block or DEFAULT_INPUT_BLOCK_BY_TIER[z_tier]
local persistent = opts.persistent == true
local chromeless = opts.chromeless == true
widgets[widget_id] = widget_def
windows[widget_id] = {
widget_def = widget_def,
opts = {
layout = opts.layout, -- nil OR string OR fn
z_tier = opts.z_tier or "normal",
layout = opts.layout, -- nil OR string OR fn
z_tier = z_tier,
persistent = persistent,
input_block = input_block,
chromeless = chromeless,
},
open = false,
}
-- v0.3.0: persistent windows are open from register-time and pushed
-- onto open_order so they appear in render + hit-test immediately.
if persistent then
open_order[#open_order + 1] = widget_id
windows[widget_id].open = true
end
end
--- M.unregister(widget_id)
@@ -507,8 +616,11 @@ function M.open(widget_id)
end
--- M.close(widget_id?)
--- With id: closes that specific widget. Without id: closes the focused
--- (= last-opened) for v0.1.1 bw-compat.
--- With id: closes that specific widget (DOES close persistent — explicit
--- modder action). Without id: closes the focused (= last-opened) for
--- v0.1.1 bw-compat — BUT v0.3.0 makes this a no-op when the focused
--- window is persistent (ESC-equivalent should not dismiss persistent
--- toolbars/HUDs).
function M.close(widget_id)
if widget_id == nil then
-- bw-compat: close focused
@@ -518,9 +630,15 @@ function M.close(widget_id)
return
end
widget_id = open_order[#open_order]
-- v0.3.0: persistent windows are exempt from arg-less close
-- (so ESC / `panel.close()` cannot dismiss persistent panels).
if windows[widget_id] and windows[widget_id].opts.persistent then
return
end
end
local w = windows[widget_id]
if not w or not w.open then return end
-- Explicit close(id) DOES close persistent windows (modder-controlled).
w.open = false
local idx = find_in_array(open_order, widget_id)
if idx then table.remove(open_order, idx) end
@@ -564,6 +682,29 @@ function M.is_pausing()
return false
end
--- M.point_in_any_panel(x, y) → bool
--- Returns true if screen-coord (x,y) falls within the bounds of ANY
--- currently-open panel window (regardless of z_tier or input_block).
--- Intended for module-side canvas-click logic: modules read input
--- directly via engine.input.* but should skip canvas-paint when the
--- click landed within a panel-widget area. Returns false if no panels
--- are open.
function M.point_in_any_panel(x, y)
if #open_order == 0 then return false end
local sw, sh = get_screen_size()
for _, id in ipairs(open_order) do
local w = windows[id]
if w and w.open then
local b = resolve_bounds(w.opts.layout, sw, sh)
if x >= b.x and x < b.x + b.w
and y >= b.y and y < b.y + b.h then
return true
end
end
end
return false
end
-- -----------------------------------------------------------------------
-- Theme
-- -----------------------------------------------------------------------
@@ -726,20 +867,24 @@ end
-- -----------------------------------------------------------------------
--- M.render()
--- Must be called each render frame. Iterates open_order bottom-up
--- (last = focused = topmost) and draws each window. Renders any open
--- context-menu on top of all windows. No-op if nothing is open.
--- Must be called each render frame. v0.3.0: iterates per-z-tier
--- (hud → normal → top) and within each tier walks open_order bottom-up
--- so HUD draws first (= background) and modal top tier draws last (=
--- topmost). Renders any open context-menu on top of all windows. No-op
--- if nothing is open.
function M.render()
if #open_order == 0 then return end
local sw, sh = get_screen_size()
for _, widget_id in ipairs(open_order) do
local w = windows[widget_id]
if w and w.open then
render_one_window(widget_id, w, sw, sh)
for _, tier_name in ipairs(Z_TIER_ORDER) do
for _, widget_id in ipairs(open_order) do
local w = windows[widget_id]
if w and w.open and w.opts.z_tier == tier_name then
render_one_window(widget_id, w, sw, sh)
end
end
end
-- Context-menu (rendered on top of all windows)
-- Context-menu (rendered on top of all windows + tiers)
if ctx_menu then
render_context_menu()
end
@@ -794,4 +939,36 @@ function M._test_get_screen_size()
return get_screen_size()
end
--- M._test_get_render_order() — returns array of widget_ids in the
--- order M.render() would iterate them (per-tier hud→normal→top, within
--- tier in open_order). v0.3.0 addition.
function M._test_get_render_order()
local out = {}
for _, tier_name in ipairs(Z_TIER_ORDER) do
for _, widget_id in ipairs(open_order) do
local w = windows[widget_id]
if w and w.open and w.opts.z_tier == tier_name then
out[#out + 1] = widget_id
end
end
end
return out
end
--- M._test_get_input_block(widget_id) — returns the resolved
--- input_block string for an open window, or nil. v0.3.0 addition.
function M._test_get_input_block(widget_id)
local w = windows[widget_id]
if not w then return nil end
return w.opts.input_block
end
--- M._test_get_persistent(widget_id) — returns the persistent flag for
--- a registered window, or nil if not registered. v0.3.0 addition.
function M._test_get_persistent(widget_id)
local w = windows[widget_id]
if not w then return nil end
return w.opts.persistent
end
return M