Files
sporel-lib-core.panel/README.md
Calic 1fcdbd192b feat: v0.3.0 Persistent + Z-Tiers + Input-Block
Layer 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-tier (hud->normal->top); input dispatch
iterates reverse (top->hud, within-tier reverse-open-order); modal
input_block='all' swallows misses. Persistent windows open at
register-time and are exempt from arg-less close() (so ESC and default
triggers cannot dismiss persistent toolbars/HUDs).

Adds chromeless=true opt (suppresses panel-lib's own decoration so
in-game HUD widgets like map-editor's toolbar/layers/palette can paint
their own visual style over the full widget bounds).

Adds public panel.point_in_any_panel(x, y) — module-side canvas-click
gate that returns true if (x, y) falls within any open panel's
bounds. Modules read input directly via engine.input.*; they can use
this helper to skip canvas-paint when the click landed on a panel
widget area.
2026-06-15 02:25:46 +02:00

19 KiB

lib-core.panel

Generic overlay-panel framework. 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 model with 11 named layout-slot templates + custom-fn hook. Render iterates per-tier (hud → normal → top); input dispatch iterates reverse (top → hud, within-tier reverse open-order); modal input_block="all" swallows misses.

The single-active model from v0.1.1 is preserved as a backward- compatibility shim — panel.open(id), panel.close(), panel.is_open() without an id-arg keep their old semantics. v0.3.0 additionally makes panel.close() (no arg) a no-op when the focused window is persistent=true (so ESC-equivalents cannot dismiss persistent toolbars/HUDs); use panel.close(id) for explicit modder-controlled close.

Version: 0.3.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, window-manager

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.3.0)

v0.3.0 layers z-tiers + persistent + input-block on top of v0.2.0's multi-active model, fully backward-compatible with v0.2.0 and v0.1.1 callers. The lib is the generic UI-Framework substrate — domain-free per ADR-0001/ADR-0049, consumed by Display-Libs (inventory-list- display, crafting-display, notify-display) and modules (vagrant- skeleton, map-editor).

Supported (v0.3.0)

  • Z-tiers: render order hud → normal → top. Input dispatch reverse (top → hud, within-tier reverse open-order).
  • Persistent windows: opts.persistent=true auto-opens at register- time. Arg-less panel.close() becomes no-op when focused is persistent (so ESC/default-trigger cannot dismiss a persistent toolbar/HUD). Explicit panel.close(id) still closes persistent.
  • Input-block routing: opts.input_block = "none" (skip hit-test entirely — HUD passes clicks through), "self" (hit-test own bounds; miss falls through to next window), "all" (modal — hit-test self; miss is swallowed, never reaches game-layer). Defaults per tier: hud→none, normal→self, top→all.
  • Public panel.point_in_any_panel(x, y): helper for module-side canvas-click logic — returns true if (x,y) falls within any open panel's bounds. Useful for modules that read input directly via engine.input.* and want to skip canvas-paint when the click landed inside a panel widget.
  • Multi-active panels: multiple windows open simultaneously (unchanged from v0.2.0).
  • 11 layout-slot templates + custom-fn hook (unchanged).
  • Bw-Compat-Shim: v0.1.1 register(id, widget) ohne opts works unchanged; close()/is_open() ohne arg map to focused = last- opened.
  • Theme system: shared theme via set_theme/get_theme (unchanged).
  • Context-menu via show_context_menu (unchanged).
  • Default-trigger key-binding via bind_default_trigger (unchanged).

Deferred (v0.4.0+)

  • Click-to-focus and explicit focus()/raise()/lower() API.
  • Drag-by-title-bar (draggable opt).
  • Resize-by-corner (resizable opt).
  • min/max-size constraints + screen-clamp.

Deferred (post-v0.4.0)

  • Window-decoration themes (per-window title-bar styles).
  • Touch/mobile input adaptation.
  • Window animations (slide-in, fade-in).
  • Window groups / tabbed-windows / MDI parent-child hierarchies.
  • Save/load of window-bounds across sessions.

API

