Files
sporel-lib-core.panel/README.md

8.9 KiB

lib-core.panel

Generic overlay-panel framework. Manages a single active widget at a time, handles input dispatch (mouse click + wheel, edge-detected), renders a titled panel overlay, and supports a context-menu layer. Designed as the glue layer between game modules and the engine render/input surfaces.

Version: 0.1.0 Lib-ID: lib-core.panel Requires: engine.render., engine.input., lib-core.input (lazy, for default-trigger) Tags: panel, overlay, ui, input, context-menu

Topology

graph LR
  this["lib-core.panel"]
  engine_render["engine.render.*"]
  engine_input["engine.input.*"]
  lib_core_input["lib-core.input"]
  this --> engine_render
  this --> engine_input
  this -.->|lazy| lib_core_input

Scope (v0.1.0)

v0.1 ships a minimal single-active-widget panel system:

  • Widget registry (register / unregister / open / close / toggle)
  • Theme system (13 configurable keys, capability-by-declaration guard)
  • Per-frame update with edge-detected mouse click + wheel dispatch
  • Context-menu (show, auto-reposition to screen bounds, hit-test, auto-close)
  • Default-trigger key binding via lib-core.input (lazy-required)
  • pause_on_open flag for game-pause gating

Intentional non-goals (deferred):

  • Multi-widget z-order / stacking panels
  • Always-on HUD widgets (non-modal overlays)
  • Keyboard navigation within widgets
  • Panel animation (fade in / out)
  • Screen-size from engine (no Lua-accessible get_screen_size; v0.1 falls back to 1280x720 constants matching default Sporel window config)

API

panel.register(widget_id, widget_def)

Syntax: panel.register(widget_id: string, widget_def: table) -> void

Example:

panel.register("inventory", {
    title        = "Inventory",
    pause_on_open = true,
    render       = function(ctx)
        -- ctx.bounds = {x, y, w, h}; ctx.theme; ctx.is_focused
        engine.render.draw_text("(empty)", ctx.bounds.x, ctx.bounds.y,
                                ctx.theme.font_size_body, ctx.theme.text_color)
    end,
    handle_input = function(ctx, event)
        -- event.kind = "click"|"wheel"
        -- event.x, event.y, event.button (click only)
        -- event.dy (wheel only)
    end,
})

Registers a widget in the panel registry. widget_def.render and widget_def.handle_input must be functions; widget_def.title must be a string. Loud-error on duplicate widget_id or missing required fields.


panel.unregister(widget_id)

Syntax: panel.unregister(widget_id: string) -> void

Removes the widget from the registry. If the widget is currently active, closes the panel (sets active to nil, clears any open context-menu).


panel.open(widget_id)

Syntax: panel.open(widget_id: string) -> void

Sets widget_id as the active widget. Loud-error if widget_id has not been registered.


panel.close()

Syntax: panel.close() -> void

Closes the active widget and clears any open context-menu.


panel.toggle(widget_id)

Syntax: panel.toggle(widget_id: string) -> void

If widget_id is currently active, closes it. Otherwise opens it. Handy for key-binding toggle semantics without manual state tracking.


panel.is_open()

Syntax: panel.is_open() -> bool

Returns true if any widget is currently active.


panel.is_pausing()

Syntax: panel.is_pausing() -> bool

Returns true if the active widget has pause_on_open = true. Modules can use this to gate their update loop (skip physics/AI while panel is open).


panel.set_theme(overrides)

Syntax: panel.set_theme(overrides: table) -> void

Example:

panel.set_theme({
    bg_color   = 0x101010F8,
    padding    = 12,
    font_size_body = 16,
})

Merges overrides into the active theme. Only keys declared in DEFAULT_THEME are accepted (capability-by-declaration). Loud-error on any unknown key with message "panel.set_theme: unknown theme key '<k>'".


panel.get_theme()

Syntax: panel.get_theme() -> table

Returns a shallow copy of the current merged theme. Safe to store; does not alias internal state.


panel.bind_default_trigger(key, widget_id)

Syntax: panel.bind_default_trigger(key: string|nil, widget_id: string) -> void

Example:

