-- ===================================================================== -- lib-core.panel v0.3.0 — Generic overlay-panel framework (Window-Manager) -- -- 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 within tier) -- - 11 named layout-slot templates + opts.layout = function(sw, sh) -- - 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) -- -- 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.3 still falls -- back to 1280x720 constants matching the default Sporel window config. -- -- 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) -- ===================================================================== -- ----------------------------------------------------------------------- -- 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 (legacy registry, preserved) -- v0.2.0: replaced single `active` scalar with multi-active model. -- windows[id] = { widget_def, opts, open, _close_button_rect?, _content_bounds? } local windows = {} -- widget_id → window-record local open_order = {} -- array of open widget_ids; last = focused = topmost 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 --- Find the (first) index of val in array arr, or nil. local function find_in_array(arr, val) for i, v in ipairs(arr) do if v == val then return i end end return nil 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 -- ===================================================================== -- 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) -- ===================================================================== local LAYOUT_SLOTS = { ["center"] = function(sw, sh) return {x=sw*0.25, y=sh*0.20, w=sw*0.50, h=sh*0.60} end, ["left"] = function(sw, sh) return {x=0, y=0, w=sw*0.40, h=sh} end, ["right"] = function(sw, sh) return {x=sw*0.60, y=0, w=sw*0.40, h=sh} end, ["top"] = function(sw, sh) return {x=0, y=0, w=sw, h=sh*0.30} end, ["bottom"] = function(sw, sh) return {x=0, y=sh*0.70, w=sw, h=sh*0.30} end, ["top-left"] = function(sw, sh) return {x=0, y=0, w=sw*0.40, h=sh*0.50} end, ["top-right"] = function(sw, sh) return {x=sw*0.60, y=0, w=sw*0.40, h=sh*0.50} end, ["bottom-left"] = function(sw, sh) return {x=0, y=sh*0.50, w=sw*0.40, h=sh*0.50} end, ["bottom-right"] = function(sw, sh) return {x=sw*0.60, y=sh*0.50, w=sw*0.40, h=sh*0.50} end, ["left-half"] = function(sw, sh) return {x=0, y=0, w=sw*0.50, h=sh} end, ["right-half"] = function(sw, sh) return {x=sw*0.50, y=0, w=sw*0.50, h=sh} end, } -- Known-slot list (for error message + completeness check) local KNOWN_SLOT_NAMES = { "center","left","right","top","bottom", "top-left","top-right","bottom-left","bottom-right", "left-half","right-half", } --- Resolves a layout specifier to {x, y, w, h} bounds. --- Accepts: nil (defaults to "center"), string (slot-name), or function. --- Loud-error on unknown string-slot. pcall-wraps custom function for --- safety (returns "center" fallback + engine.print warning on error). local function resolve_bounds(layout, sw, sh) if layout == nil then return LAYOUT_SLOTS["center"](sw, sh) end if type(layout) == "string" then local fn = LAYOUT_SLOTS[layout] if fn == nil then error(string.format( "panel.register: unknown layout slot '%s' (known: %s)", layout, table.concat(KNOWN_SLOT_NAMES, ", ")), 3) end return fn(sw, sh) end if type(layout) == "function" then local ok, result = pcall(layout, sw, sh) if not ok then if engine and engine.print then engine.print(string.format( "[WARN] panel: custom layout fn errored: %s (falling back to center)", tostring(result))) end return LAYOUT_SLOTS["center"](sw, sh) end return result end error(string.format( "panel.register: layout must be nil, string, or function (got %s)", type(layout)), 3) 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 -- Close-button rect in the top-right of the title bar. Same math is used -- by render() (draw the X 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 -- ----------------------------------------------------------------------- -- Internal: per-window render helper (extracted from v0.1 render()) -- ----------------------------------------------------------------------- local function render_one_window(widget_id, win, sw, sh) local widget_def = win.widget_def local bounds = resolve_bounds(win.opts.layout, 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) -- Border engine.render.draw_rect_lines(panel_x, panel_y, panel_w, panel_h, theme.border_color, 2) -- Title engine.render.draw_text(widget_def.title, panel_x + padding, panel_y + padding, theme.font_size_title, theme.text_color) -- Close button (X) 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) -- Stash close-button rect on the window record so hit-test can find it win._close_button_rect = cb -- Content area (below title bar) local title_area_h = theme.font_size_title + padding * 2 win._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 = win._content_bounds, theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state is_focused = (widget_id == open_order[#open_order]), } widget_def.render(widget_ctx) end local function render_context_menu() 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 + theme.padding, rect.y + (theme.row_height - theme.font_size_body) / 2, theme.font_size_body, theme.text_color) end end -- ----------------------------------------------------------------------- -- Internal: event dispatch -- ----------------------------------------------------------------------- --- Returns true if the event was consumed by the context-menu. local function dispatch_context_menu_event(event) if event.kind == "click" then build_ctx_menu_row_rects() for i, rect in ipairs(ctx_menu.row_rects or {}) 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 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 return true end end -- Clicked outside the menu: dismiss ctx_menu = nil return true end return false 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: --- - If input_block="all" AND click is OUTSIDE its bounds: swallow --- the event IMMEDIATELY (modal owns the screen — do not let --- lower-tier widgets claim outside-clicks behind it). --- - input_block="none": skip hit-test entirely (HUD passes through). --- - else: hit-test bounds; on hit route to widget. --- - chromeless=true suppresses the close-button hit-test (the --- widget owns its full bounds, including the top-right area). --- 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 local sw, sh = get_screen_size() local saw_modal = false -- track if any input_block="all" window is open -- 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 -- Modal swallow: if the click is outside this modal's -- bounds, swallow immediately so lower-tier widgets -- behind it cannot claim it. Inside-bounds clicks fall -- through to normal routing below. if event.kind == "click" and event.x and event.y then local mb = resolve_bounds(win.opts.layout, sw, sh) if not (event.x >= mb.x and event.x < mb.x + mb.w and event.y >= mb.y and event.y < mb.y + mb.h) then return end end 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. -- Chromeless widgets have no chrome (no X), so we -- must NOT hit-test the phantom close-zone — the -- widget owns its full bounds. if event.button == "left" and not win.opts.chromeless 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 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 end end end end -- 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 -- ----------------------------------------------------------------------- -- Public API (M declared at top of file so closures above can reference it) -- ----------------------------------------------------------------------- -- ----------------------------------------------------------------------- -- Registry / Lifecycle -- ----------------------------------------------------------------------- --- M.register(widget_id, widget_def, opts?) --- Registers a new widget. widget_def must have: --- .render(ctx) — function, called each render frame when widget is open --- .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. --- --- opts is OPTIONAL. Accepted keys: --- 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 = "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". --- chromeless = false (default) | true — when true, suppresses the --- panel-lib chrome (title-bar, border, X close-button). --- Widget's render(ctx) draws into the full bounds --- without panel-lib decoration. Useful for HUD-style --- widgets that have their own visual design (status --- bars, toolbars with their own theme). 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)) 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 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 = 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) --- Removes a widget from the registry. If the widget is currently open, --- closes the panel first (removes from open_order; clears ctx_menu if --- this was the last-open window). function M.unregister(widget_id) if windows[widget_id] and windows[widget_id].open then M.close(widget_id) end widgets[widget_id] = nil windows[widget_id] = nil end --- M.open(widget_id) --- Opens widget_id (adds to open_order; makes it the focused/topmost). --- If already open, brings it to the front (re-pushes onto open_order top). --- 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 local w = windows[widget_id] if w.open then -- Already open: bring to front (focus) within open_order. local idx = find_in_array(open_order, widget_id) if idx then table.remove(open_order, idx) end end open_order[#open_order + 1] = widget_id w.open = true end --- M.close(widget_id?) --- 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 if #open_order == 0 then -- v0.1.1: close() also cleared ctx_menu unconditionally ctx_menu = nil 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 -- Context-menu was attached to whatever was on top — clear it if our close -- removed the focused window. if #open_order == 0 then ctx_menu = nil end end --- M.toggle(widget_id) --- If widget_id is currently open, closes it. Otherwise opens it. function M.toggle(widget_id) if M.is_open(widget_id) then M.close(widget_id) else M.open(widget_id) end end --- M.is_open(widget_id?) → bool --- With id: is that specific widget open? --- Without id: is any widget open? (v0.1.1 bw-compat) function M.is_open(widget_id) if widget_id == nil then return #open_order > 0 end local w = windows[widget_id] return w ~= nil and w.open == true end --- M.is_pausing() → bool --- Returns true if ANY open widget has pause_on_open=true. function M.is_pausing() for _, id in ipairs(open_order) do local w = windows[id] if w and w.widget_def.pause_on_open == true then return true end end 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 -- ----------------------------------------------------------------------- --- 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. --- Multiple calls register independent triggers (each widget gets its own --- input-action named "panel_toggle_" 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 #open_order == 0 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. 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 _, 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 + tiers) if ctx_menu then render_context_menu() end end -- ----------------------------------------------------------------------- -- Test backdoors (v0.1.0 + v0.2.0 additions) -- ----------------------------------------------------------------------- --- 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 --- M._test_reset_all() — clears widgets + windows + open_order + ctx_menu. --- Note: theme + triggers preserved across reset; tests that need --- those reset should do it explicitly. function M._test_reset_all() widgets = {} windows = {} open_order = {} ctx_menu = nil end --- M._test_get_open_ids() — returns array of currently-open ids in --- open-order (last = focused). function M._test_get_open_ids() local out = {} for i, id in ipairs(open_order) do out[i] = id end return out end --- M._test_get_focused_id() — last-opened id, or nil. function M._test_get_focused_id() if #open_order == 0 then return nil end return open_order[#open_order] end --- M._test_get_window_bounds(widget_id) — resolved bounds for an open --- window, or nil if not open. function M._test_get_window_bounds(widget_id) local w = windows[widget_id] if not w or not w.open then return nil end local sw, sh = get_screen_size() return resolve_bounds(w.opts.layout, sw, sh) end --- M._test_get_screen_size() — current screen size used by layouts. 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