bounds_override actually survives panel.close(id) + re-open (drag/resize position persists). It is only cleared by unregister or _test_reset_all. Previously the README claimed close(id) cleared it, contradicting the implementation in M.close. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
685 lines
25 KiB
Markdown
685 lines
25 KiB
Markdown
# 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
|
|
|
|
<!-- 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.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 by `focus()` (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=true` makes left-click in the title-bar
|
|
area start a drag. Drag updates a per-window `bounds_override` that
|
|
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=true` renders a 12x12 SE-corner handle
|
|
and starts a resize on left-click. Resize clamps to `opts.min_size`
|
|
(default `{w=120, h=80}`) and `opts.max_size` (default
|
|
`{w=99999, h=99999}`).
|
|
- `bounds_override` survives 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=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. v0.4.0: respects `bounds_override` from 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, custom `function(sw, sh) -> {x,y,w,h}`, or
|
|
`nil` (defaults to `"center"`). See the [Layout-Slots](#layout-slots)
|
|
table.
|
|
- `z_tier` — `"hud"` | `"normal"` (default) | `"top"`. Controls render
|
|
order (hud → normal → top) and hit-test order (top → normal → hud).
|
|
- `persistent` — `bool` (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](#input-block-routing-v030).
|
|
- `chromeless` — `bool` (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.
|
|
- `draggable` — `bool` (default `false`, 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` (default `false`, 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:**
|
|
```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 (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:**
|
|
```lua
|
|
-- 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:**
|
|
```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.
|
|
|
|
## 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
|
|
|
|
```lua
|
|
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:
|
|
|
|
```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
|
|
```
|
|
|
|
## v0.4.0 — Focus + Drag + Resize
|
|
|
|
### Focus API
|
|
|
|
```lua
|
|
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
|
|
|
|
```lua
|
|
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
|
|
|
|
```lua
|
|
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:
|
|
- Mouse-release after drag/resize (the moved/resized state stays).
|
|
- `panel.close(id)` followed by `panel.open(id)` — the window re-opens
|
|
at its last-dragged/resized position. (Spec contract: drag/resize
|
|
wins over layout-fn.)
|
|
|
|
`bounds_override` is cleared when:
|
|
- The widget is `panel.unregister(id)` (window record destroyed).
|
|
- `panel._test_reset_all()` is called (test backdoor).
|
|
|
|
Modders cannot programmatically clear `bounds_override` without
|
|
unregister/re-register. A future version may add a
|
|
`panel.reset_bounds(id)` helper if demand emerges. 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.
|