From 1fcdbd192bb3ff6abc231c192f3042b0b523374c Mon Sep 17 00:00:00 2001 From: Calic Date: Mon, 15 Jun 2026 02:25:46 +0200 Subject: [PATCH] feat: v0.3.0 Persistent + Z-Tiers + Input-Block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 185 +++++++++++++++++++++----- init.lua | 361 ++++++++++++++++++++++++++++++++++++++------------- manifest.lib | 2 +- 3 files changed, 422 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 76468ad..9e7cc20 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # 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. +Generic overlay-panel framework. v0.3.0 layers **z-tiers** (hud / normal +/ top), **persistent windows**, and **input-block routing** (none / self +/ all) on top of v0.2.0's multi-active model with 11 named layout-slot +templates + custom-fn hook. Render iterates per-tier (hud → normal → +top); input dispatch iterates reverse (top → hud, within-tier reverse +open-order); modal `input_block="all"` swallows misses. The single-active model from v0.1.1 is preserved as a backward- compatibility shim — `panel.open(id)`, `panel.close()`, `panel.is_open()` -without an id-arg keep their old semantics. +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 **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 @@ -31,23 +35,36 @@ graph LR ``` -## Scope (v0.2.0) +## Scope (v0.3.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). +v0.3.0 layers z-tiers + persistent + input-block on top of v0.2.0's +multi-active model, fully backward-compatible with v0.2.0 and v0.1.1 +callers. The lib is the generic UI-Framework substrate — domain-free +per ADR-0001/ADR-0049, consumed by Display-Libs (inventory-list- +display, crafting-display, notify-display) and modules (vagrant- +skeleton, map-editor). -### Supported (v0.2.0) +### Supported (v0.3.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). +- **Z-tiers**: render order hud → normal → top. Input dispatch reverse + (top → hud, within-tier reverse open-order). +- **Persistent windows**: `opts.persistent=true` auto-opens at register- + time. Arg-less `panel.close()` becomes no-op when focused is + persistent (so ESC/default-trigger cannot dismiss a persistent + toolbar/HUD). Explicit `panel.close(id)` still closes persistent. +- **Input-block routing**: `opts.input_block` = `"none"` (skip hit-test + entirely — HUD passes clicks through), `"self"` (hit-test own bounds; + miss falls through to next window), `"all"` (modal — hit-test self; + miss is swallowed, never reaches game-layer). Defaults per tier: + hud→none, normal→self, top→all. +- **Public `panel.point_in_any_panel(x, y)`**: helper for module-side + canvas-click logic — returns true if (x,y) falls within any open + panel's bounds. Useful for modules that read input directly via + `engine.input.*` and want to skip canvas-paint when the click landed + inside a panel widget. +- Multi-active panels: multiple windows open simultaneously (unchanged + from v0.2.0). +- 11 layout-slot templates + custom-fn hook (unchanged). - Bw-Compat-Shim: v0.1.1 `register(id, widget)` ohne opts works unchanged; `close()`/`is_open()` ohne arg map to focused = last- opened. @@ -55,13 +72,6 @@ modules (vagrant-skeleton). - 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. @@ -83,7 +93,22 @@ modules (vagrant-skeleton). **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:** ```lua @@ -138,12 +163,39 @@ below for the migration recipe. **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 +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` @@ -346,6 +398,73 @@ If you relied on the v0.1.1 "open(B) closes A" behavior, call 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 | diff --git a/init.lua b/init.lua index c1ca7f0..e7a94e9 100644 --- a/init.lua +++ b/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 --- window-manager with 11 named layout-slots + custom-fn hook. All --- v0.1.1 callers keep their semantics via a backward-compatibility --- shim (open/close/is_open without id-arg operate on the focused panel --- = last-opened). +-- v0.3.0 layers z_tiers (hud → normal → top), persistent windows, and +-- input_block routing (none/self/all) on top of v0.2.0's multi-active + +-- layout-slot model. Render iterates per-z-tier (hud → normal → top); +-- input dispatch iterates reverse (top → hud, within-tier reverse-open- +-- order). `persistent=true` registers a window as open-from-register-time +-- and exempts it from bw-compat close() (modder must explicit-close). -- -- Provides: -- - 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) --- - 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 -- - Default-trigger binding via lib-core.input (lazy-required) -- - Optional pause-gate (any open widget with pause_on_open=true) @@ -27,12 +32,11 @@ -- - engine.render.measure_text(text, font_size) → w, h -- -- 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. -- --- DEFERRED (v0.2 non-goals): --- - Persistent panels (always-on HUD widgets) → v0.3 --- - z_tier other than "normal" (modal, hud, system) → v0.4 +-- DEFERRED (v0.3 non-goals): +-- - Focus API (focus / raise / lower) → v0.4 -- - Drag + resize → v0.4 -- - Keyboard navigation within widgets -- - Panel animation (fade in/out) @@ -125,6 +129,28 @@ local function estimate_menu_width(actions, padding) return max_w 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) -- ===================================================================== @@ -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 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 engine.render.draw_rect(panel_x, panel_y, panel_w, panel_h, theme.bg_color) @@ -334,89 +379,119 @@ end --- _dispatch_event(event): route a synthetic or real input event. --- 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) -- Context-menu always wins if open (consumes the next click). if ctx_menu then if dispatch_context_menu_event(event) then return end end - -- Iterate reverse-open-order (last-opened = topmost gets first crack). local sw, sh = get_screen_size() - for i = #open_order, 1, -1 do - local widget_id = open_order[i] - local win = windows[widget_id] - if win and win.open then - local b = resolve_bounds(win.opts.layout, sw, sh) + local saw_modal = false -- track if any input_block="all" window is open - -- Click events use hit-test against window bounds. - if event.kind == "click" then - if event.x and event.y - 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 - -- Close-button intercept (top-right X): left-click closes - -- the window before forwarding to the widget. - if event.button == "left" then - local cb = win._close_button_rect - 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, theme.padding, theme.font_size_title) + -- 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 + local widget_id = open_order[i] + local win = windows[widget_id] + 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) + if event.kind == "click" then + if event.x and event.y + 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 + -- Close-button intercept (top-right X): left-click + -- closes the window before forwarding to the widget. + if event.button == "left" then + local cb = win._close_button_rect + if not cb then + cb = close_button_rect(b.x, b.y, b.w, + theme.padding, theme.font_size_title) + end + 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 + M.close(widget_id) + return + end + end + + local content = win._content_bounds + 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 title_area_h = theme.font_size_title + padding * 2 + content = { + x = b.x + padding, + y = b.y + title_area_h, + w = b.w - padding * 2, + h = b.h - title_area_h - padding, + } + end + end + local widget_ctx = { + bounds = content, + theme = M.get_theme(), + is_focused = (widget_id == open_order[#open_order]), + } + win.widget_def.handle_input(widget_ctx, event) + return end - 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 - M.close(widget_id) + elseif event.kind == "wheel" then + -- 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 + local content = win._content_bounds + 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 title_area_h = theme.font_size_title + padding * 2 + content = { + x = b.x + padding, + y = b.y + title_area_h, + w = b.w - padding * 2, + h = b.h - title_area_h - padding, + } + end + end + local widget_ctx = { + bounds = content, + theme = M.get_theme(), + is_focused = true, + } + win.widget_def.handle_input(widget_ctx, event) return end end - - -- Route click to widget - local content = win._content_bounds - if not content then - -- Render hasn't run; compute content bounds now - local padding = theme.padding - local title_area_h = theme.font_size_title + padding * 2 - content = { - x = b.x + padding, - y = b.y + title_area_h, - w = b.w - padding * 2, - h = b.h - title_area_h - padding, - } - end - local widget_ctx = { - bounds = content, - theme = M.get_theme(), - is_focused = (widget_id == open_order[#open_order]), - } - win.widget_def.handle_input(widget_ctx, event) - return - 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 - -- Wheel events route to the focused window (last-opened). - if widget_id == open_order[#open_order] then - local content = win._content_bounds - if not content then - local padding = theme.padding - local title_area_h = theme.font_size_title + padding * 2 - content = { - x = b.x + padding, - y = b.y + title_area_h, - w = b.w - padding * 2, - h = b.h - title_area_h - padding, - } - end - local widget_ctx = { - bounds = content, - theme = M.get_theme(), - is_focused = true, - } - win.widget_def.handle_input(widget_ctx, event) - return end end end end - -- v0.2.0: events that hit no panel are dropped (no game-routing yet) + + -- 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 -- ----------------------------------------------------------------------- @@ -437,7 +512,12 @@ end --- layout = "center" (default) | "left" | "right" | "top" | "bottom" | --- "top-left" | "top-right" | "bottom-left" | "bottom-right" | --- "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) if type(widget_id) ~= "string" then 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 .. "'") end 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). local sw, sh = get_screen_size() local _validation_bounds = resolve_bounds(opts.layout, sw, sh) _ = _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 windows[widget_id] = { widget_def = widget_def, opts = { - layout = opts.layout, -- nil OR string OR fn - z_tier = opts.z_tier or "normal", + layout = opts.layout, -- nil OR string OR fn + z_tier = z_tier, + persistent = persistent, + input_block = input_block, + chromeless = chromeless, }, 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 --- M.unregister(widget_id) @@ -507,8 +616,11 @@ function M.open(widget_id) end --- M.close(widget_id?) ---- With id: closes that specific widget. Without id: closes the focused ---- (= last-opened) for v0.1.1 bw-compat. +--- With id: closes that specific widget (DOES close persistent — explicit +--- 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) if widget_id == nil then -- bw-compat: close focused @@ -518,9 +630,15 @@ function M.close(widget_id) return end 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 local w = windows[widget_id] if not w or not w.open then return end + -- Explicit close(id) DOES close persistent windows (modder-controlled). w.open = false local idx = find_in_array(open_order, widget_id) if idx then table.remove(open_order, idx) end @@ -564,6 +682,29 @@ function M.is_pausing() return false 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 -- ----------------------------------------------------------------------- @@ -726,20 +867,24 @@ end -- ----------------------------------------------------------------------- --- M.render() ---- Must be called each render frame. Iterates open_order bottom-up ---- (last = focused = topmost) and draws each window. Renders any open ---- context-menu on top of all windows. No-op if nothing is open. +--- Must be called each render frame. v0.3.0: iterates per-z-tier +--- (hud → normal → top) and within each tier walks open_order bottom-up +--- 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() if #open_order == 0 then return end local sw, sh = get_screen_size() - for _, widget_id in ipairs(open_order) do - local w = windows[widget_id] - if w and w.open then - render_one_window(widget_id, w, sw, sh) + 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 + render_one_window(widget_id, w, sw, sh) + end end end - -- Context-menu (rendered on top of all windows) + -- Context-menu (rendered on top of all windows + tiers) if ctx_menu then render_context_menu() end @@ -794,4 +939,36 @@ function M._test_get_screen_size() return get_screen_size() 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 diff --git a/manifest.lib b/manifest.lib index c986dbf..1cc96f1 100644 --- a/manifest.lib +++ b/manifest.lib @@ -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":[]} \ No newline at end of file