Files
sporel-lib-core.panel/init.lua
Calic 9ed60d3877 feat: close button (X) in panel title bar
Renders an X glyph in the top-right of every panel's title bar; left-
click on it calls M.close(). Hit-test in _dispatch_event runs before
the widget-forward path so the click is consumed by the panel-frame
even when a widget would otherwise handle it. Pairs with the existing
ESC-close-pattern that lives at module level.
2026-06-14 02:39:04 +02:00

573 lines
22 KiB
Lua
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.
-- =====================================================================
-- lib-core.panel v0.1.0 — Generic overlay-panel framework
--
-- Provides a single-active-widget panel system with:
-- - Widget registry + lifecycle (register, open, close, toggle)
-- - Centrally-rendered overlay with configurable theme
-- - Per-frame input dispatch (mouse click + wheel, edge-detected)
-- - Context-menu (show, hit-test, auto-close)
-- - Default-trigger binding via lib-core.input (lazy-required)
-- - Optional pause-gate (pause_on_open=true widget makes is_pausing()=true)
--
-- Engine surfaces used:
-- - engine.input.get_mouse_pos() → x, y
-- - engine.input.is_mouse_down(button) → bool
-- - engine.input.get_mouse_wheel() → number
-- - engine.input.MOUSE_LEFT / MOUSE_RIGHT
-- - engine.render.draw_rect(x, y, w, h, color)
-- - engine.render.draw_rect_lines(x, y, w, h, color, thickness)
-- - engine.render.draw_text(text, x, y, font_size, color)
-- - 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.1 falls back to
-- 1280x720 constants matching the default Sporel window config.
--
-- DEFERRED (v0.1 non-goals):
-- - Multi-widget z-order / stacking
-- - Always-on HUD widgets (non-modal overlays)
-- - Keyboard navigation within widgets
-- - Panel animation (fade in/out)
-- =====================================================================
-- -----------------------------------------------------------------------
-- Module-level state (all local — no globals)
-- -----------------------------------------------------------------------
-- Module-table declared early so closures in module-local helpers below
-- can reference M.X functions. Method assignments (function M.x() ... end)
-- still land further down once the public surface section starts.
local M = {}
local widgets = {} -- widget_id (string) → widget_def table
local active = nil -- active widget_id or nil
local theme = {} -- merged DEFAULT_THEME + overrides
local ctx_menu = nil -- context-menu state table or nil
-- Default-trigger bindings: array of {action_name, widget_id}. Multiple
-- bind_default_trigger calls append independent entries (each widget gets
-- its own input-action so they don't clobber each other in lib-core.input).
local triggers = {}
local last_mouse_left = false -- for edge-detection (was down last frame)
local last_mouse_right = false -- for edge-detection (was down last frame)
-- -----------------------------------------------------------------------
-- Theme
-- -----------------------------------------------------------------------
-- 13 canonical keys. DO NOT add keys here without updating set_theme validation.
local DEFAULT_THEME = {
bg_color = 0x202020F0,
border_color = 0x808080FF,
text_color = 0xE0E0E0FF,
text_color_dim = 0xA0A0A0FF,
selection_color = 0x404080FF,
context_menu_bg = 0x303030F8,
context_menu_hover = 0x404060FF,
font_size_title = 18,
font_size_body = 14,
padding = 8,
row_height = 24,
panel_width_frac = 0.5,
panel_height_frac = 0.7,
}
-- Init theme from DEFAULT_THEME at module load
for k, v in pairs(DEFAULT_THEME) do
theme[k] = v
end
-- -----------------------------------------------------------------------
-- Fallback screen constants (no engine.render.get_screen_size in Lua API)
-- -----------------------------------------------------------------------
local FALLBACK_SCREEN_W = 1280
local FALLBACK_SCREEN_H = 720
-- -----------------------------------------------------------------------
-- Internal helpers
-- -----------------------------------------------------------------------
--- Retrieve the current screen dimensions. Falls back to constants because
--- engine.render does not expose a Lua-callable screen-size function.
local function get_screen_size()
return FALLBACK_SCREEN_W, FALLBACK_SCREEN_H
end
--- Estimate menu width from action labels (rough; render can refine via
--- measure_text, but context-menu render in v0.1 uses this pre-computed w).
local function estimate_menu_width(actions, padding)
local max_w = 0
for _, action in ipairs(actions) do
local chars = #(action.label or "")
-- 8 px/char is a rough estimate for the default font; refine via
-- engine.render.measure_text in render-phase (v0.2 deferral).
local w = chars * 8 + 2 * padding
if w > max_w then max_w = w end
end
return max_w
end
-- -----------------------------------------------------------------------
-- Internal: context-menu dispatch + render helpers
-- -----------------------------------------------------------------------
--- Build per-row hit-rects on ctx_menu and store as ctx_menu.row_rects.
--- Called before dispatch and before render to keep both in sync.
local function build_ctx_menu_row_rects()
if not ctx_menu then return end
local rects = {}
for i, _ in ipairs(ctx_menu.actions) do
local row_y = ctx_menu.y + (i - 1) * theme.row_height
rects[i] = {
x = ctx_menu.x,
y = row_y,
w = ctx_menu.w,
h = theme.row_height,
}
end
ctx_menu.row_rects = rects
end
--- Returns current mouse position safely (0,0 if not in game context).
local function safe_mouse_pos()
if engine and engine.input and engine.input.get_mouse_pos then
return engine.input.get_mouse_pos()
end
return 0, 0
end
-- -----------------------------------------------------------------------
-- Internal: event dispatch
-- -----------------------------------------------------------------------
--- _dispatch_event(event): route a synthetic or real input event.
--- event = {kind="click", x, y, button="left"|"right"} or {kind="wheel", dy}
-- Close-button rect in the top-right of the title bar. Same math is used
-- by render() (draw the × glyph) and _dispatch_event (hit-test left-click).
local function close_button_rect(panel_x, panel_y, panel_w, padding, font_size_title)
local size = font_size_title + padding
return {
x = panel_x + panel_w - size - padding,
y = panel_y + padding,
w = size,
h = size,
}
end
local function _dispatch_event(event)
-- Close-button intercept (top-right ×): left-click closes the active panel
-- before the click is forwarded to the widget or context-menu.
if active and event.kind == "click" and event.button == "left" then
local screen_w, screen_h = get_screen_size()
local panel_w = screen_w * theme.panel_width_frac
local panel_h = screen_h * theme.panel_height_frac
local panel_x = (screen_w - panel_w) / 2
local panel_y = (screen_h - panel_h) / 2
local cb = close_button_rect(panel_x, panel_y, panel_w, theme.padding, theme.font_size_title)
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()
return
end
end
-- Context-menu intercept: any click is consumed by the menu.
if ctx_menu and event.kind == "click" then
-- Hit-test rows
build_ctx_menu_row_rects()
local hit = false
for i, rect in ipairs(ctx_menu.row_rects) do
if event.x >= rect.x and event.x < rect.x + rect.w
and event.y >= rect.y and event.y < rect.y + rect.h then
hit = true
local action = ctx_menu.actions[i]
ctx_menu = nil -- close menu before callback (re-entrant safety)
if action and action.callback then
action.callback({ close_menu = function() ctx_menu = nil end })
end
break
end
end
if not hit then
-- Click outside context-menu area → auto-close
ctx_menu = nil
end
return
end
-- Normal dispatch to active widget
if not active or not widgets[active] then return end
local screen_w, screen_h = get_screen_size()
local panel_w = screen_w * theme.panel_width_frac
local panel_h = screen_h * theme.panel_height_frac
local panel_x = (screen_w - panel_w) / 2
local panel_y = (screen_h - panel_h) / 2
local padding = theme.padding
local title_area_h = theme.font_size_title + padding * 2
local content_bounds = {
x = panel_x + padding,
y = panel_y + title_area_h,
w = panel_w - padding * 2,
h = panel_h - title_area_h - padding,
}
local widget_ctx = {
bounds = content_bounds,
theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state
is_focused = true,
}
widgets[active].handle_input(widget_ctx, event)
end
-- -----------------------------------------------------------------------
-- Public API (M declared at top of file so closures above can reference it)
-- -----------------------------------------------------------------------
-- -----------------------------------------------------------------------
-- Registry / Lifecycle
-- -----------------------------------------------------------------------
--- M.register(widget_id, widget_def)
--- Registers a new widget. widget_def must have:
--- .render(ctx) — function, called each render frame when widget is active
--- .handle_input(ctx, event) — function, called for each input event
--- .title — string, displayed in the panel title bar
--- Optional: .pause_on_open = true — makes is_pausing() return true while open.
function M.register(widget_id, widget_def)
if type(widget_id) ~= "string" then
error("panel.register: widget_id must be a string, got " .. type(widget_id))
end
if type(widget_def) ~= "table" then
error("panel.register: widget_def must be a table, got " .. type(widget_def))
end
if type(widget_def.render) ~= "function" then
error("panel.register: widget_def.render must be a function")
end
if type(widget_def.handle_input) ~= "function" then
error("panel.register: widget_def.handle_input must be a function")
end
if type(widget_def.title) ~= "string" then
error("panel.register: widget_def.title must be a string")
end
if widgets[widget_id] ~= nil then
error("panel.register: duplicate widget_id '" .. widget_id .. "'")
end
widgets[widget_id] = widget_def
end
--- M.unregister(widget_id)
--- Removes a widget from the registry. If the widget is currently active,
--- closes the panel first (clears active + ctx_menu).
function M.unregister(widget_id)
if active == widget_id then
active = nil
ctx_menu = nil
end
widgets[widget_id] = nil
end
--- M.open(widget_id)
--- Sets widget_id as the active (displayed) widget. Loud-error if not registered.
function M.open(widget_id)
if widgets[widget_id] == nil then
error("panel.open: unknown widget_id '" .. tostring(widget_id) .. "' (register before open)")
end
active = widget_id
end
--- M.close()
--- Closes the active panel and clears any open context-menu.
function M.close()
active = nil
ctx_menu = nil
end
--- M.toggle(widget_id)
--- If widget_id is currently active, closes it. Otherwise opens it.
function M.toggle(widget_id)
if active == widget_id then
M.close()
else
M.open(widget_id)
end
end
--- M.is_open() → bool
--- Returns true if any widget is currently active.
function M.is_open()
return active ~= nil
end
--- M.is_pausing() → bool
--- Returns true if the active widget has pause_on_open=true.
function M.is_pausing()
return active ~= nil and widgets[active] ~= nil
and widgets[active].pause_on_open == true
end
-- -----------------------------------------------------------------------
-- Theme
-- -----------------------------------------------------------------------
--- M.set_theme(overrides)
--- Merges overrides into the active theme. Only keys declared in DEFAULT_THEME
--- are accepted; unknown keys loud-error with "unknown theme key '<k>'".
function M.set_theme(overrides)
if type(overrides) ~= "table" then
error("panel.set_theme: expected table, got " .. type(overrides))
end
for k, v in pairs(overrides) do
if DEFAULT_THEME[k] == nil then
error("panel.set_theme: unknown theme key '" .. tostring(k) .. "'")
end
theme[k] = v
end
end
--- M.get_theme() → table
--- Returns a shallow copy of the current theme.
function M.get_theme()
local copy = {}
for k, v in pairs(theme) do
copy[k] = v
end
return copy
end
-- -----------------------------------------------------------------------
-- Default-trigger binding
-- -----------------------------------------------------------------------
--- M.bind_default_trigger(key, widget_id)
--- Binds a keyboard key as the default toggle trigger for a widget.
--- key defaults to "tab" when nil. Lazy-requires lib-core.input to avoid
--- module-load-time circular dependency.
--- Multiple calls register independent triggers (each widget gets its own
--- input-action named "panel_toggle_<widget_id>" so bindings don't clobber).
--- Loud-error if widget_id is not registered ("register before bind").
function M.bind_default_trigger(key, widget_id)
if key == nil then key = "tab" end
if widgets[widget_id] == nil then
error("panel.bind_default_trigger: unknown widget_id '" .. tostring(widget_id)
.. "' (register before bind)")
end
local input = require("lib-core.input") -- lazy require: avoid load-time cycle
local action_name = "panel_toggle_" .. widget_id
input.bind(action_name, {key})
table.insert(triggers, { action_name = action_name, widget_id = widget_id })
end
-- -----------------------------------------------------------------------
-- Context-menu
-- -----------------------------------------------------------------------
--- M.show_context_menu(x, y, actions)
--- Pops up a context menu at (x, y) with the given actions array.
--- actions = array of {label=string, callback=function} entries.
--- Auto-repositions to stay within screen bounds.
function M.show_context_menu(x, y, actions)
if type(actions) ~= "table" or #actions == 0 then
error("panel.show_context_menu: actions must be a non-empty array of {label, callback} tables")
end
for i, action in ipairs(actions) do
if type(action) ~= "table" then
error("panel.show_context_menu: action #" .. i .. " must be a table")
end
if type(action.label) ~= "string" then
error("panel.show_context_menu: action #" .. i .. " must have a string label")
end
if type(action.callback) ~= "function" then
error("panel.show_context_menu: action #" .. i .. " must have a callback function")
end
end
local padding = theme.padding
local row_h = theme.row_height
local menu_w = estimate_menu_width(actions, padding)
local menu_h = #actions * row_h
local screen_w, screen_h = get_screen_size()
-- Auto-reposition: clamp so menu stays on screen
if x + menu_w > screen_w then x = screen_w - menu_w end
if x < 0 then x = 0 end
if y + menu_h > screen_h then y = screen_h - menu_h end
if y < 0 then y = 0 end
ctx_menu = {
x = x,
y = y,
w = menu_w,
h = menu_h,
actions = actions,
row_rects = {}, -- populated by build_ctx_menu_row_rects()
}
build_ctx_menu_row_rects()
end
-- -----------------------------------------------------------------------
-- Per-frame update + input dispatch
-- -----------------------------------------------------------------------
--- M.update(dt)
--- Must be called each game-update frame. Handles:
--- - Default-trigger key check (toggling bound widget)
--- - Mouse edge-detection for click events (dispatches on press, not hold)
--- - Mouse-wheel event dispatch
function M.update(dt)
-- Check all default triggers (lazy requires lib-core.input if any are bound)
if #triggers > 0 then
local input = require("lib-core.input")
for _, t in ipairs(triggers) do
if input.was_action_pressed(t.action_name) then
M.toggle(t.widget_id)
end
end
end
if not active then
-- Still need to update last_mouse_* state even when closed so that
-- opening mid-frame doesn't produce a spurious edge on the next frame.
if engine and engine.input then
last_mouse_left = engine.input.is_mouse_down(engine.input.MOUSE_LEFT)
last_mouse_right = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT)
end
return
end
-- Read current mouse state
local mx, my = engine.input.get_mouse_pos()
local cur_left = engine.input.is_mouse_down(engine.input.MOUSE_LEFT)
local cur_right = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT)
-- Edge-detect: pressed THIS frame (was up, now down)
local pressed_left = cur_left and not last_mouse_left
local pressed_right = cur_right and not last_mouse_right
if pressed_left then
_dispatch_event({ kind = "click", x = mx, y = my, button = "left" })
end
if pressed_right then
_dispatch_event({ kind = "click", x = mx, y = my, button = "right" })
end
-- Wheel dispatch
local wheel_amt = engine.input.get_mouse_wheel()
if wheel_amt ~= 0 then
_dispatch_event({ kind = "wheel", dy = wheel_amt })
end
-- Update last-state for next frame's edge-detection
last_mouse_left = cur_left
last_mouse_right = cur_right
end
-- -----------------------------------------------------------------------
-- Render
-- -----------------------------------------------------------------------
--- M.render()
--- Must be called each render frame. Draws the active widget's panel
--- (background, border, title bar) then invokes widget_def.render(ctx).
--- If a context-menu is open, renders it on top.
--- No-op if no widget is active.
function M.render()
if not active then return end
local widget_def = widgets[active]
if not widget_def then return end
-- Screen size: fallback to 1280x720 (engine.render has no Lua-accessible
-- get_screen_size; see module header for explanation)
local screen_w, screen_h = get_screen_size()
local panel_w = screen_w * theme.panel_width_frac
local panel_h = screen_h * theme.panel_height_frac
local panel_x = (screen_w - panel_w) / 2
local panel_y = (screen_h - panel_h) / 2
local padding = theme.padding
-- Background
engine.render.draw_rect(panel_x, panel_y, panel_w, panel_h, theme.bg_color)
-- Border
engine.render.draw_rect_lines(panel_x, panel_y, panel_w, panel_h,
theme.border_color, 2)
-- Title
local title = widget_def.title
engine.render.draw_text(title,
panel_x + padding,
panel_y + padding,
theme.font_size_title,
theme.text_color)
-- Close button (×) in top-right of title bar. Hit-tested in _dispatch_event.
local cb = close_button_rect(panel_x, panel_y, panel_w, padding, theme.font_size_title)
engine.render.draw_text("X",
cb.x + math.floor(cb.w / 4),
cb.y,
theme.font_size_title,
theme.text_color)
-- Content area (below title bar)
local title_area_h = theme.font_size_title + padding * 2
local content_bounds = {
x = panel_x + padding,
y = panel_y + title_area_h,
w = panel_w - padding * 2,
h = panel_h - title_area_h - padding,
}
local widget_ctx = {
bounds = content_bounds,
theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state
is_focused = true,
}
widget_def.render(widget_ctx)
-- Context-menu (rendered on top of widget content)
if ctx_menu then
local mx, my = safe_mouse_pos()
-- Menu background
engine.render.draw_rect(ctx_menu.x, ctx_menu.y,
ctx_menu.w, ctx_menu.h,
theme.context_menu_bg)
build_ctx_menu_row_rects()
for i, action in ipairs(ctx_menu.actions) do
local rect = ctx_menu.row_rects[i]
local row_bg = theme.context_menu_bg
-- Hover highlight: mouse y within this row
if my >= rect.y and my < rect.y + rect.h
and mx >= rect.x and mx < rect.x + rect.w then
row_bg = theme.context_menu_hover
engine.render.draw_rect(rect.x, rect.y, rect.w, rect.h, row_bg)
end
-- Label
engine.render.draw_text(action.label,
rect.x + padding,
rect.y + (theme.row_height - theme.font_size_body) / 2,
theme.font_size_body,
theme.text_color)
end
end
end
-- -----------------------------------------------------------------------
-- Test backdoor
-- -----------------------------------------------------------------------
--- M._dispatch_event_for_test(event) — for tests only.
--- Direct passthrough to the internal _dispatch_event function so that
--- panel-test can simulate input events without a running game loop.
function M._dispatch_event_for_test(event)
_dispatch_event(event)
end
return M