panel.register("inventory", { ... })
panel.bind_default_trigger("tab", "inventory")
-- or: panel.bind_default_trigger(nil, "inventory")  -- defaults to "tab"

Binds a keyboard key as the default toggle trigger for widget_id. key defaults to "tab" when nil. Lazy-requires lib-core.input to avoid module-load-time cycles. Loud-error if widget_id is not registered.


panel.update(dt)

Syntax: panel.update(dt: number) -> void

Must be called each game-update frame. Checks the default trigger key, reads mouse state, emits edge-detected click events (left and right independently), and dispatches wheel events. No-ops if no widget is active (but still tracks mouse state to avoid spurious edges on next open).


panel.render()

Syntax: panel.render() -> void

Must be called each render frame (inside the engine render phase). Draws the panel background, border, and title, then invokes widget_def.render(ctx). If a context-menu is open, renders it on top. No-op if no widget is active.


panel.show_context_menu(x, y, actions)

Syntax: panel.show_context_menu(x: number, y: number, actions: table) -> void

Example:

panel.show_context_menu(event.x, event.y, {
    { label = "Use",  callback = function(info) use_item()  end },
    { label = "Drop", callback = function(info) drop_item() end },
})

Pops up a context-menu at (x, y). actions must be a non-empty array of {label: string, callback: function} tables. Auto-repositions to remain within the screen boundary. The callback receives {close_menu: function} (which is a no-op in v0.1 since the menu closes itself before invoking the callback). Click outside any row auto-closes the menu.


panel._dispatch_event_for_test(event) — for tests only

Syntax: panel._dispatch_event_for_test(event: table) -> void

Direct passthrough to the internal _dispatch_event function. Allows test-modules to simulate input events without a running game loop (since M.update is not exercised headless).

Theme Schema

Key Default Description
bg_color 0x202020F0 Panel background (RGBA packed)
border_color 0x808080FF Panel border
text_color 0xE0E0E0FF Primary text
text_color_dim 0xA0A0A0FF Dimmed / secondary text
selection_color 0x404080FF Row / item selection highlight
context_menu_bg 0x303030F8 Context-menu background
context_menu_hover 0x404060FF Context-menu row hover
font_size_title 18 Title bar font size (px)
font_size_body 14 Content font size (px)
padding 8 Inner padding (px)
row_height 24 Row height for lists / menus (px)
panel_width_frac 0.5 Panel width as fraction of screen
panel_height_frac 0.7 Panel height as fraction of screen

Widget-Lifecycle-Contract

Widget render(ctx) is called each render frame while the widget is active. Widget handle_input(ctx, event) is called for each dispatched input event.

Both receive a ctx table:

ctx = {
    bounds = {
        x = number,  -- content area top-left x
        y = number,  -- content area top-left y
        w = number,  -- content area width
        h = number,  -- content area height
    },
    theme      = table,   -- current merged theme (read-only by convention)
    is_focused = bool,    -- true when this widget is the active one
}

The event table passed to handle_input:

-- Mouse click:
event = { kind = "click", x = number, y = number, button = "left"|"right" }

-- Mouse wheel:
event = { kind = "wheel", dy = number }  -- dy > 0 = scroll up, dy < 0 = scroll down

Context-menu intercepts clicks before they reach handle_input. The widget calls panel.show_context_menu(x, y, actions) from within handle_input when a right-click (or any application-specific trigger) warrants a menu.

Glue-Pattern

Minimal module setup (copy-paste-able):

local panel = require("lib-core.panel")

-- 1. Register your widget once (e.g. in module init or M.load)
panel.register("inventory", {
    title         = "Inventory",
    pause_on_open = true,
    render        = function(ctx) inventory_ui.draw(ctx) end,
    handle_input  = function(ctx, event) inventory_ui.on_input(ctx, event) end,
})

-- 2. Bind a toggle key (optional; defaults to Tab)
panel.bind_default_trigger("i", "inventory")

-- 3. Wire into your module's update + render
function M.update(dt)
    panel.update(dt)
    if not panel.is_pausing() then
        -- normal game logic here
    end
end

function M.render()
    -- ... draw world ...
    panel.render()  -- draws panel overlay on top
end