initial: panel v0.1.0 — generic panel framework
This commit is contained in:
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
Copyright (c) 2026 Calic. All rights reserved.
|
||||||
|
|
||||||
|
This software is part of the Sporel platform — **Tier 1 (Official /
|
||||||
|
Proprietary)** content per the Three-Tier Licensing Model documented in
|
||||||
|
`meta/docs/archive/design/vision.md §Licensing Model` (current source;
|
||||||
|
migration to `meta/docs/architecture/licensing-model.md` pending).
|
||||||
|
|
||||||
|
⚠ **WIP — Legal review required before public launch.** The terms below
|
||||||
|
reflect design intent only; the formalized license framework will be
|
||||||
|
finalized through legal counsel before the first public release. Until
|
||||||
|
then, this notice serves as a placeholder defending the platform owner's
|
||||||
|
rights against unintentional re-licensing.
|
||||||
|
|
||||||
|
No license is granted to copy, modify, distribute, sublicense, or otherwise
|
||||||
|
use this software in any form without prior written permission from the
|
||||||
|
copyright holder.
|
||||||
|
|
||||||
|
References:
|
||||||
|
- Tier 1 (this file): all rights reserved, proprietary, sold/distributed
|
||||||
|
via official channels (Steam, etc.)
|
||||||
|
- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue-
|
||||||
|
share — see vision.md §Licensing Model
|
||||||
|
- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies
|
||||||
|
to community-uploaded libs/modules/assets, not this repo
|
||||||
305
README.md
Normal file
305
README.md
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
# 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.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
|
||||||
|
|
||||||
|
## Topology
|
||||||
|
|
||||||
|
<!-- topology:start (auto-generated; do not edit) -->
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
this["lib-core.panel"]
|
||||||
|
engine_render["engine.render.*"]
|
||||||
|
engine_input["engine.input.*"]
|
||||||
|
lib_core_input["lib-core.input"]
|
||||||
|
this --> engine_render
|
||||||
|
this --> engine_input
|
||||||
|
this -.->|lazy| lib_core_input
|
||||||
|
```
|
||||||
|
<!-- topology:end -->
|
||||||
|
|
||||||
|
## Scope (v0.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 '<k>'"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `panel.get_theme()`
|
||||||
|
|
||||||
|
**Syntax:** `panel.get_theme() -> table`
|
||||||
|
|
||||||
|
Returns a shallow copy of the current merged theme. Safe to store; does
|
||||||
|
not alias internal state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `panel.bind_default_trigger(key, widget_id)`
|
||||||
|
|
||||||
|
**Syntax:** `panel.bind_default_trigger(key: string|nil, widget_id: string) -> void`
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```lua
|
||||||
|
panel.register("inventory", { ... })
|
||||||
|
panel.bind_default_trigger("tab", "inventory")
|
||||||
|
-- or: panel.bind_default_trigger(nil, "inventory") -- defaults to "tab"
|
||||||
|
```
|
||||||
|
|
||||||
|
Binds a keyboard key as the default toggle trigger for `widget_id`. `key`
|
||||||
|
defaults to `"tab"` when `nil`. Lazy-requires `lib-core.input` to avoid
|
||||||
|
module-load-time cycles. Loud-error if `widget_id` is not registered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `panel.update(dt)`
|
||||||
|
|
||||||
|
**Syntax:** `panel.update(dt: number) -> void`
|
||||||
|
|
||||||
|
Must be called each game-update frame. Checks the default trigger key,
|
||||||
|
reads mouse state, emits edge-detected click events (left and right
|
||||||
|
independently), and dispatches wheel events. No-ops if no 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
|
||||||
|
```
|
||||||
528
init.lua
Normal file
528
init.lua
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- lib-core.panel v0.1.0 — Generic overlay-panel framework
|
||||||
|
--
|
||||||
|
-- Provides a single-active-widget panel system with:
|
||||||
|
-- - Widget registry + lifecycle (register, open, close, toggle)
|
||||||
|
-- - Centrally-rendered overlay with configurable theme
|
||||||
|
-- - Per-frame input dispatch (mouse click + wheel, edge-detected)
|
||||||
|
-- - Context-menu (show, hit-test, auto-close)
|
||||||
|
-- - Default-trigger binding via lib-core.input (lazy-required)
|
||||||
|
-- - Optional pause-gate (pause_on_open=true widget makes is_pausing()=true)
|
||||||
|
--
|
||||||
|
-- Engine surfaces used:
|
||||||
|
-- - engine.input.get_mouse_pos() → x, y
|
||||||
|
-- - engine.input.is_mouse_down(button) → bool
|
||||||
|
-- - engine.input.get_mouse_wheel() → number
|
||||||
|
-- - engine.input.MOUSE_LEFT / MOUSE_RIGHT
|
||||||
|
-- - engine.render.draw_rect(x, y, w, h, color)
|
||||||
|
-- - engine.render.draw_rect_lines(x, y, w, h, color, thickness)
|
||||||
|
-- - engine.render.draw_text(text, x, y, font_size, color)
|
||||||
|
-- - 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.1 falls back to
|
||||||
|
-- 1280x720 constants matching the default Sporel window config.
|
||||||
|
--
|
||||||
|
-- DEFERRED (v0.1 non-goals):
|
||||||
|
-- - Multi-widget z-order / stacking
|
||||||
|
-- - Always-on HUD widgets (non-modal overlays)
|
||||||
|
-- - Keyboard navigation within widgets
|
||||||
|
-- - Panel animation (fade in/out)
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Module-level state (all local — no globals)
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
local widgets = {} -- widget_id (string) → widget_def table
|
||||||
|
local active = nil -- active widget_id or nil
|
||||||
|
local theme = {} -- merged DEFAULT_THEME + overrides
|
||||||
|
local ctx_menu = nil -- context-menu state table or nil
|
||||||
|
local trigger_action_name = nil -- input action name for default trigger
|
||||||
|
local trigger_widget_id = nil -- which widget_id to toggle on trigger
|
||||||
|
local last_mouse_left = false -- for edge-detection (was down last frame)
|
||||||
|
local last_mouse_right = false -- for edge-detection (was down last frame)
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Theme
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 13 canonical keys. DO NOT add keys here without updating set_theme validation.
|
||||||
|
local DEFAULT_THEME = {
|
||||||
|
bg_color = 0x202020F0,
|
||||||
|
border_color = 0x808080FF,
|
||||||
|
text_color = 0xE0E0E0FF,
|
||||||
|
text_color_dim = 0xA0A0A0FF,
|
||||||
|
selection_color = 0x404080FF,
|
||||||
|
context_menu_bg = 0x303030F8,
|
||||||
|
context_menu_hover = 0x404060FF,
|
||||||
|
font_size_title = 18,
|
||||||
|
font_size_body = 14,
|
||||||
|
padding = 8,
|
||||||
|
row_height = 24,
|
||||||
|
panel_width_frac = 0.5,
|
||||||
|
panel_height_frac = 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Init theme from DEFAULT_THEME at module load
|
||||||
|
for k, v in pairs(DEFAULT_THEME) do
|
||||||
|
theme[k] = v
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Fallback screen constants (no engine.render.get_screen_size in Lua API)
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
local FALLBACK_SCREEN_W = 1280
|
||||||
|
local FALLBACK_SCREEN_H = 720
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Internal helpers
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- Retrieve the current screen dimensions. Falls back to constants because
|
||||||
|
--- engine.render does not expose a Lua-callable screen-size function.
|
||||||
|
local function get_screen_size()
|
||||||
|
return FALLBACK_SCREEN_W, FALLBACK_SCREEN_H
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Estimate menu width from action labels (rough; render can refine via
|
||||||
|
--- measure_text, but context-menu render in v0.1 uses this pre-computed w).
|
||||||
|
local function estimate_menu_width(actions, padding)
|
||||||
|
local max_w = 0
|
||||||
|
for _, action in ipairs(actions) do
|
||||||
|
local chars = #(action.label or "")
|
||||||
|
-- 8 px/char is a rough estimate for the default font; refine via
|
||||||
|
-- engine.render.measure_text in render-phase (v0.2 deferral).
|
||||||
|
local w = chars * 8 + 2 * padding
|
||||||
|
if w > max_w then max_w = w end
|
||||||
|
end
|
||||||
|
return max_w
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Internal: context-menu dispatch + render helpers
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- Build per-row hit-rects on ctx_menu and store as ctx_menu.row_rects.
|
||||||
|
--- Called before dispatch and before render to keep both in sync.
|
||||||
|
local function build_ctx_menu_row_rects()
|
||||||
|
if not ctx_menu then return end
|
||||||
|
local rects = {}
|
||||||
|
for i, _ in ipairs(ctx_menu.actions) do
|
||||||
|
local row_y = ctx_menu.y + (i - 1) * theme.row_height
|
||||||
|
rects[i] = {
|
||||||
|
x = ctx_menu.x,
|
||||||
|
y = row_y,
|
||||||
|
w = ctx_menu.w,
|
||||||
|
h = theme.row_height,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
ctx_menu.row_rects = rects
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Returns current mouse position safely (0,0 if not in game context).
|
||||||
|
local function safe_mouse_pos()
|
||||||
|
if engine and engine.input and engine.input.get_mouse_pos then
|
||||||
|
return engine.input.get_mouse_pos()
|
||||||
|
end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Internal: event dispatch
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- _dispatch_event(event): route a synthetic or real input event.
|
||||||
|
--- event = {kind="click", x, y, button="left"|"right"} or {kind="wheel", dy}
|
||||||
|
local function _dispatch_event(event)
|
||||||
|
-- Context-menu intercept: any click is consumed by the menu.
|
||||||
|
if ctx_menu and event.kind == "click" then
|
||||||
|
-- Hit-test rows
|
||||||
|
build_ctx_menu_row_rects()
|
||||||
|
local hit = false
|
||||||
|
for i, rect in ipairs(ctx_menu.row_rects) do
|
||||||
|
if event.x >= rect.x and event.x < rect.x + rect.w
|
||||||
|
and event.y >= rect.y and event.y < rect.y + rect.h then
|
||||||
|
hit = true
|
||||||
|
local action = ctx_menu.actions[i]
|
||||||
|
ctx_menu = nil -- close menu before callback (re-entrant safety)
|
||||||
|
if action and action.callback then
|
||||||
|
action.callback({ close_menu = function() ctx_menu = nil end })
|
||||||
|
end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not hit then
|
||||||
|
-- Click outside context-menu area → auto-close
|
||||||
|
ctx_menu = nil
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normal dispatch to active widget
|
||||||
|
if not active or not widgets[active] then return end
|
||||||
|
|
||||||
|
local screen_w, screen_h = get_screen_size()
|
||||||
|
local panel_w = screen_w * theme.panel_width_frac
|
||||||
|
local panel_h = screen_h * theme.panel_height_frac
|
||||||
|
local panel_x = (screen_w - panel_w) / 2
|
||||||
|
local panel_y = (screen_h - panel_h) / 2
|
||||||
|
local padding = theme.padding
|
||||||
|
local title_area_h = theme.font_size_title + padding * 2
|
||||||
|
|
||||||
|
local content_bounds = {
|
||||||
|
x = panel_x + padding,
|
||||||
|
y = panel_y + title_area_h,
|
||||||
|
w = panel_w - padding * 2,
|
||||||
|
h = panel_h - title_area_h - padding,
|
||||||
|
}
|
||||||
|
|
||||||
|
local widget_ctx = {
|
||||||
|
bounds = content_bounds,
|
||||||
|
theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state
|
||||||
|
is_focused = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets[active].handle_input(widget_ctx, event)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Public module table
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Registry / Lifecycle
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- M.register(widget_id, widget_def)
|
||||||
|
--- Registers a new widget. widget_def must have:
|
||||||
|
--- .render(ctx) — function, called each render frame when widget is active
|
||||||
|
--- .handle_input(ctx, event) — function, called for each input event
|
||||||
|
--- .title — string, displayed in the panel title bar
|
||||||
|
--- Optional: .pause_on_open = true — makes is_pausing() return true while open.
|
||||||
|
function M.register(widget_id, widget_def)
|
||||||
|
if type(widget_id) ~= "string" then
|
||||||
|
error("panel.register: widget_id must be a string, got " .. type(widget_id))
|
||||||
|
end
|
||||||
|
if type(widget_def) ~= "table" then
|
||||||
|
error("panel.register: widget_def must be a table, got " .. type(widget_def))
|
||||||
|
end
|
||||||
|
if type(widget_def.render) ~= "function" then
|
||||||
|
error("panel.register: widget_def.render must be a function")
|
||||||
|
end
|
||||||
|
if type(widget_def.handle_input) ~= "function" then
|
||||||
|
error("panel.register: widget_def.handle_input must be a function")
|
||||||
|
end
|
||||||
|
if type(widget_def.title) ~= "string" then
|
||||||
|
error("panel.register: widget_def.title must be a string")
|
||||||
|
end
|
||||||
|
if widgets[widget_id] ~= nil then
|
||||||
|
error("panel.register: duplicate widget_id '" .. widget_id .. "'")
|
||||||
|
end
|
||||||
|
widgets[widget_id] = widget_def
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M.unregister(widget_id)
|
||||||
|
--- Removes a widget from the registry. If the widget is currently active,
|
||||||
|
--- closes the panel first (clears active + ctx_menu).
|
||||||
|
function M.unregister(widget_id)
|
||||||
|
if active == widget_id then
|
||||||
|
active = nil
|
||||||
|
ctx_menu = nil
|
||||||
|
end
|
||||||
|
widgets[widget_id] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M.open(widget_id)
|
||||||
|
--- Sets widget_id as the active (displayed) widget. Loud-error if not registered.
|
||||||
|
function M.open(widget_id)
|
||||||
|
if widgets[widget_id] == nil then
|
||||||
|
error("panel.open: unknown widget_id '" .. tostring(widget_id) .. "' (register before open)")
|
||||||
|
end
|
||||||
|
active = widget_id
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M.close()
|
||||||
|
--- Closes the active panel and clears any open context-menu.
|
||||||
|
function M.close()
|
||||||
|
active = nil
|
||||||
|
ctx_menu = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M.toggle(widget_id)
|
||||||
|
--- If widget_id is currently active, closes it. Otherwise opens it.
|
||||||
|
function M.toggle(widget_id)
|
||||||
|
if active == widget_id then
|
||||||
|
M.close()
|
||||||
|
else
|
||||||
|
M.open(widget_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M.is_open() → bool
|
||||||
|
--- Returns true if any widget is currently active.
|
||||||
|
function M.is_open()
|
||||||
|
return active ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M.is_pausing() → bool
|
||||||
|
--- Returns true if the active widget has pause_on_open=true.
|
||||||
|
function M.is_pausing()
|
||||||
|
return active ~= nil and widgets[active] ~= nil
|
||||||
|
and widgets[active].pause_on_open == true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Theme
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- M.set_theme(overrides)
|
||||||
|
--- Merges overrides into the active theme. Only keys declared in DEFAULT_THEME
|
||||||
|
--- are accepted; unknown keys loud-error with "unknown theme key '<k>'".
|
||||||
|
function M.set_theme(overrides)
|
||||||
|
if type(overrides) ~= "table" then
|
||||||
|
error("panel.set_theme: expected table, got " .. type(overrides))
|
||||||
|
end
|
||||||
|
for k, v in pairs(overrides) do
|
||||||
|
if DEFAULT_THEME[k] == nil then
|
||||||
|
error("panel.set_theme: unknown theme key '" .. tostring(k) .. "'")
|
||||||
|
end
|
||||||
|
theme[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M.get_theme() → table
|
||||||
|
--- Returns a shallow copy of the current theme.
|
||||||
|
function M.get_theme()
|
||||||
|
local copy = {}
|
||||||
|
for k, v in pairs(theme) do
|
||||||
|
copy[k] = v
|
||||||
|
end
|
||||||
|
return copy
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Default-trigger binding
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- M.bind_default_trigger(key, widget_id)
|
||||||
|
--- Binds a keyboard key as the default toggle trigger for a widget.
|
||||||
|
--- key defaults to "tab" when nil. Lazy-requires lib-core.input to avoid
|
||||||
|
--- module-load-time circular dependency.
|
||||||
|
--- Loud-error if widget_id is not registered ("register before bind").
|
||||||
|
function M.bind_default_trigger(key, widget_id)
|
||||||
|
if key == nil then key = "tab" end
|
||||||
|
if widgets[widget_id] == nil then
|
||||||
|
error("panel.bind_default_trigger: unknown widget_id '" .. tostring(widget_id)
|
||||||
|
.. "' (register before bind)")
|
||||||
|
end
|
||||||
|
local input = require("lib-core.input") -- lazy require: avoid load-time cycle
|
||||||
|
input.bind("panel_toggle", {key})
|
||||||
|
trigger_action_name = "panel_toggle"
|
||||||
|
trigger_widget_id = widget_id
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Context-menu
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- M.show_context_menu(x, y, actions)
|
||||||
|
--- Pops up a context menu at (x, y) with the given actions array.
|
||||||
|
--- actions = array of {label=string, callback=function} entries.
|
||||||
|
--- Auto-repositions to stay within screen bounds.
|
||||||
|
function M.show_context_menu(x, y, actions)
|
||||||
|
if type(actions) ~= "table" or #actions == 0 then
|
||||||
|
error("panel.show_context_menu: actions must be a non-empty array of {label, callback} tables")
|
||||||
|
end
|
||||||
|
for i, action in ipairs(actions) do
|
||||||
|
if type(action) ~= "table" then
|
||||||
|
error("panel.show_context_menu: action #" .. i .. " must be a table")
|
||||||
|
end
|
||||||
|
if type(action.label) ~= "string" then
|
||||||
|
error("panel.show_context_menu: action #" .. i .. " must have a string label")
|
||||||
|
end
|
||||||
|
if type(action.callback) ~= "function" then
|
||||||
|
error("panel.show_context_menu: action #" .. i .. " must have a callback function")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local padding = theme.padding
|
||||||
|
local row_h = theme.row_height
|
||||||
|
local menu_w = estimate_menu_width(actions, padding)
|
||||||
|
local menu_h = #actions * row_h
|
||||||
|
|
||||||
|
local screen_w, screen_h = get_screen_size()
|
||||||
|
|
||||||
|
-- Auto-reposition: clamp so menu stays on screen
|
||||||
|
if x + menu_w > screen_w then x = screen_w - menu_w end
|
||||||
|
if x < 0 then x = 0 end
|
||||||
|
if y + menu_h > screen_h then y = screen_h - menu_h end
|
||||||
|
if y < 0 then y = 0 end
|
||||||
|
|
||||||
|
ctx_menu = {
|
||||||
|
x = x,
|
||||||
|
y = y,
|
||||||
|
w = menu_w,
|
||||||
|
h = menu_h,
|
||||||
|
actions = actions,
|
||||||
|
row_rects = {}, -- populated by build_ctx_menu_row_rects()
|
||||||
|
}
|
||||||
|
build_ctx_menu_row_rects()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Per-frame update + input dispatch
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- M.update(dt)
|
||||||
|
--- Must be called each game-update frame. Handles:
|
||||||
|
--- - Default-trigger key check (toggling bound widget)
|
||||||
|
--- - Mouse edge-detection for click events (dispatches on press, not hold)
|
||||||
|
--- - Mouse-wheel event dispatch
|
||||||
|
function M.update(dt)
|
||||||
|
-- Check default trigger (lazy requires lib-core.input internally if bound)
|
||||||
|
if trigger_action_name then
|
||||||
|
local input = require("lib-core.input")
|
||||||
|
if input.was_action_pressed(trigger_action_name) then
|
||||||
|
M.toggle(trigger_widget_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not active then
|
||||||
|
-- Still need to update last_mouse_* state even when closed so that
|
||||||
|
-- opening mid-frame doesn't produce a spurious edge on the next frame.
|
||||||
|
if engine and engine.input then
|
||||||
|
last_mouse_left = engine.input.is_mouse_down(engine.input.MOUSE_LEFT)
|
||||||
|
last_mouse_right = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Read current mouse state
|
||||||
|
local mx, my = engine.input.get_mouse_pos()
|
||||||
|
local cur_left = engine.input.is_mouse_down(engine.input.MOUSE_LEFT)
|
||||||
|
local cur_right = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT)
|
||||||
|
|
||||||
|
-- Edge-detect: pressed THIS frame (was up, now down)
|
||||||
|
local pressed_left = cur_left and not last_mouse_left
|
||||||
|
local pressed_right = cur_right and not last_mouse_right
|
||||||
|
|
||||||
|
if pressed_left then
|
||||||
|
_dispatch_event({ kind = "click", x = mx, y = my, button = "left" })
|
||||||
|
end
|
||||||
|
if pressed_right then
|
||||||
|
_dispatch_event({ kind = "click", x = mx, y = my, button = "right" })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wheel dispatch
|
||||||
|
local wheel_amt = engine.input.get_mouse_wheel()
|
||||||
|
if wheel_amt ~= 0 then
|
||||||
|
_dispatch_event({ kind = "wheel", dy = wheel_amt })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Update last-state for next frame's edge-detection
|
||||||
|
last_mouse_left = cur_left
|
||||||
|
last_mouse_right = cur_right
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Render
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- M.render()
|
||||||
|
--- Must be called each render frame. Draws the active widget's panel
|
||||||
|
--- (background, border, title bar) then invokes widget_def.render(ctx).
|
||||||
|
--- If a context-menu is open, renders it on top.
|
||||||
|
--- No-op if no widget is active.
|
||||||
|
function M.render()
|
||||||
|
if not active then return end
|
||||||
|
|
||||||
|
local widget_def = widgets[active]
|
||||||
|
if not widget_def then return end
|
||||||
|
|
||||||
|
-- Screen size: fallback to 1280x720 (engine.render has no Lua-accessible
|
||||||
|
-- get_screen_size; see module header for explanation)
|
||||||
|
local screen_w, screen_h = get_screen_size()
|
||||||
|
|
||||||
|
local panel_w = screen_w * theme.panel_width_frac
|
||||||
|
local panel_h = screen_h * theme.panel_height_frac
|
||||||
|
local panel_x = (screen_w - panel_w) / 2
|
||||||
|
local panel_y = (screen_h - panel_h) / 2
|
||||||
|
local padding = theme.padding
|
||||||
|
|
||||||
|
-- Background
|
||||||
|
engine.render.draw_rect(panel_x, panel_y, panel_w, panel_h, theme.bg_color)
|
||||||
|
|
||||||
|
-- Border
|
||||||
|
engine.render.draw_rect_lines(panel_x, panel_y, panel_w, panel_h,
|
||||||
|
theme.border_color, 2)
|
||||||
|
|
||||||
|
-- Title
|
||||||
|
local title = widget_def.title
|
||||||
|
engine.render.draw_text(title,
|
||||||
|
panel_x + padding,
|
||||||
|
panel_y + padding,
|
||||||
|
theme.font_size_title,
|
||||||
|
theme.text_color)
|
||||||
|
|
||||||
|
-- Content area (below title bar)
|
||||||
|
local title_area_h = theme.font_size_title + padding * 2
|
||||||
|
local content_bounds = {
|
||||||
|
x = panel_x + padding,
|
||||||
|
y = panel_y + title_area_h,
|
||||||
|
w = panel_w - padding * 2,
|
||||||
|
h = panel_h - title_area_h - padding,
|
||||||
|
}
|
||||||
|
|
||||||
|
local widget_ctx = {
|
||||||
|
bounds = content_bounds,
|
||||||
|
theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state
|
||||||
|
is_focused = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
widget_def.render(widget_ctx)
|
||||||
|
|
||||||
|
-- Context-menu (rendered on top of widget content)
|
||||||
|
if ctx_menu then
|
||||||
|
local mx, my = safe_mouse_pos()
|
||||||
|
-- Menu background
|
||||||
|
engine.render.draw_rect(ctx_menu.x, ctx_menu.y,
|
||||||
|
ctx_menu.w, ctx_menu.h,
|
||||||
|
theme.context_menu_bg)
|
||||||
|
|
||||||
|
build_ctx_menu_row_rects()
|
||||||
|
for i, action in ipairs(ctx_menu.actions) do
|
||||||
|
local rect = ctx_menu.row_rects[i]
|
||||||
|
local row_bg = theme.context_menu_bg
|
||||||
|
|
||||||
|
-- Hover highlight: mouse y within this row
|
||||||
|
if my >= rect.y and my < rect.y + rect.h
|
||||||
|
and mx >= rect.x and mx < rect.x + rect.w then
|
||||||
|
row_bg = theme.context_menu_hover
|
||||||
|
engine.render.draw_rect(rect.x, rect.y, rect.w, rect.h, row_bg)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Label
|
||||||
|
engine.render.draw_text(action.label,
|
||||||
|
rect.x + padding,
|
||||||
|
rect.y + (theme.row_height - theme.font_size_body) / 2,
|
||||||
|
theme.font_size_body,
|
||||||
|
theme.text_color)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Test backdoor
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- M._dispatch_event_for_test(event) — for tests only.
|
||||||
|
--- Direct passthrough to the internal _dispatch_event function so that
|
||||||
|
--- panel-test can simulate input events without a running game loop.
|
||||||
|
function M._dispatch_event_for_test(event)
|
||||||
|
_dispatch_event(event)
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
1
manifest.lib
Normal file
1
manifest.lib
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"id":"lib-core.panel","version":"0.1.0","api_min":"0.1","deps":[]}
|
||||||
Reference in New Issue
Block a user