Adds focus/raise/lower/get_focused API with click-to-focus auto-routing (hud-tier never focusable), drag-by-title-bar with 32px screen-clamp, resize via SE-corner handle with min/max_size clamps. Drag and resize move bounds outside the layout-fn via a per-window bounds_override that shadows resolve_bounds across render + dispatch + point_in_any_panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lib-core.panel
Generic overlay-panel framework. v0.4.0 adds the focus API (focus
/ raise / lower / get_focused), click-to-focus auto-routing,
drag-by-title-bar (draggable=true), and resize-by-SE-corner
(resizable=true) on top of v0.3.0's z-tier + persistent + input-block
model. Drag and resize move bounds outside the layout-fn via a
window-record bounds_override; min/max_size constraints and a
32px-of-title-bar screen-clamp keep windows recoverable.
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.4.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, focus, drag, resize
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.4.0)
v0.4.0 adds focus API + click-to-focus + drag + resize on top of v0.3.0, fully backward-compatible with v0.3.0, 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.4.0 — new)
- Focus API:
panel.focus(id)/panel.raise(id)(alias) bring a widget to the top within open_order;panel.lower(id)moves it to the bottom;panel.get_focused()returns the currently-focused id (top-tier priority, else last-opened). hud-tier widgets are never promoted byfocus()(they are cosmetic overlays by design). - Click-to-focus: a click that hits a non-hud window auto-routes
through
focus()before forwarding to the widget's handler. - Drag:
opts.draggable=truemakes left-click in the title-bar area start a drag. Drag updates a per-windowbounds_overridethat shadows the layout-fn. Screen-clamp keeps at least 32px of the title-bar reachable. Chromeless widgets are excluded (no defined drag-handle). - Resize:
opts.resizable=truerenders a 12x12 SE-corner handle and starts a resize on left-click. Resize clamps toopts.min_size(default{w=120, h=80}) andopts.max_size(default{w=99999, h=99999}). bounds_overridesurvives the layout-fn until the modder closes the window or the test backdoor_test_reset_all()is called.
Supported (v0.3.0 — unchanged)
- Z-tiers: render order hud → normal → top. Input dispatch reverse (top → hud, within-tier reverse open-order).
- Persistent windows:
opts.persistent=trueauto-opens at register- time. Arg-lesspanel.close()becomes no-op when focused is persistent (so ESC/default-trigger cannot dismiss a persistent toolbar/HUD). Explicitpanel.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. v0.4.0: respectsbounds_overridefrom drag/resize. - 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 (post-v0.4.0)
- Resize corners other than SE (NE/SW/NW + edge-resize).
- 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, customfunction(sw, sh) -> {x,y,w,h}, ornil(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).persistent—bool(defaultfalse). If true, the window is open from the moment of registration and is exempt from arg-lesspanel.close()(modder must close explicitly withclose(id)).input_block—"none"|"self"|"all". Defaults per tier: hud→"none", normal→"self", top→"all". See Input-Block.chromeless—bool(defaultfalse). If true, panel-lib skips its own chrome (background, border, title-bar, close-X). The widget gets the FULL panel bounds asctx.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.draggable—bool(defaultfalse, v0.4.0+). If true, a left-click in the title-bar area starts a drag-by-mouse. Ignored on chromeless widgets (no defined drag-handle).resizable—bool(defaultfalse, v0.4.0+). If true, the window renders a 12x12 SE-corner handle that left-click starts a resize. Works on chromeless widgets too.min_size—{w=number, h=number}(default{w=120, h=80}, v0.4.0+). Minimum window dimensions when resizing.max_size—{w=number, h=number}(default{w=99999, h=99999}, v0.4.0+). Maximum window dimensions when resizing.
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 haspause_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 orderM.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
v0.4.0 — Focus + Drag + Resize
Focus API
panel.focus(widget_id) -- bring to top within open_order (no-op for hud)
panel.raise(widget_id) -- alias for focus(); reads as "raise to top"
panel.lower(widget_id) -- move to bottom of open_order
panel.get_focused() -- returns focused widget_id, or nil
focus(id) removes id from open_order and re-appends at the top.
Hud-tier widgets are never focusable — focus() and click-to-focus
both no-op for them, since hud is by design a cosmetic overlay tier.
lower(id) works for all tiers (lowering within tier is a layering
operation, not focus).
get_focused() returns the topmost-tier last-opened widget: if any
top-tier window is open it wins (regardless of normal-/hud-tier windows
later in open_order), otherwise it returns the simple last-opened id.
Returns nil if no windows are open.
Click-to-Focus
A left-click that hits a non-hud window's bounds auto-focuses that
widget before forwarding to its handler. This is the standard window-
manager behavior modders expect from desktop UI. The auto-focus call
goes through M.focus, so hud-tier widgets are naturally excluded.
Drag
panel.register("toolbox", widget, {
layout = function(_sw, _sh) return {x=100, y=100, w=240, h=300} end,
draggable = true,
})
Left-click in the title-bar area (the top font_size_title + 2 * padding pixels of the window) of a draggable=true window starts a
drag. The window's bounds_override is updated each frame to follow
the mouse, with a 32-pixel screen-clamp so the title-bar always
remains reachable on at least one edge — drag can push the window
mostly off-screen but cannot lose it entirely.
Chromeless widgets are excluded from drag-detection (their title-bar
is invisible — there is no defined drag-handle). If a modder sets both
chromeless = true and draggable = true, the chromeless guard wins;
clicks pass through to the widget's handler unchanged.
Resize
panel.register("toolbox", widget, {
layout = function(_sw, _sh) return {x=100, y=100, w=240, h=300} end,
resizable = true,
min_size = { w = 180, h = 150 },
max_size = { w = 600, h = 480 },
})
resizable = true renders a 12x12 SE-corner handle (filled rect in
border_color) and starts a resize on left-click within the handle.
The handle is drawn even on chromeless widgets — the modder opted in,
and the 12px square is the only visual cue. Resize updates the
window's bounds_override w/h; resize does not move the
top-left corner. Width and height clamp to opts.min_size and
opts.max_size.
v0.4.0 supports the SE corner only. NE/SW/NW + edge-resize are deferred.
Bounds-Override Pattern
When drag or resize first fires, the window's record gains a
bounds_override = {x, y, w, h} field. From that point on, the helper
get_bounds_for(win, sw, sh) returns bounds_override instead of
calling resolve_bounds(opts.layout, sw, sh). All render + dispatch +
point_in_any_panel callsites use get_bounds_for, so the override
is consistent across the API.
bounds_override survives until:
- The widget is closed via
panel.close(id)(cleared). - The widget is unregistered.
panel._test_reset_all()is called.
There is no public API to clear bounds_override — re-opening a closed
draggable widget gives it the layout-fn's bounds again. Persistent
windows that drag during a session keep their dragged position until
the next process start (no save/load yet — deferred post-v0.4.0).
Test backdoors (v0.4.0 additions)
panel._test_simulate_drag_start(id, mx, my)— force drag-state.panel._test_simulate_drag_move(mx, my)— drive one drag-step.panel._test_simulate_drag_end()— release the drag.panel._test_simulate_resize_start(id, mx, my)— force resize-state.panel._test_simulate_resize_move(mx, my)— drive one resize-step.panel._test_simulate_resize_end()— release the resize.panel._test_get_dragging_id()— currently dragged widget_id, or nil.panel._test_get_resizing_id()— currently resized widget_id, or nil.
The simulate-backdoors bypass engine.input polling so test-libs can
drive drag + resize without a running game loop.