# lib-core.panel Generic overlay-panel framework. Manages a single active widget at a time, handles input dispatch (mouse click + wheel, edge-detected), renders a titled panel overlay, and supports a context-menu layer. Designed as the glue layer between game modules and the engine render/input surfaces. **Version:** 0.1.1 **Lib-ID:** lib-core.panel **Requires:** engine.render.*, engine.input.*, lib-core.input (lazy, for default-trigger) **Tags:** panel, overlay, ui, input, context-menu ## Topology ```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.1.0) v0.1 ships a minimal single-active-widget panel system: - Widget registry (register / unregister / open / close / toggle) - Theme system (13 configurable keys, capability-by-declaration guard) - Per-frame update with edge-detected mouse click + wheel dispatch - Context-menu (show, auto-reposition to screen bounds, hit-test, auto-close) - Default-trigger key binding via lib-core.input (lazy-required) - pause_on_open flag for game-pause gating **Intentional non-goals (deferred):** - Multi-widget z-order / stacking panels - Always-on HUD widgets (non-modal overlays) - Keyboard navigation within widgets - Panel animation (fade in / out) - Screen-size from engine (no Lua-accessible get_screen_size; v0.1 falls back to 1280x720 constants matching default Sporel window config) ## API ### `panel.register(widget_id, widget_def)` **Syntax:** `panel.register(widget_id: string, widget_def: table) -> void` **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 active, closes the panel (sets active to nil, clears any open context-menu). --- ### `panel.open(widget_id)` **Syntax:** `panel.open(widget_id: string) -> void` Sets `widget_id` as the active widget. Loud-error if `widget_id` has not been registered. --- ### `panel.close()` **Syntax:** `panel.close() -> void` Closes the active widget and clears any open context-menu. --- ### `panel.toggle(widget_id)` **Syntax:** `panel.toggle(widget_id: string) -> void` If `widget_id` is currently active, closes it. Otherwise opens it. Handy for key-binding toggle semantics without manual state tracking. --- ### `panel.is_open()` **Syntax:** `panel.is_open() -> bool` Returns `true` if any widget is currently active. --- ### `panel.is_pausing()` **Syntax:** `panel.is_pausing() -> bool` Returns `true` if the active widget has `pause_on_open = true`. Modules can use this to gate their update loop (skip physics/AI while 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 widget is active (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 widget is active. --- ### `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). ## 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 active. 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 active one } ``` 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 ```