panel.register(widget_id, widget_def, opts?)

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

opts is optional. Accepted keys:

  • layout — slot-name string, custom function(sw, sh) -> {x,y,w,h}, or nil (defaults to "center"). See the Layout-Slots table.
  • z_tier"hud" | "normal" (default) | "top". Controls render order (hud → normal → top) and hit-test order (top → normal → hud).
  • persistentbool (default false). If true, the window is open from the moment of registration and is exempt from arg-less panel.close() (modder must close explicitly with close(id)).
  • input_block"none" | "self" | "all". Defaults per tier: hud→"none", normal→"self", top→"all". See Input-Block.
  • chromelessbool (default false). If true, panel-lib skips its own chrome (background, border, title-bar, close-X). The widget gets the FULL panel bounds as ctx.bounds (no title-bar inset) and must render its own background/border. Intended for persistent HUD widgets (toolbars, layer-pickers, palettes) that have their own visual style.

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 open, closes it first (removes from open_order; clears any open context-menu if it was the last open window).


panel.open(widget_id)

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

Opens widget_id. The window is pushed onto the top of open_order (becomes focused). Other open windows remain open. If widget_id is already open, it is brought to the front (re-focused). Loud-error if widget_id has not been registered.

v0.2.0 change: open(id) no longer closes other open panels. Multiple panels can be visible simultaneously. See §Bw-Compat Guarantee below for the migration recipe.


panel.close(widget_id?)

Syntax: panel.close(widget_id?: string) -> void

With widget_id: closes that specific window (DOES close persistent windows — explicit modder action). Without arg (v0.1.1 bw-compat): closes the focused (last-opened) window — BUT v0.3.0 makes this a no-op when the focused window is persistent=true. Clears any open context-menu when the last open window is closed.


panel.point_in_any_panel(x, y)

Syntax: panel.point_in_any_panel(x: number, y: number) -> bool

Example:

-- In a module's M.update(dt):
local mx, my = engine.input.get_mouse_pos()
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
    if not panel.point_in_any_panel(mx, my) then
        -- click landed on canvas — paint!
        canvas.paint(mx, my)
    end
end

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.* and can use this helper to skip canvas-paint when the click landed on a panel widget area. Returns false if no panels are open.


panel.toggle(widget_id)

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

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


panel.is_open(widget_id?)

Syntax: panel.is_open(widget_id?: string) -> bool

With widget_id: returns true iff that specific window is currently open. Without arg (v0.1.1 bw-compat): returns true if ANY window is open.


panel.is_pausing()

Syntax: panel.is_pausing() -> bool

