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.
This commit is contained in:
185
README.md
185
README.md
@@ -1,20 +1,24 @@
|
|||||||
# lib-core.panel
|
# lib-core.panel
|
||||||
|
|
||||||
Generic overlay-panel framework. v0.2.0 supports multiple panels open at
|
Generic overlay-panel framework. v0.3.0 layers **z-tiers** (hud / normal
|
||||||
the same time with 11 named layout-slot templates + custom-fn hook for
|
/ top), **persistent windows**, and **input-block routing** (none / self
|
||||||
bespoke positioning. Handles input dispatch (mouse click + wheel,
|
/ all) on top of v0.2.0's multi-active model with 11 named layout-slot
|
||||||
edge-detected with reverse-open-order hit-test), renders titled panel
|
templates + custom-fn hook. Render iterates per-tier (hud → normal →
|
||||||
overlays, and supports a context-menu layer on top. Designed as the glue
|
top); input dispatch iterates reverse (top → hud, within-tier reverse
|
||||||
layer between game modules and the engine render/input surfaces.
|
open-order); modal `input_block="all"` swallows misses.
|
||||||
|
|
||||||
The single-active model from v0.1.1 is preserved as a backward-
|
The single-active model from v0.1.1 is preserved as a backward-
|
||||||
compatibility shim — `panel.open(id)`, `panel.close()`, `panel.is_open()`
|
compatibility shim — `panel.open(id)`, `panel.close()`, `panel.is_open()`
|
||||||
without an id-arg keep their old semantics.
|
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.2.0
|
**Version:** 0.3.0
|
||||||
**Lib-ID:** lib-core.panel
|
**Lib-ID:** lib-core.panel
|
||||||
**Requires:** engine.render.*, engine.input.*, lib-core.input (lazy, for default-trigger)
|
**Requires:** engine.render.*, engine.input.*, lib-core.input (lazy, for default-trigger)
|
||||||
**Tags:** panel, overlay, ui, input, context-menu
|
**Tags:** panel, overlay, ui, input, context-menu, window-manager
|
||||||
|
|
||||||
## Topology
|
## Topology
|
||||||
|
|
||||||
@@ -31,23 +35,36 @@ graph LR
|
|||||||
```
|
```
|
||||||
<!-- topology:end -->
|
<!-- topology:end -->
|
||||||
|
|
||||||
## Scope (v0.2.0)
|
## Scope (v0.3.0)
|
||||||
|
|
||||||
v0.2.0 ships multi-active panels with layout-slot positioning,
|
v0.3.0 layers z-tiers + persistent + input-block on top of v0.2.0's
|
||||||
preserving full backward-compatibility with v0.1.1's single-active
|
multi-active model, fully backward-compatible with v0.2.0 and v0.1.1
|
||||||
callers via shim semantics. The lib is the generic UI-Framework
|
callers. The lib is the generic UI-Framework substrate — domain-free
|
||||||
substrate — domain-free per ADR-0001/ADR-0049, consumed by Display-
|
per ADR-0001/ADR-0049, consumed by Display-Libs (inventory-list-
|
||||||
Libs (inventory-list-display, crafting-display, notify-display) and
|
display, crafting-display, notify-display) and modules (vagrant-
|
||||||
modules (vagrant-skeleton).
|
skeleton, map-editor).
|
||||||
|
|
||||||
### Supported (v0.2.0)
|
### Supported (v0.3.0)
|
||||||
|
|
||||||
- Multi-active panels: multiple windows open simultaneously, each
|
- **Z-tiers**: render order hud → normal → top. Input dispatch reverse
|
||||||
rendered + hit-tested independently.
|
(top → hud, within-tier reverse open-order).
|
||||||
- 11 layout-slot templates (center, left, right, top, bottom, four
|
- **Persistent windows**: `opts.persistent=true` auto-opens at register-
|
||||||
corners, left-half, right-half) for common positioning.
|
time. Arg-less `panel.close()` becomes no-op when focused is
|
||||||
- Custom layout-fn hook `function(sw, sh) -> {x,y,w,h}` for bespoke
|
persistent (so ESC/default-trigger cannot dismiss a persistent
|
||||||
positioning (HUD elements, status displays, custom tool UIs).
|
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
|
- Bw-Compat-Shim: v0.1.1 `register(id, widget)` ohne opts works
|
||||||
unchanged; `close()`/`is_open()` ohne arg map to focused = last-
|
unchanged; `close()`/`is_open()` ohne arg map to focused = last-
|
||||||
opened.
|
opened.
|
||||||
@@ -55,13 +72,6 @@ modules (vagrant-skeleton).
|
|||||||
- Context-menu via `show_context_menu` (unchanged).
|
- Context-menu via `show_context_menu` (unchanged).
|
||||||
- Default-trigger key-binding via `bind_default_trigger` (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+)
|
### Deferred (v0.4.0+)
|
||||||
|
|
||||||
- Click-to-focus and explicit focus()/raise()/lower() API.
|
- Click-to-focus and explicit focus()/raise()/lower() API.
|
||||||
@@ -83,7 +93,22 @@ modules (vagrant-skeleton).
|
|||||||
|
|
||||||
**Syntax:** `panel.register(widget_id: string, widget_def: table, opts?: table) -> void`
|
**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.
|
`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.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```lua
|
```lua
|
||||||
@@ -138,12 +163,39 @@ below for the migration recipe.
|
|||||||
|
|
||||||
**Syntax:** `panel.close(widget_id?: string) -> void`
|
**Syntax:** `panel.close(widget_id?: string) -> void`
|
||||||
|
|
||||||
With `widget_id`: closes that specific window. Without arg (v0.1.1
|
With `widget_id`: closes that specific window (DOES close persistent
|
||||||
bw-compat): closes the focused (last-opened) window. Clears any open
|
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.
|
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)`
|
### `panel.toggle(widget_id)`
|
||||||
|
|
||||||
**Syntax:** `panel.toggle(widget_id: string) -> void`
|
**Syntax:** `panel.toggle(widget_id: string) -> void`
|
||||||
@@ -346,6 +398,73 @@ If you relied on the v0.1.1 "open(B) closes A" behavior, call
|
|||||||
window, or nil if not open.
|
window, or nil if not open.
|
||||||
- `panel._test_get_screen_size()` — current screen size used by layouts.
|
- `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
|
## Theme Schema
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|
|||||||
247
init.lua
247
init.lua
@@ -1,17 +1,22 @@
|
|||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
-- lib-core.panel v0.2.0 — Generic overlay-panel framework (Multi-Active)
|
-- lib-core.panel v0.3.0 — Generic overlay-panel framework (Window-Manager)
|
||||||
--
|
--
|
||||||
-- v0.2.0 evolves the v0.1 single-active model into a multi-active
|
-- v0.3.0 layers z_tiers (hud → normal → top), persistent windows, and
|
||||||
-- window-manager with 11 named layout-slots + custom-fn hook. All
|
-- input_block routing (none/self/all) on top of v0.2.0's multi-active +
|
||||||
-- v0.1.1 callers keep their semantics via a backward-compatibility
|
-- layout-slot model. Render iterates per-z-tier (hud → normal → top);
|
||||||
-- shim (open/close/is_open without id-arg operate on the focused panel
|
-- input dispatch iterates reverse (top → hud, within-tier reverse-open-
|
||||||
-- = last-opened).
|
-- order). `persistent=true` registers a window as open-from-register-time
|
||||||
|
-- and exempts it from bw-compat close() (modder must explicit-close).
|
||||||
--
|
--
|
||||||
-- Provides:
|
-- Provides:
|
||||||
-- - Widget registry + lifecycle (register, open, close, toggle)
|
-- - Widget registry + lifecycle (register, open, close, toggle)
|
||||||
-- - Multi-active open_order (last-opened = focused = topmost)
|
-- - Multi-active open_order (last-opened = focused = topmost within tier)
|
||||||
-- - 11 named layout-slot templates + opts.layout = function(sw, sh)
|
-- - 11 named layout-slot templates + opts.layout = function(sw, sh)
|
||||||
-- - Per-frame input dispatch with reverse-open-order hit-test
|
-- - Z-tier rendering: hud (bottom) → normal → top (drawn last)
|
||||||
|
-- - Persistent windows: open at register-time; ESC-equivalent no-op
|
||||||
|
-- - Input-block routing: none / self / all (modal swallows misses)
|
||||||
|
-- - panel.point_in_any_panel(x,y): public hit-test for module-side canvas
|
||||||
|
-- - Per-frame input dispatch with reverse-z-tier reverse-open-order hit
|
||||||
-- - Context-menu (show, hit-test, auto-close) on top of windows
|
-- - Context-menu (show, hit-test, auto-close) on top of windows
|
||||||
-- - Default-trigger binding via lib-core.input (lazy-required)
|
-- - Default-trigger binding via lib-core.input (lazy-required)
|
||||||
-- - Optional pause-gate (any open widget with pause_on_open=true)
|
-- - Optional pause-gate (any open widget with pause_on_open=true)
|
||||||
@@ -27,12 +32,11 @@
|
|||||||
-- - engine.render.measure_text(text, font_size) → w, h
|
-- - engine.render.measure_text(text, font_size) → w, h
|
||||||
--
|
--
|
||||||
-- Screen-size limitation: engine.render does NOT expose get_screen_size()
|
-- Screen-size limitation: engine.render does NOT expose get_screen_size()
|
||||||
-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.2 still falls
|
-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.3 still falls
|
||||||
-- back to 1280x720 constants matching the default Sporel window config.
|
-- back to 1280x720 constants matching the default Sporel window config.
|
||||||
--
|
--
|
||||||
-- DEFERRED (v0.2 non-goals):
|
-- DEFERRED (v0.3 non-goals):
|
||||||
-- - Persistent panels (always-on HUD widgets) → v0.3
|
-- - Focus API (focus / raise / lower) → v0.4
|
||||||
-- - z_tier other than "normal" (modal, hud, system) → v0.4
|
|
||||||
-- - Drag + resize → v0.4
|
-- - Drag + resize → v0.4
|
||||||
-- - Keyboard navigation within widgets
|
-- - Keyboard navigation within widgets
|
||||||
-- - Panel animation (fade in/out)
|
-- - Panel animation (fade in/out)
|
||||||
@@ -125,6 +129,28 @@ local function estimate_menu_width(actions, padding)
|
|||||||
return max_w
|
return max_w
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- v0.3.0 — Z-Tiers + Input-Block defaults
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Z-Tier constants. Bottom-to-top render order = HUD (drawn first =
|
||||||
|
-- background), then normal (toolbars/persistent panels), then top
|
||||||
|
-- (drawn last = modal dialogs). Input dispatch iterates reverse.
|
||||||
|
local VALID_Z_TIERS = { hud = true, normal = true, top = true }
|
||||||
|
local Z_TIER_ORDER = { "hud", "normal", "top" }
|
||||||
|
|
||||||
|
-- Default input_block per tier when widget does not specify one.
|
||||||
|
-- hud: pure cosmetic overlay, never absorbs input (HUD bars, status).
|
||||||
|
-- normal: absorbs clicks within own bounds (persistent toolbars).
|
||||||
|
-- top: modal; swallows clicks that miss it too (blocking dialogs).
|
||||||
|
local DEFAULT_INPUT_BLOCK_BY_TIER = {
|
||||||
|
hud = "none",
|
||||||
|
normal = "self",
|
||||||
|
top = "all",
|
||||||
|
}
|
||||||
|
|
||||||
|
local VALID_INPUT_BLOCK = { none = true, self = true, all = true }
|
||||||
|
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
-- v0.2.0 — Layout slots (per Phase Panel-WM spec §4.1)
|
-- v0.2.0 — Layout slots (per Phase Panel-WM spec §4.1)
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
@@ -235,6 +261,25 @@ local function render_one_window(widget_id, win, sw, sh)
|
|||||||
local panel_x, panel_y, panel_w, panel_h = bounds.x, bounds.y, bounds.w, bounds.h
|
local panel_x, panel_y, panel_w, panel_h = bounds.x, bounds.y, bounds.w, bounds.h
|
||||||
local padding = theme.padding
|
local padding = theme.padding
|
||||||
|
|
||||||
|
-- v0.3.0: chromeless widgets skip the panel-lib decorations (bg /
|
||||||
|
-- border / title bar / close-X) entirely. The widget gets the FULL
|
||||||
|
-- panel bounds as ctx.bounds (no title-bar inset). Intended for
|
||||||
|
-- in-game persistent HUD widgets (toolbars, layer-pickers, palettes)
|
||||||
|
-- that want to render their own chrome.
|
||||||
|
if win.opts.chromeless then
|
||||||
|
win._content_bounds = {
|
||||||
|
x = panel_x, y = panel_y, w = panel_w, h = panel_h,
|
||||||
|
}
|
||||||
|
win._close_button_rect = nil
|
||||||
|
local widget_ctx = {
|
||||||
|
bounds = win._content_bounds,
|
||||||
|
theme = M.get_theme(),
|
||||||
|
is_focused = (widget_id == open_order[#open_order]),
|
||||||
|
}
|
||||||
|
widget_def.render(widget_ctx)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
-- Background
|
-- Background
|
||||||
engine.render.draw_rect(panel_x, panel_y, panel_w, panel_h, theme.bg_color)
|
engine.render.draw_rect(panel_x, panel_y, panel_w, panel_h, theme.bg_color)
|
||||||
|
|
||||||
@@ -334,32 +379,48 @@ end
|
|||||||
|
|
||||||
--- _dispatch_event(event): route a synthetic or real input event.
|
--- _dispatch_event(event): route a synthetic or real input event.
|
||||||
--- event = {kind="click", x, y, button="left"|"right"} or {kind="wheel", dy}
|
--- event = {kind="click", x, y, button="left"|"right"} or {kind="wheel", dy}
|
||||||
|
---
|
||||||
|
--- v0.3.0 routing:
|
||||||
|
--- 1. Context-menu (if open) always wins.
|
||||||
|
--- 2. Iterate reverse z_tier (top → normal → hud), within tier
|
||||||
|
--- reverse-open-order. For each open window:
|
||||||
|
--- - input_block="none": skip hit-test entirely (HUD passes through).
|
||||||
|
--- - else: hit-test bounds; on hit route to widget.
|
||||||
|
--- 3. If no window claimed the event AND any open window has
|
||||||
|
--- input_block="all": swallow (modal block).
|
||||||
|
--- 4. Else drop (no game-routing — module handles its own input).
|
||||||
local function _dispatch_event(event)
|
local function _dispatch_event(event)
|
||||||
-- Context-menu always wins if open (consumes the next click).
|
-- Context-menu always wins if open (consumes the next click).
|
||||||
if ctx_menu then
|
if ctx_menu then
|
||||||
if dispatch_context_menu_event(event) then return end
|
if dispatch_context_menu_event(event) then return end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Iterate reverse-open-order (last-opened = topmost gets first crack).
|
|
||||||
local sw, sh = get_screen_size()
|
local sw, sh = get_screen_size()
|
||||||
|
local saw_modal = false -- track if any input_block="all" window is open
|
||||||
|
|
||||||
|
-- Iterate reverse z-tier (top first), within tier reverse-open-order.
|
||||||
|
for ti = #Z_TIER_ORDER, 1, -1 do
|
||||||
|
local tier_name = Z_TIER_ORDER[ti]
|
||||||
for i = #open_order, 1, -1 do
|
for i = #open_order, 1, -1 do
|
||||||
local widget_id = open_order[i]
|
local widget_id = open_order[i]
|
||||||
local win = windows[widget_id]
|
local win = windows[widget_id]
|
||||||
if win and win.open then
|
if win and win.open and win.opts.z_tier == tier_name then
|
||||||
|
if win.opts.input_block == "all" then
|
||||||
|
saw_modal = true
|
||||||
|
end
|
||||||
|
if win.opts.input_block ~= "none" then
|
||||||
local b = resolve_bounds(win.opts.layout, sw, sh)
|
local b = resolve_bounds(win.opts.layout, sw, sh)
|
||||||
|
|
||||||
-- Click events use hit-test against window bounds.
|
|
||||||
if event.kind == "click" then
|
if event.kind == "click" then
|
||||||
if event.x and event.y
|
if event.x and event.y
|
||||||
and event.x >= b.x and event.x < b.x + b.w
|
and event.x >= b.x and event.x < b.x + b.w
|
||||||
and event.y >= b.y and event.y < b.y + b.h then
|
and event.y >= b.y and event.y < b.y + b.h then
|
||||||
-- Close-button intercept (top-right X): left-click closes
|
-- Close-button intercept (top-right X): left-click
|
||||||
-- the window before forwarding to the widget.
|
-- closes the window before forwarding to the widget.
|
||||||
if event.button == "left" then
|
if event.button == "left" then
|
||||||
local cb = win._close_button_rect
|
local cb = win._close_button_rect
|
||||||
if not cb then
|
if not cb then
|
||||||
-- Render hasn't run yet (e.g. test-mode); compute now
|
cb = close_button_rect(b.x, b.y, b.w,
|
||||||
cb = close_button_rect(b.x, b.y, b.w, theme.padding, theme.font_size_title)
|
theme.padding, theme.font_size_title)
|
||||||
end
|
end
|
||||||
if event.x >= cb.x and event.x < cb.x + cb.w
|
if event.x >= cb.x and event.x < cb.x + cb.w
|
||||||
and event.y >= cb.y and event.y < cb.y + cb.h then
|
and event.y >= cb.y and event.y < cb.y + cb.h then
|
||||||
@@ -368,10 +429,11 @@ local function _dispatch_event(event)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Route click to widget
|
|
||||||
local content = win._content_bounds
|
local content = win._content_bounds
|
||||||
if not content then
|
if not content then
|
||||||
-- Render hasn't run; compute content bounds now
|
if win.opts.chromeless then
|
||||||
|
content = { x = b.x, y = b.y, w = b.w, h = b.h }
|
||||||
|
else
|
||||||
local padding = theme.padding
|
local padding = theme.padding
|
||||||
local title_area_h = theme.font_size_title + padding * 2
|
local title_area_h = theme.font_size_title + padding * 2
|
||||||
content = {
|
content = {
|
||||||
@@ -381,6 +443,7 @@ local function _dispatch_event(event)
|
|||||||
h = b.h - title_area_h - padding,
|
h = b.h - title_area_h - padding,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
local widget_ctx = {
|
local widget_ctx = {
|
||||||
bounds = content,
|
bounds = content,
|
||||||
theme = M.get_theme(),
|
theme = M.get_theme(),
|
||||||
@@ -389,13 +452,16 @@ local function _dispatch_event(event)
|
|||||||
win.widget_def.handle_input(widget_ctx, event)
|
win.widget_def.handle_input(widget_ctx, event)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- v0.2.0: no input_block tiers yet — miss falls through to next window
|
|
||||||
-- (v0.3.0 will add modal-blocking)
|
|
||||||
elseif event.kind == "wheel" then
|
elseif event.kind == "wheel" then
|
||||||
-- Wheel events route to the focused window (last-opened).
|
-- Wheel routes to the focused (last-opened) window
|
||||||
|
-- regardless of tier — once we reach the focused id
|
||||||
|
-- in tier-walk order we forward and stop.
|
||||||
if widget_id == open_order[#open_order] then
|
if widget_id == open_order[#open_order] then
|
||||||
local content = win._content_bounds
|
local content = win._content_bounds
|
||||||
if not content then
|
if not content then
|
||||||
|
if win.opts.chromeless then
|
||||||
|
content = { x = b.x, y = b.y, w = b.w, h = b.h }
|
||||||
|
else
|
||||||
local padding = theme.padding
|
local padding = theme.padding
|
||||||
local title_area_h = theme.font_size_title + padding * 2
|
local title_area_h = theme.font_size_title + padding * 2
|
||||||
content = {
|
content = {
|
||||||
@@ -405,6 +471,7 @@ local function _dispatch_event(event)
|
|||||||
h = b.h - title_area_h - padding,
|
h = b.h - title_area_h - padding,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
local widget_ctx = {
|
local widget_ctx = {
|
||||||
bounds = content,
|
bounds = content,
|
||||||
theme = M.get_theme(),
|
theme = M.get_theme(),
|
||||||
@@ -416,7 +483,15 @@ local function _dispatch_event(event)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- v0.2.0: events that hit no panel are dropped (no game-routing yet)
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- No window claimed the event. If any modal-block ("all") is open,
|
||||||
|
-- swallow the event so it does not reach the game-layer. Otherwise
|
||||||
|
-- drop it silently (modules read engine.input directly for canvas
|
||||||
|
-- interactions and can call panel.point_in_any_panel(x,y) to skip
|
||||||
|
-- clicks that fall within a panel area).
|
||||||
|
if saw_modal then return end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
@@ -437,7 +512,12 @@ end
|
|||||||
--- layout = "center" (default) | "left" | "right" | "top" | "bottom" |
|
--- layout = "center" (default) | "left" | "right" | "top" | "bottom" |
|
||||||
--- "top-left" | "top-right" | "bottom-left" | "bottom-right" |
|
--- "top-left" | "top-right" | "bottom-left" | "bottom-right" |
|
||||||
--- "left-half" | "right-half" | function(sw, sh) -> {x,y,w,h}
|
--- "left-half" | "right-half" | function(sw, sh) -> {x,y,w,h}
|
||||||
--- z_tier = "normal" (default; v0.2.0 only "normal" exposed)
|
--- z_tier = "hud" | "normal" (default) | "top"
|
||||||
|
--- persistent = bool (default false) — if true, window auto-opens at
|
||||||
|
--- register-time and panel.close() without arg becomes a
|
||||||
|
--- no-op when it's focused (modder must close(id) explicit).
|
||||||
|
--- input_block = "none" | "self" | "all" — defaults per tier:
|
||||||
|
--- hud → "none", normal → "self", top → "all".
|
||||||
function M.register(widget_id, widget_def, opts)
|
function M.register(widget_id, widget_def, opts)
|
||||||
if type(widget_id) ~= "string" then
|
if type(widget_id) ~= "string" then
|
||||||
error("panel.register: widget_id must be a string, got " .. type(widget_id))
|
error("panel.register: widget_id must be a string, got " .. type(widget_id))
|
||||||
@@ -458,20 +538,49 @@ function M.register(widget_id, widget_def, opts)
|
|||||||
error("panel.register: duplicate widget_id '" .. widget_id .. "'")
|
error("panel.register: duplicate widget_id '" .. widget_id .. "'")
|
||||||
end
|
end
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
|
|
||||||
|
-- v0.3.0: validate z_tier + input_block eagerly so typos loud-error
|
||||||
|
-- at register-time (not at the next frame's render/dispatch).
|
||||||
|
if opts.z_tier ~= nil and not VALID_Z_TIERS[opts.z_tier] then
|
||||||
|
error(string.format(
|
||||||
|
"panel.register: unknown z_tier '%s' (must be hud|normal|top)",
|
||||||
|
tostring(opts.z_tier)), 2)
|
||||||
|
end
|
||||||
|
if opts.input_block ~= nil and not VALID_INPUT_BLOCK[opts.input_block] then
|
||||||
|
error(string.format(
|
||||||
|
"panel.register: unknown input_block '%s' (must be none|self|all)",
|
||||||
|
tostring(opts.input_block)), 2)
|
||||||
|
end
|
||||||
|
|
||||||
-- Validate opts.layout eagerly (so unknown-slot errors surface at register, not later).
|
-- Validate opts.layout eagerly (so unknown-slot errors surface at register, not later).
|
||||||
local sw, sh = get_screen_size()
|
local sw, sh = get_screen_size()
|
||||||
local _validation_bounds = resolve_bounds(opts.layout, sw, sh)
|
local _validation_bounds = resolve_bounds(opts.layout, sw, sh)
|
||||||
_ = _validation_bounds -- discard; bounds re-resolved per-frame to react to screen-resize
|
_ = _validation_bounds -- discard; bounds re-resolved per-frame to react to screen-resize
|
||||||
|
|
||||||
|
local z_tier = opts.z_tier or "normal"
|
||||||
|
local input_block = opts.input_block or DEFAULT_INPUT_BLOCK_BY_TIER[z_tier]
|
||||||
|
local persistent = opts.persistent == true
|
||||||
|
local chromeless = opts.chromeless == true
|
||||||
|
|
||||||
widgets[widget_id] = widget_def
|
widgets[widget_id] = widget_def
|
||||||
windows[widget_id] = {
|
windows[widget_id] = {
|
||||||
widget_def = widget_def,
|
widget_def = widget_def,
|
||||||
opts = {
|
opts = {
|
||||||
layout = opts.layout, -- nil OR string OR fn
|
layout = opts.layout, -- nil OR string OR fn
|
||||||
z_tier = opts.z_tier or "normal",
|
z_tier = z_tier,
|
||||||
|
persistent = persistent,
|
||||||
|
input_block = input_block,
|
||||||
|
chromeless = chromeless,
|
||||||
},
|
},
|
||||||
open = false,
|
open = false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- v0.3.0: persistent windows are open from register-time and pushed
|
||||||
|
-- onto open_order so they appear in render + hit-test immediately.
|
||||||
|
if persistent then
|
||||||
|
open_order[#open_order + 1] = widget_id
|
||||||
|
windows[widget_id].open = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- M.unregister(widget_id)
|
--- M.unregister(widget_id)
|
||||||
@@ -507,8 +616,11 @@ function M.open(widget_id)
|
|||||||
end
|
end
|
||||||
|
|
||||||
--- M.close(widget_id?)
|
--- M.close(widget_id?)
|
||||||
--- With id: closes that specific widget. Without id: closes the focused
|
--- With id: closes that specific widget (DOES close persistent — explicit
|
||||||
--- (= last-opened) for v0.1.1 bw-compat.
|
--- modder action). Without id: closes the focused (= last-opened) for
|
||||||
|
--- v0.1.1 bw-compat — BUT v0.3.0 makes this a no-op when the focused
|
||||||
|
--- window is persistent (ESC-equivalent should not dismiss persistent
|
||||||
|
--- toolbars/HUDs).
|
||||||
function M.close(widget_id)
|
function M.close(widget_id)
|
||||||
if widget_id == nil then
|
if widget_id == nil then
|
||||||
-- bw-compat: close focused
|
-- bw-compat: close focused
|
||||||
@@ -518,9 +630,15 @@ function M.close(widget_id)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
widget_id = open_order[#open_order]
|
widget_id = open_order[#open_order]
|
||||||
|
-- v0.3.0: persistent windows are exempt from arg-less close
|
||||||
|
-- (so ESC / `panel.close()` cannot dismiss persistent panels).
|
||||||
|
if windows[widget_id] and windows[widget_id].opts.persistent then
|
||||||
|
return
|
||||||
|
end
|
||||||
end
|
end
|
||||||
local w = windows[widget_id]
|
local w = windows[widget_id]
|
||||||
if not w or not w.open then return end
|
if not w or not w.open then return end
|
||||||
|
-- Explicit close(id) DOES close persistent windows (modder-controlled).
|
||||||
w.open = false
|
w.open = false
|
||||||
local idx = find_in_array(open_order, widget_id)
|
local idx = find_in_array(open_order, widget_id)
|
||||||
if idx then table.remove(open_order, idx) end
|
if idx then table.remove(open_order, idx) end
|
||||||
@@ -564,6 +682,29 @@ function M.is_pausing()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- M.point_in_any_panel(x, y) → bool
|
||||||
|
--- 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.* but should skip canvas-paint when the
|
||||||
|
--- click landed within a panel-widget area. Returns false if no panels
|
||||||
|
--- are open.
|
||||||
|
function M.point_in_any_panel(x, y)
|
||||||
|
if #open_order == 0 then return false end
|
||||||
|
local sw, sh = get_screen_size()
|
||||||
|
for _, id in ipairs(open_order) do
|
||||||
|
local w = windows[id]
|
||||||
|
if w and w.open then
|
||||||
|
local b = resolve_bounds(w.opts.layout, sw, sh)
|
||||||
|
if x >= b.x and x < b.x + b.w
|
||||||
|
and y >= b.y and y < b.y + b.h then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
-- Theme
|
-- Theme
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
@@ -726,20 +867,24 @@ end
|
|||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
--- M.render()
|
--- M.render()
|
||||||
--- Must be called each render frame. Iterates open_order bottom-up
|
--- Must be called each render frame. v0.3.0: iterates per-z-tier
|
||||||
--- (last = focused = topmost) and draws each window. Renders any open
|
--- (hud → normal → top) and within each tier walks open_order bottom-up
|
||||||
--- context-menu on top of all windows. No-op if nothing is open.
|
--- so HUD draws first (= background) and modal top tier draws last (=
|
||||||
|
--- topmost). Renders any open context-menu on top of all windows. No-op
|
||||||
|
--- if nothing is open.
|
||||||
function M.render()
|
function M.render()
|
||||||
if #open_order == 0 then return end
|
if #open_order == 0 then return end
|
||||||
local sw, sh = get_screen_size()
|
local sw, sh = get_screen_size()
|
||||||
|
for _, tier_name in ipairs(Z_TIER_ORDER) do
|
||||||
for _, widget_id in ipairs(open_order) do
|
for _, widget_id in ipairs(open_order) do
|
||||||
local w = windows[widget_id]
|
local w = windows[widget_id]
|
||||||
if w and w.open then
|
if w and w.open and w.opts.z_tier == tier_name then
|
||||||
render_one_window(widget_id, w, sw, sh)
|
render_one_window(widget_id, w, sw, sh)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Context-menu (rendered on top of all windows)
|
-- Context-menu (rendered on top of all windows + tiers)
|
||||||
if ctx_menu then
|
if ctx_menu then
|
||||||
render_context_menu()
|
render_context_menu()
|
||||||
end
|
end
|
||||||
@@ -794,4 +939,36 @@ function M._test_get_screen_size()
|
|||||||
return get_screen_size()
|
return get_screen_size()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- M._test_get_render_order() — returns array of widget_ids in the
|
||||||
|
--- order M.render() would iterate them (per-tier hud→normal→top, within
|
||||||
|
--- tier in open_order). v0.3.0 addition.
|
||||||
|
function M._test_get_render_order()
|
||||||
|
local out = {}
|
||||||
|
for _, tier_name in ipairs(Z_TIER_ORDER) do
|
||||||
|
for _, widget_id in ipairs(open_order) do
|
||||||
|
local w = windows[widget_id]
|
||||||
|
if w and w.open and w.opts.z_tier == tier_name then
|
||||||
|
out[#out + 1] = widget_id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M._test_get_input_block(widget_id) — returns the resolved
|
||||||
|
--- input_block string for an open window, or nil. v0.3.0 addition.
|
||||||
|
function M._test_get_input_block(widget_id)
|
||||||
|
local w = windows[widget_id]
|
||||||
|
if not w then return nil end
|
||||||
|
return w.opts.input_block
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M._test_get_persistent(widget_id) — returns the persistent flag for
|
||||||
|
--- a registered window, or nil if not registered. v0.3.0 addition.
|
||||||
|
function M._test_get_persistent(widget_id)
|
||||||
|
local w = windows[widget_id]
|
||||||
|
if not w then return nil end
|
||||||
|
return w.opts.persistent
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"id":"lib-core.panel","version":"0.2.0","api_min":"0.1","deps":[]}
|
{"id":"lib-core.panel","version":"0.3.0","api_min":"0.1","deps":[]}
|
||||||
Reference in New Issue
Block a user