# lib-core.panel 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. 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.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, window-manager ## Topology ```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 ``` ## Scope (v0.3.0) 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.3.0) - **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. - Theme system: shared theme via `set_theme`/`get_theme` (unchanged). - Context-menu via `show_context_menu` (unchanged). - Default-trigger key-binding via `bind_default_trigger` (unchanged). ### Deferred (v0.4.0+) - Click-to-focus and explicit focus()/raise()/lower() API. - Drag-by-title-bar (draggable opt). - Resize-by-corner (resizable opt). - min/max-size constraints + screen-clamp. ### Deferred (post-v0.4.0) - Window-decoration themes (per-window title-bar styles). - Touch/mobile input adaptation. - Window animations (slide-in, fade-in). - Window groups / tabbed-windows / MDI parent-child hierarchies. - Save/load of window-bounds across sessions. ## API ### `panel.register(widget_id, widget_def, opts?)` **Syntax:** `panel.register(widget_id: string, widget_def: table, opts?: table) -> void` `opts` is optional. Accepted keys: - `layout` — slot-name string, 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 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 ''"`. --- ### `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 ```