initial: panel v0.1.0 — generic panel framework

This commit is contained in:
Calic
2026-06-13 23:09:43 +02:00
commit 64aa1c8777
4 changed files with 858 additions and 0 deletions

528
init.lua Normal file
View File

@@ -0,0 +1,528 @@
-- =====================================================================
-- 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)
-- -----------------------------------------------------------------------
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
local trigger_action_name = nil -- input action name for default trigger
local trigger_widget_id = nil -- which widget_id to toggle on trigger
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}
local function _dispatch_event(event)
-- 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 module table
-- -----------------------------------------------------------------------
local M = {}
-- -----------------------------------------------------------------------
-- 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.
--- 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
input.bind("panel_toggle", {key})
trigger_action_name = "panel_toggle"
trigger_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 default trigger (lazy requires lib-core.input internally if bound)
if trigger_action_name then
local input = require("lib-core.input")
if input.was_action_pressed(trigger_action_name) then
M.toggle(trigger_widget_id)
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)
-- 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