-- ===================================================================== -- 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 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 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 ''". 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