Files
sporel-lib-core.panel/README.md
Calic cdf1f63895 docs: README cleanup for v0.2.0 multi-active model
- Rewrite §Scope from v0.1.0 to v0.2.0: lists multi-active +
  layout-slots as supported; restages deferred items per phase.
- Replace "active" with "open"/"focused" in API prose for
  unregister + update + render (no scalar `active` exists in
  v0.2.0).
- Add cross-ref under panel.open noting the v0.2.0 behavioral
  change (open does not auto-close other panels).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 01:57:02 +02:00

432 lines
14 KiB
Markdown

# lib-core.panel
Generic overlay-panel framework. v0.2.0 supports multiple panels open at
the same time with 11 named layout-slot templates + custom-fn hook for
bespoke positioning. Handles input dispatch (mouse click + wheel,
edge-detected with reverse-open-order hit-test), renders titled panel
overlays, and supports a context-menu layer on top. Designed as the glue
layer between game modules and the engine render/input surfaces.
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.
**Version:** 0.2.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
<!-- topology:start (auto-generated; do not edit) -->
```mermaid
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
```
<!-- topology:end -->
## Scope (v0.2.0)
v0.2.0 ships multi-active panels with layout-slot positioning,
preserving full backward-compatibility with v0.1.1's single-active
callers via shim semantics. 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).
### Supported (v0.2.0)
- Multi-active panels: multiple windows open simultaneously, each
rendered + hit-tested independently.
- 11 layout-slot templates (center, left, right, top, bottom, four
corners, left-half, right-half) for common positioning.
- Custom layout-fn hook `function(sw, sh) -> {x,y,w,h}` for bespoke
positioning (HUD elements, status displays, custom tool UIs).
- 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.3.0+)
- Z-order tiers (hud / normal / top) for layered rendering.
- Persistent windows (always-open, exempt from ESC-close).
- Input-block modes (none/self/all) for pass-through vs modal capture.
- Map-editor-style multi-region UI as first-class persistent widgets.
### 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 or `function(sw, sh) -> {x,y,w,h}`; defaults to `"center"`) and `z_tier` (`"normal"`; v0.2.0 only the normal tier is exposed). See the [Multi-Active + Layout-Slots](#v020--multi-active--layout-slots) section below for the full slot table.
**Example:**
```lua
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. Without arg (v0.1.1
bw-compat): closes the focused (last-opened) window. Clears any open
context-menu when the last open window is closed.
---
### `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:**
```lua
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:**
```lua
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:**
```lua
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.
## 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:
```lua
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`:
```lua
-- 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):
```lua
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
```