Returns true if ANY currently-open window has pause_on_open = true. Modules can use this to gate their update loop (skip physics/AI while a pausing 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 panels are open (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 panels are open.


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).


v0.2.0 — Multi-Active + Layout-Slots

Multiple panels can now be open at the same time. The previous single-active model is preserved as the default for v0.1.1 callers via a backward-compatibility shim.

Layout-Slots

panel.register(id, widget_def, {layout = ...}) accepts:

  • nil"center" default.
  • One of the 11 named templates (see table below).
  • A custom function function(sw, sh) -> {x, y, w, h} for bespoke positioning (HP-bar, status-display, etc.).
Slot x y w h
"center" (default) 25% sw 20% sh 50% sw 60% sh
"left" 0 0 40% sw sh
"right" 60% sw 0 40% sw sh
"top" 0 0 sw 30% sh
"bottom" 0 70% sh sw 30% sh
"top-left" 0 0 40% sw 50% sh
"top-right" 60% sw 0 40% sw 50% sh
"bottom-left" 0 50% sh 40% sw 50% sh
"bottom-right" 60% sw 50% sh 40% sw 50% sh
"left-half" 0 0 50% sw sh
"right-half" 50% sw 0 50% sw sh

Unknown slot-name strings raise a loud-error at panel.register (so typos surface immediately, not in the next frame's render). Custom functions that error at runtime fall back to "center" with an engine.print warning.

Bounds are re-resolved every render frame, so layout-slots react to screen-resizes automatically.

Multi-Active API

  • panel.open(id) — opens; does NOT close other open panels. If already open, brings to front (re-focused).
  • panel.close(id) — closes that specific id.
  • panel.close() — bw-compat: closes the focused (last-opened) panel.
  • panel.is_open(id) — id-specific.
  • panel.is_open() — bw-compat: any panel open.
  • panel.is_pausing() — true if ANY open panel has pause_on_open=true.

Hit-Test Order

Input events dispatch through panels in reverse open-order (last- opened first). A click that hits a panel's bounds is consumed by that panel and not propagated further. A click that misses all panels is dropped (v0.2.0 does not route to game; v0.3+ may add that).

Wheel events route to the focused (last-opened) panel only.

Bw-Compat Guarantee

All v0.1.1 callers (register(id, widget) without opts, open(id), close(), is_open()) keep their existing semantics. The one behavioral change: panel.open(A) followed by panel.open(B) now leaves BOTH open instead of replacing A.

If you relied on the v0.1.1 "open(B) closes A" behavior, call panel.close() before panel.open(B) to keep the single-active idiom.

Test backdoors (v0.2.0 additions)

  • panel._test_reset_all() — clears widgets + windows + open_order + ctx_menu (theme + triggers preserved).
  • panel._test_get_open_ids() — array of currently-open ids in open-order (last = focused).
  • panel._test_get_focused_id() — last-opened id, or nil.
  • panel._test_get_window_bounds(id) — resolved {x,y,w,h} for an open window, or nil if not open.
  • panel._test_get_screen_size() — current screen size used by layouts.

v0.3.0 — Z-Tiers, Persistent, Input-Block

Z-Tiers

Three rendering tiers, drawn bottom-to-top:

Tier Render order Default input_block Use-case
"hud" first (background) "none" HP-bars, status displays, world-overlays
"normal" second "self" Toolbars, persistent panels, workbench windows
"top" third (foreground) "all" Modal dialogs, blocking confirmations

Within a tier, windows render in open_order (later-opened = drawn later = on top of same-tier earlier-opened). Hit-test iterates reverse z-tier (top → normal → hud); within tier reverse-open-order (last-opened first). Unknown tier names raise a loud-error at panel.register (typos surface immediately).

Persistent Windows

panel.register("toolbar", widget, {
    z_tier      = "normal",
    layout      = "top",
    persistent  = true,        -- open at register-time
    input_block = "self",
})

persistent=true pushes the window onto open_order immediately and makes panel.close() (no arg) a no-op when the window is focused. Use this for in-game UI that must stay visible (toolbars, layer-pickers, palettes, HUDs). The modder can still close it explicitly with panel.close(widget_id).

ESC-equivalent paths (default-trigger keys, arg-less panel.close()) will NOT dismiss persistent panels. This protects users from accidentally hiding their toolbar.

Input-Block Routing (v0.3.0)

opts.input_block controls whether mouse events are absorbed:

Value Behavior
"none" Window is never hit-tested. Mouse events pass through to lower windows. Use for HUD overlays.
"self" Hit-test the window's own bounds. Clicks inside → widget; clicks outside → fall through to next window. Default for normal tier.
"all" Modal. Hit-test self; clicks outside the window are swallowed (do not reach lower windows or the game-layer). Default for top tier.

If any open window has input_block="all", ALL outside-misses are swallowed at the bottom of dispatch — this is the "modal block" semantic. Game-layer code (canvas-paint, world-click) should consult panel.point_in_any_panel(x, y) before reacting to clicks, since panel-lib does not intercept clicks the module reads via engine.input.* directly.

Tier-defaults: hud→"none", normal→"self", top→"all".

Test backdoors (v0.3.0 additions)

  • panel._test_get_render_order() — array of widget_ids in the order M.render() would iterate (per-tier, within-tier open-order).
  • panel._test_get_input_block(id) — resolved input_block for a registered window, or nil.
  • panel._test_get_persistent(id) — persistent flag for a registered window, or nil.

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 open. 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 focused one (last-opened)
}

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