feat: v0.2.0 multi-active panels with layout-slots
Migrate the internal model from single-active scalar to multi-active
open_order array + per-id window records. register accepts opts.layout
(11 named slots: center, left, right, top, bottom, the four corners,
left-half, right-half — plus a custom function(sw,sh)->{x,y,w,h} hook
for HP-bars and bespoke positioning). open/close/is_open accept an
optional id argument for per-id semantics; the no-arg forms preserve
the v0.1.1 contract (close() removes the focused last-opened, is_open()
returns true if any panel is open).
Input dispatch iterates open_order in reverse so the topmost panel gets
the first crack at a click; misses fall through to lower windows.
is_pausing now checks every open window for pause_on_open=true, not
just the focused one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
README.md
131
README.md
@@ -1,11 +1,17 @@
|
|||||||
# lib-core.panel
|
# lib-core.panel
|
||||||
|
|
||||||
Generic overlay-panel framework. Manages a single active widget at a time,
|
Generic overlay-panel framework. v0.2.0 supports multiple panels open at
|
||||||
handles input dispatch (mouse click + wheel, edge-detected), renders a
|
the same time with 11 named layout-slot templates + custom-fn hook for
|
||||||
titled panel overlay, and supports a context-menu layer. Designed as the
|
bespoke positioning. Handles input dispatch (mouse click + wheel,
|
||||||
glue layer between game modules and the engine render/input surfaces.
|
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.
|
||||||
|
|
||||||
**Version:** 0.1.1
|
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.
|
||||||
|
|
||||||
|
**Version:** 0.2.0
|
||||||
**Lib-ID:** lib-core.panel
|
**Lib-ID:** lib-core.panel
|
||||||
**Requires:** engine.render.*, engine.input.*, lib-core.input (lazy, for default-trigger)
|
**Requires:** engine.render.*, engine.input.*, lib-core.input (lazy, for default-trigger)
|
||||||
**Tags:** panel, overlay, ui, input, context-menu
|
**Tags:** panel, overlay, ui, input, context-menu
|
||||||
@@ -45,9 +51,11 @@ v0.1 ships a minimal single-active-widget panel system:
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
### `panel.register(widget_id, widget_def)`
|
### `panel.register(widget_id, widget_def, opts?)`
|
||||||
|
|
||||||
**Syntax:** `panel.register(widget_id: string, widget_def: table) -> void`
|
**Syntax:** `panel.register(widget_id: string, widget_def: table, opts?: table) -> void`
|
||||||
|
|
||||||
|
`opts` is optional. Accepted keys: `layout` (slot-name string or `function(sw, sh) -> {x,y,w,h}`; defaults to `"center"`) and `z_tier` (`"normal"`; v0.2.0 only the normal tier is exposed). See the [Multi-Active + Layout-Slots](#v020--multi-active--layout-slots) section below for the full slot table.
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```lua
|
```lua
|
||||||
@@ -86,16 +94,20 @@ closes the panel (sets active to nil, clears any open context-menu).
|
|||||||
|
|
||||||
**Syntax:** `panel.open(widget_id: string) -> void`
|
**Syntax:** `panel.open(widget_id: string) -> void`
|
||||||
|
|
||||||
Sets `widget_id` as the active widget. Loud-error if `widget_id` has not
|
Opens `widget_id`. The window is pushed onto the top of `open_order`
|
||||||
been registered.
|
(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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `panel.close()`
|
### `panel.close(widget_id?)`
|
||||||
|
|
||||||
**Syntax:** `panel.close() -> void`
|
**Syntax:** `panel.close(widget_id?: string) -> void`
|
||||||
|
|
||||||
Closes the active widget and clears any open context-menu.
|
With `widget_id`: closes that specific window. Without arg (v0.1.1
|
||||||
|
bw-compat): closes the focused (last-opened) window. Clears any open
|
||||||
|
context-menu when the last open window is closed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,16 +115,18 @@ Closes the active widget and clears any open context-menu.
|
|||||||
|
|
||||||
**Syntax:** `panel.toggle(widget_id: string) -> void`
|
**Syntax:** `panel.toggle(widget_id: string) -> void`
|
||||||
|
|
||||||
If `widget_id` is currently active, closes it. Otherwise opens it. Handy
|
If `widget_id` is currently open, closes it. Otherwise opens it. Handy
|
||||||
for key-binding toggle semantics without manual state tracking.
|
for key-binding toggle semantics without manual state tracking.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `panel.is_open()`
|
### `panel.is_open(widget_id?)`
|
||||||
|
|
||||||
**Syntax:** `panel.is_open() -> bool`
|
**Syntax:** `panel.is_open(widget_id?: string) -> bool`
|
||||||
|
|
||||||
Returns `true` if any widget is currently active.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,9 +134,9 @@ Returns `true` if any widget is currently active.
|
|||||||
|
|
||||||
**Syntax:** `panel.is_pausing() -> bool`
|
**Syntax:** `panel.is_pausing() -> bool`
|
||||||
|
|
||||||
Returns `true` if the active widget has `pause_on_open = true`. Modules
|
Returns `true` if ANY currently-open window has `pause_on_open = true`.
|
||||||
can use this to gate their update loop (skip physics/AI while panel is
|
Modules can use this to gate their update loop (skip physics/AI while a
|
||||||
open).
|
pausing panel is open).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -220,6 +234,85 @@ Direct passthrough to the internal `_dispatch_event` function. Allows
|
|||||||
test-modules to simulate input events without a running game loop (since
|
test-modules to simulate input events without a running game loop (since
|
||||||
`M.update` is not exercised headless).
|
`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.
|
||||||
|
|
||||||
## Theme Schema
|
## Theme Schema
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|
|||||||
591
init.lua
591
init.lua
@@ -1,13 +1,20 @@
|
|||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
-- lib-core.panel v0.1.0 — Generic overlay-panel framework
|
-- lib-core.panel v0.2.0 — Generic overlay-panel framework (Multi-Active)
|
||||||
--
|
--
|
||||||
-- Provides a single-active-widget panel system with:
|
-- 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).
|
||||||
|
--
|
||||||
|
-- Provides:
|
||||||
-- - Widget registry + lifecycle (register, open, close, toggle)
|
-- - Widget registry + lifecycle (register, open, close, toggle)
|
||||||
-- - Centrally-rendered overlay with configurable theme
|
-- - Multi-active open_order (last-opened = focused = topmost)
|
||||||
-- - Per-frame input dispatch (mouse click + wheel, edge-detected)
|
-- - 11 named layout-slot templates + opts.layout = function(sw, sh)
|
||||||
-- - Context-menu (show, hit-test, auto-close)
|
-- - Per-frame input dispatch with reverse-open-order hit-test
|
||||||
|
-- - Context-menu (show, hit-test, auto-close) on top of windows
|
||||||
-- - Default-trigger binding via lib-core.input (lazy-required)
|
-- - Default-trigger binding via lib-core.input (lazy-required)
|
||||||
-- - Optional pause-gate (pause_on_open=true widget makes is_pausing()=true)
|
-- - Optional pause-gate (any open widget with pause_on_open=true)
|
||||||
--
|
--
|
||||||
-- Engine surfaces used:
|
-- Engine surfaces used:
|
||||||
-- - engine.input.get_mouse_pos() → x, y
|
-- - engine.input.get_mouse_pos() → x, y
|
||||||
@@ -20,12 +27,13 @@
|
|||||||
-- - engine.render.measure_text(text, font_size) → w, h
|
-- - engine.render.measure_text(text, font_size) → w, h
|
||||||
--
|
--
|
||||||
-- Screen-size limitation: engine.render does NOT expose get_screen_size()
|
-- Screen-size limitation: engine.render does NOT expose get_screen_size()
|
||||||
-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.1 falls back to
|
-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.2 still falls
|
||||||
-- 1280x720 constants matching the default Sporel window config.
|
-- back to 1280x720 constants matching the default Sporel window config.
|
||||||
--
|
--
|
||||||
-- DEFERRED (v0.1 non-goals):
|
-- DEFERRED (v0.2 non-goals):
|
||||||
-- - Multi-widget z-order / stacking
|
-- - Persistent panels (always-on HUD widgets) → v0.3
|
||||||
-- - Always-on HUD widgets (non-modal overlays)
|
-- - z_tier other than "normal" (modal, hud, system) → v0.4
|
||||||
|
-- - Drag + resize → v0.4
|
||||||
-- - Keyboard navigation within widgets
|
-- - Keyboard navigation within widgets
|
||||||
-- - Panel animation (fade in/out)
|
-- - Panel animation (fade in/out)
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
@@ -39,8 +47,11 @@
|
|||||||
-- still land further down once the public surface section starts.
|
-- still land further down once the public surface section starts.
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
local widgets = {} -- widget_id (string) → widget_def table
|
local widgets = {} -- widget_id (string) → widget_def table (legacy registry, preserved)
|
||||||
local active = nil -- active widget_id or nil
|
-- v0.2.0: replaced single `active` scalar with multi-active model.
|
||||||
|
-- windows[id] = { widget_def, opts, open, _close_button_rect?, _content_bounds? }
|
||||||
|
local windows = {} -- widget_id → window-record
|
||||||
|
local open_order = {} -- array of open widget_ids; last = focused = topmost
|
||||||
local theme = {} -- merged DEFAULT_THEME + overrides
|
local theme = {} -- merged DEFAULT_THEME + overrides
|
||||||
local ctx_menu = nil -- context-menu state table or nil
|
local ctx_menu = nil -- context-menu state table or nil
|
||||||
-- Default-trigger bindings: array of {action_name, widget_id}. Multiple
|
-- Default-trigger bindings: array of {action_name, widget_id}. Multiple
|
||||||
@@ -92,6 +103,14 @@ local function get_screen_size()
|
|||||||
return FALLBACK_SCREEN_W, FALLBACK_SCREEN_H
|
return FALLBACK_SCREEN_W, FALLBACK_SCREEN_H
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Find the (first) index of val in array arr, or nil.
|
||||||
|
local function find_in_array(arr, val)
|
||||||
|
for i, v in ipairs(arr) do
|
||||||
|
if v == val then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
--- Estimate menu width from action labels (rough; render can refine via
|
--- 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).
|
--- measure_text, but context-menu render in v0.1 uses this pre-computed w).
|
||||||
local function estimate_menu_width(actions, padding)
|
local function estimate_menu_width(actions, padding)
|
||||||
@@ -106,6 +125,65 @@ local function estimate_menu_width(actions, padding)
|
|||||||
return max_w
|
return max_w
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- v0.2.0 — Layout slots (per Phase Panel-WM spec §4.1)
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
local LAYOUT_SLOTS = {
|
||||||
|
["center"] = function(sw, sh) return {x=sw*0.25, y=sh*0.20, w=sw*0.50, h=sh*0.60} end,
|
||||||
|
["left"] = function(sw, sh) return {x=0, y=0, w=sw*0.40, h=sh} end,
|
||||||
|
["right"] = function(sw, sh) return {x=sw*0.60, y=0, w=sw*0.40, h=sh} end,
|
||||||
|
["top"] = function(sw, sh) return {x=0, y=0, w=sw, h=sh*0.30} end,
|
||||||
|
["bottom"] = function(sw, sh) return {x=0, y=sh*0.70, w=sw, h=sh*0.30} end,
|
||||||
|
["top-left"] = function(sw, sh) return {x=0, y=0, w=sw*0.40, h=sh*0.50} end,
|
||||||
|
["top-right"] = function(sw, sh) return {x=sw*0.60, y=0, w=sw*0.40, h=sh*0.50} end,
|
||||||
|
["bottom-left"] = function(sw, sh) return {x=0, y=sh*0.50, w=sw*0.40, h=sh*0.50} end,
|
||||||
|
["bottom-right"] = function(sw, sh) return {x=sw*0.60, y=sh*0.50, w=sw*0.40, h=sh*0.50} end,
|
||||||
|
["left-half"] = function(sw, sh) return {x=0, y=0, w=sw*0.50, h=sh} end,
|
||||||
|
["right-half"] = function(sw, sh) return {x=sw*0.50, y=0, w=sw*0.50, h=sh} end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Known-slot list (for error message + completeness check)
|
||||||
|
local KNOWN_SLOT_NAMES = {
|
||||||
|
"center","left","right","top","bottom",
|
||||||
|
"top-left","top-right","bottom-left","bottom-right",
|
||||||
|
"left-half","right-half",
|
||||||
|
}
|
||||||
|
|
||||||
|
--- Resolves a layout specifier to {x, y, w, h} bounds.
|
||||||
|
--- Accepts: nil (defaults to "center"), string (slot-name), or function.
|
||||||
|
--- Loud-error on unknown string-slot. pcall-wraps custom function for
|
||||||
|
--- safety (returns "center" fallback + engine.print warning on error).
|
||||||
|
local function resolve_bounds(layout, sw, sh)
|
||||||
|
if layout == nil then
|
||||||
|
return LAYOUT_SLOTS["center"](sw, sh)
|
||||||
|
end
|
||||||
|
if type(layout) == "string" then
|
||||||
|
local fn = LAYOUT_SLOTS[layout]
|
||||||
|
if fn == nil then
|
||||||
|
error(string.format(
|
||||||
|
"panel.register: unknown layout slot '%s' (known: %s)",
|
||||||
|
layout, table.concat(KNOWN_SLOT_NAMES, ", ")), 3)
|
||||||
|
end
|
||||||
|
return fn(sw, sh)
|
||||||
|
end
|
||||||
|
if type(layout) == "function" then
|
||||||
|
local ok, result = pcall(layout, sw, sh)
|
||||||
|
if not ok then
|
||||||
|
if engine and engine.print then
|
||||||
|
engine.print(string.format(
|
||||||
|
"[WARN] panel: custom layout fn errored: %s (falling back to center)",
|
||||||
|
tostring(result)))
|
||||||
|
end
|
||||||
|
return LAYOUT_SLOTS["center"](sw, sh)
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
error(string.format(
|
||||||
|
"panel.register: layout must be nil, string, or function (got %s)",
|
||||||
|
type(layout)), 3)
|
||||||
|
end
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
-- Internal: context-menu dispatch + render helpers
|
-- Internal: context-menu dispatch + render helpers
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
@@ -135,14 +213,8 @@ local function safe_mouse_pos()
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
end
|
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}
|
|
||||||
-- Close-button rect in the top-right of the title bar. Same math is used
|
-- Close-button rect in the top-right of the title bar. Same math is used
|
||||||
-- by render() (draw the × glyph) and _dispatch_event (hit-test left-click).
|
-- by render() (draw the X glyph) and _dispatch_event (hit-test left-click).
|
||||||
local function close_button_rect(panel_x, panel_y, panel_w, padding, font_size_title)
|
local function close_button_rect(panel_x, panel_y, panel_w, padding, font_size_title)
|
||||||
local size = font_size_title + padding
|
local size = font_size_title + padding
|
||||||
return {
|
return {
|
||||||
@@ -153,59 +225,44 @@ local function close_button_rect(panel_x, panel_y, panel_w, padding, font_size_t
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local function _dispatch_event(event)
|
-- -----------------------------------------------------------------------
|
||||||
-- Close-button intercept (top-right ×): left-click closes the active panel
|
-- Internal: per-window render helper (extracted from v0.1 render())
|
||||||
-- before the click is forwarded to the widget or context-menu.
|
-- -----------------------------------------------------------------------
|
||||||
if active and event.kind == "click" and event.button == "left" then
|
|
||||||
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 cb = close_button_rect(panel_x, panel_y, panel_w, theme.padding, theme.font_size_title)
|
|
||||||
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()
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Context-menu intercept: any click is consumed by the menu.
|
local function render_one_window(widget_id, win, sw, sh)
|
||||||
if ctx_menu and event.kind == "click" then
|
local widget_def = win.widget_def
|
||||||
-- Hit-test rows
|
local bounds = resolve_bounds(win.opts.layout, sw, sh)
|
||||||
build_ctx_menu_row_rects()
|
local panel_x, panel_y, panel_w, panel_h = bounds.x, bounds.y, bounds.w, bounds.h
|
||||||
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 padding = theme.padding
|
||||||
local title_area_h = theme.font_size_title + padding * 2
|
|
||||||
|
|
||||||
local content_bounds = {
|
-- 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
|
||||||
|
engine.render.draw_text(widget_def.title,
|
||||||
|
panel_x + padding,
|
||||||
|
panel_y + padding,
|
||||||
|
theme.font_size_title,
|
||||||
|
theme.text_color)
|
||||||
|
|
||||||
|
-- Close button (X) in top-right of title bar. Hit-tested in _dispatch_event.
|
||||||
|
local cb = close_button_rect(panel_x, panel_y, panel_w, padding, theme.font_size_title)
|
||||||
|
engine.render.draw_text("X",
|
||||||
|
cb.x + math.floor(cb.w / 4),
|
||||||
|
cb.y,
|
||||||
|
theme.font_size_title,
|
||||||
|
theme.text_color)
|
||||||
|
|
||||||
|
-- Stash close-button rect on the window record so hit-test can find it
|
||||||
|
win._close_button_rect = cb
|
||||||
|
|
||||||
|
-- Content area (below title bar)
|
||||||
|
local title_area_h = theme.font_size_title + padding * 2
|
||||||
|
win._content_bounds = {
|
||||||
x = panel_x + padding,
|
x = panel_x + padding,
|
||||||
y = panel_y + title_area_h,
|
y = panel_y + title_area_h,
|
||||||
w = panel_w - padding * 2,
|
w = panel_w - padding * 2,
|
||||||
@@ -213,12 +270,153 @@ local function _dispatch_event(event)
|
|||||||
}
|
}
|
||||||
|
|
||||||
local widget_ctx = {
|
local widget_ctx = {
|
||||||
bounds = content_bounds,
|
bounds = win._content_bounds,
|
||||||
theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state
|
theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state
|
||||||
is_focused = true,
|
is_focused = (widget_id == open_order[#open_order]),
|
||||||
}
|
}
|
||||||
|
|
||||||
widgets[active].handle_input(widget_ctx, event)
|
widget_def.render(widget_ctx)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render_context_menu()
|
||||||
|
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 + theme.padding,
|
||||||
|
rect.y + (theme.row_height - theme.font_size_body) / 2,
|
||||||
|
theme.font_size_body,
|
||||||
|
theme.text_color)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
-- Internal: event dispatch
|
||||||
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
--- Returns true if the event was consumed by the context-menu.
|
||||||
|
local function dispatch_context_menu_event(event)
|
||||||
|
if event.kind == "click" then
|
||||||
|
build_ctx_menu_row_rects()
|
||||||
|
for i, rect in ipairs(ctx_menu.row_rects or {}) 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
|
||||||
|
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
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Clicked outside the menu: dismiss
|
||||||
|
ctx_menu = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- _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 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)
|
||||||
|
|
||||||
|
-- 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)
|
||||||
|
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
|
||||||
|
|
||||||
|
-- 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)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
@@ -228,13 +426,19 @@ end
|
|||||||
-- Registry / Lifecycle
|
-- Registry / Lifecycle
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
--- M.register(widget_id, widget_def)
|
--- M.register(widget_id, widget_def, opts?)
|
||||||
--- Registers a new widget. widget_def must have:
|
--- Registers a new widget. widget_def must have:
|
||||||
--- .render(ctx) — function, called each render frame when widget is active
|
--- .render(ctx) — function, called each render frame when widget is open
|
||||||
--- .handle_input(ctx, event) — function, called for each input event
|
--- .handle_input(ctx, event) — function, called for each input event
|
||||||
--- .title — string, displayed in the panel title bar
|
--- .title — string, displayed in the panel title bar
|
||||||
--- Optional: .pause_on_open = true — makes is_pausing() return true while open.
|
--- Optional: .pause_on_open = true — makes is_pausing() return true while open.
|
||||||
function M.register(widget_id, widget_def)
|
---
|
||||||
|
--- opts is OPTIONAL. Accepted keys:
|
||||||
|
--- 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)
|
||||||
|
function M.register(widget_id, widget_def, opts)
|
||||||
if type(widget_id) ~= "string" then
|
if type(widget_id) ~= "string" then
|
||||||
error("panel.register: widget_id must be a string, got " .. type(widget_id))
|
error("panel.register: widget_id must be a string, got " .. type(widget_id))
|
||||||
end
|
end
|
||||||
@@ -253,57 +457,111 @@ function M.register(widget_id, widget_def)
|
|||||||
if widgets[widget_id] ~= nil then
|
if widgets[widget_id] ~= nil then
|
||||||
error("panel.register: duplicate widget_id '" .. widget_id .. "'")
|
error("panel.register: duplicate widget_id '" .. widget_id .. "'")
|
||||||
end
|
end
|
||||||
|
opts = opts or {}
|
||||||
|
-- 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
|
||||||
|
|
||||||
widgets[widget_id] = widget_def
|
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",
|
||||||
|
},
|
||||||
|
open = false,
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
--- M.unregister(widget_id)
|
--- M.unregister(widget_id)
|
||||||
--- Removes a widget from the registry. If the widget is currently active,
|
--- Removes a widget from the registry. If the widget is currently open,
|
||||||
--- closes the panel first (clears active + ctx_menu).
|
--- closes the panel first (removes from open_order; clears ctx_menu if
|
||||||
|
--- this was the last-open window).
|
||||||
function M.unregister(widget_id)
|
function M.unregister(widget_id)
|
||||||
if active == widget_id then
|
if windows[widget_id] and windows[widget_id].open then
|
||||||
active = nil
|
M.close(widget_id)
|
||||||
ctx_menu = nil
|
|
||||||
end
|
end
|
||||||
widgets[widget_id] = nil
|
widgets[widget_id] = nil
|
||||||
|
windows[widget_id] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
--- M.open(widget_id)
|
--- M.open(widget_id)
|
||||||
--- Sets widget_id as the active (displayed) widget. Loud-error if not registered.
|
--- Opens widget_id (adds to open_order; makes it the focused/topmost).
|
||||||
|
--- If already open, brings it to the front (re-pushes onto open_order top).
|
||||||
|
--- Loud-error if not registered.
|
||||||
function M.open(widget_id)
|
function M.open(widget_id)
|
||||||
if widgets[widget_id] == nil then
|
if widgets[widget_id] == nil then
|
||||||
error("panel.open: unknown widget_id '" .. tostring(widget_id) .. "' (register before open)")
|
error("panel.open: unknown widget_id '" .. tostring(widget_id) .. "' (register before open)")
|
||||||
end
|
end
|
||||||
active = widget_id
|
local w = windows[widget_id]
|
||||||
|
if w.open then
|
||||||
|
-- Already open: bring to front (focus) within open_order.
|
||||||
|
local idx = find_in_array(open_order, widget_id)
|
||||||
|
if idx then
|
||||||
|
table.remove(open_order, idx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
open_order[#open_order + 1] = widget_id
|
||||||
|
w.open = true
|
||||||
end
|
end
|
||||||
|
|
||||||
--- M.close()
|
--- M.close(widget_id?)
|
||||||
--- Closes the active panel and clears any open context-menu.
|
--- With id: closes that specific widget. Without id: closes the focused
|
||||||
function M.close()
|
--- (= last-opened) for v0.1.1 bw-compat.
|
||||||
active = nil
|
function M.close(widget_id)
|
||||||
|
if widget_id == nil then
|
||||||
|
-- bw-compat: close focused
|
||||||
|
if #open_order == 0 then
|
||||||
|
-- v0.1.1: close() also cleared ctx_menu unconditionally
|
||||||
ctx_menu = nil
|
ctx_menu = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
|
widget_id = open_order[#open_order]
|
||||||
|
end
|
||||||
|
local w = windows[widget_id]
|
||||||
|
if not w or not w.open then return end
|
||||||
|
w.open = false
|
||||||
|
local idx = find_in_array(open_order, widget_id)
|
||||||
|
if idx then table.remove(open_order, idx) end
|
||||||
|
-- Context-menu was attached to whatever was on top — clear it if our close
|
||||||
|
-- removed the focused window.
|
||||||
|
if #open_order == 0 then
|
||||||
|
ctx_menu = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- M.toggle(widget_id)
|
--- M.toggle(widget_id)
|
||||||
--- If widget_id is currently active, closes it. Otherwise opens it.
|
--- If widget_id is currently open, closes it. Otherwise opens it.
|
||||||
function M.toggle(widget_id)
|
function M.toggle(widget_id)
|
||||||
if active == widget_id then
|
if M.is_open(widget_id) then
|
||||||
M.close()
|
M.close(widget_id)
|
||||||
else
|
else
|
||||||
M.open(widget_id)
|
M.open(widget_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- M.is_open() → bool
|
--- M.is_open(widget_id?) → bool
|
||||||
--- Returns true if any widget is currently active.
|
--- With id: is that specific widget open?
|
||||||
function M.is_open()
|
--- Without id: is any widget open? (v0.1.1 bw-compat)
|
||||||
return active ~= nil
|
function M.is_open(widget_id)
|
||||||
|
if widget_id == nil then
|
||||||
|
return #open_order > 0
|
||||||
|
end
|
||||||
|
local w = windows[widget_id]
|
||||||
|
return w ~= nil and w.open == true
|
||||||
end
|
end
|
||||||
|
|
||||||
--- M.is_pausing() → bool
|
--- M.is_pausing() → bool
|
||||||
--- Returns true if the active widget has pause_on_open=true.
|
--- Returns true if ANY open widget has pause_on_open=true.
|
||||||
function M.is_pausing()
|
function M.is_pausing()
|
||||||
return active ~= nil and widgets[active] ~= nil
|
for _, id in ipairs(open_order) do
|
||||||
and widgets[active].pause_on_open == true
|
local w = windows[id]
|
||||||
|
if w and w.widget_def.pause_on_open == true then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
@@ -426,7 +684,7 @@ function M.update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if not active then
|
if #open_order == 0 then
|
||||||
-- Still need to update last_mouse_* state even when closed so that
|
-- 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.
|
-- opening mid-frame doesn't produce a spurious edge on the next frame.
|
||||||
if engine and engine.input then
|
if engine and engine.input then
|
||||||
@@ -468,98 +726,27 @@ end
|
|||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
--- M.render()
|
--- M.render()
|
||||||
--- Must be called each render frame. Draws the active widget's panel
|
--- Must be called each render frame. Iterates open_order bottom-up
|
||||||
--- (background, border, title bar) then invokes widget_def.render(ctx).
|
--- (last = focused = topmost) and draws each window. Renders any open
|
||||||
--- If a context-menu is open, renders it on top.
|
--- context-menu on top of all windows. No-op if nothing is open.
|
||||||
--- No-op if no widget is active.
|
|
||||||
function M.render()
|
function M.render()
|
||||||
if not active then return end
|
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)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local widget_def = widgets[active]
|
-- Context-menu (rendered on top of all windows)
|
||||||
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)
|
|
||||||
|
|
||||||
-- Close button (×) in top-right of title bar. Hit-tested in _dispatch_event.
|
|
||||||
local cb = close_button_rect(panel_x, panel_y, panel_w, padding, theme.font_size_title)
|
|
||||||
engine.render.draw_text("X",
|
|
||||||
cb.x + math.floor(cb.w / 4),
|
|
||||||
cb.y,
|
|
||||||
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
|
if ctx_menu then
|
||||||
local mx, my = safe_mouse_pos()
|
render_context_menu()
|
||||||
-- 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
|
||||||
end
|
end
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
-- Test backdoor
|
-- Test backdoors (v0.1.0 + v0.2.0 additions)
|
||||||
-- -----------------------------------------------------------------------
|
-- -----------------------------------------------------------------------
|
||||||
|
|
||||||
--- M._dispatch_event_for_test(event) — for tests only.
|
--- M._dispatch_event_for_test(event) — for tests only.
|
||||||
@@ -569,4 +756,42 @@ function M._dispatch_event_for_test(event)
|
|||||||
_dispatch_event(event)
|
_dispatch_event(event)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- M._test_reset_all() — clears widgets + windows + open_order + ctx_menu.
|
||||||
|
--- Note: theme + triggers preserved across reset; tests that need
|
||||||
|
--- those reset should do it explicitly.
|
||||||
|
function M._test_reset_all()
|
||||||
|
widgets = {}
|
||||||
|
windows = {}
|
||||||
|
open_order = {}
|
||||||
|
ctx_menu = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M._test_get_open_ids() — returns array of currently-open ids in
|
||||||
|
--- open-order (last = focused).
|
||||||
|
function M._test_get_open_ids()
|
||||||
|
local out = {}
|
||||||
|
for i, id in ipairs(open_order) do out[i] = id end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M._test_get_focused_id() — last-opened id, or nil.
|
||||||
|
function M._test_get_focused_id()
|
||||||
|
if #open_order == 0 then return nil end
|
||||||
|
return open_order[#open_order]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M._test_get_window_bounds(widget_id) — resolved bounds for an open
|
||||||
|
--- window, or nil if not open.
|
||||||
|
function M._test_get_window_bounds(widget_id)
|
||||||
|
local w = windows[widget_id]
|
||||||
|
if not w or not w.open then return nil end
|
||||||
|
local sw, sh = get_screen_size()
|
||||||
|
return resolve_bounds(w.opts.layout, sw, sh)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- M._test_get_screen_size() — current screen size used by layouts.
|
||||||
|
function M._test_get_screen_size()
|
||||||
|
return get_screen_size()
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"id":"lib-core.panel","version":"0.1.1","api_min":"0.1","deps":[]}
|
{"id":"lib-core.panel","version":"0.2.0","api_min":"0.1","deps":[]}
|
||||||
|
|||||||
Reference in New Issue
Block a user