diff --git a/README.md b/README.md index 7acdef6..5e707e1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,17 @@ # 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. +Generic overlay-panel framework. v0.2.0 supports multiple panels open at +the same time with 11 named layout-slot templates + custom-fn hook for +bespoke positioning. Handles input dispatch (mouse click + wheel, +edge-detected with reverse-open-order hit-test), renders titled panel +overlays, and supports a context-menu layer on top. Designed as the glue +layer between game modules and the engine render/input surfaces. -**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 **Requires:** engine.render.*, engine.input.*, lib-core.input (lazy, for default-trigger) **Tags:** panel, overlay, ui, input, context-menu @@ -45,9 +51,11 @@ v0.1 ships a minimal single-active-widget panel system: ## 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:** ```lua @@ -86,16 +94,20 @@ closes the panel (sets active to nil, clears any open context-menu). **Syntax:** `panel.open(widget_id: string) -> void` -Sets `widget_id` as the active widget. Loud-error if `widget_id` has not -been registered. +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. --- -### `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` -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. --- -### `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` -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). +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). --- @@ -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 `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 | Key | Default | Description | diff --git a/init.lua b/init.lua index e94b80e..c1ca7f0 100644 --- a/init.lua +++ b/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) --- - Centrally-rendered overlay with configurable theme --- - Per-frame input dispatch (mouse click + wheel, edge-detected) --- - Context-menu (show, hit-test, auto-close) +-- - Multi-active open_order (last-opened = focused = topmost) +-- - 11 named layout-slot templates + opts.layout = function(sw, sh) +-- - 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) --- - 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.input.get_mouse_pos() → x, y @@ -20,12 +27,13 @@ -- - 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. +-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.2 still 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) +-- DEFERRED (v0.2 non-goals): +-- - Persistent panels (always-on HUD widgets) → v0.3 +-- - z_tier other than "normal" (modal, hud, system) → v0.4 +-- - Drag + resize → v0.4 -- - Keyboard navigation within widgets -- - Panel animation (fade in/out) -- ===================================================================== @@ -39,8 +47,11 @@ -- still land further down once the public surface section starts. local M = {} -local widgets = {} -- widget_id (string) → widget_def table -local active = nil -- active widget_id or nil +local widgets = {} -- widget_id (string) → widget_def table (legacy registry, preserved) +-- 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 ctx_menu = nil -- context-menu state table or nil -- 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 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 --- measure_text, but context-menu render in v0.1 uses this pre-computed w). local function estimate_menu_width(actions, padding) @@ -106,6 +125,65 @@ local function estimate_menu_width(actions, padding) return max_w 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 -- ----------------------------------------------------------------------- @@ -135,14 +213,8 @@ local function safe_mouse_pos() 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} -- 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 size = font_size_title + padding return { @@ -153,59 +225,44 @@ local function close_button_rect(panel_x, panel_y, panel_w, padding, font_size_t } end -local function _dispatch_event(event) - -- Close-button intercept (top-right ×): left-click closes the active panel - -- 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 +-- ----------------------------------------------------------------------- +-- Internal: per-window render helper (extracted from v0.1 render()) +-- ----------------------------------------------------------------------- - -- 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 function render_one_window(widget_id, win, sw, sh) + local widget_def = win.widget_def + local bounds = resolve_bounds(win.opts.layout, sw, sh) + local panel_x, panel_y, panel_w, panel_h = bounds.x, bounds.y, bounds.w, bounds.h 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, y = panel_y + title_area_h, w = panel_w - padding * 2, @@ -213,12 +270,153 @@ local function _dispatch_event(event) } local widget_ctx = { - bounds = content_bounds, + bounds = win._content_bounds, 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 -- ----------------------------------------------------------------------- @@ -228,13 +426,19 @@ end -- Registry / Lifecycle -- ----------------------------------------------------------------------- ---- M.register(widget_id, widget_def) +--- M.register(widget_id, widget_def, opts?) --- 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 --- .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) +--- +--- 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 error("panel.register: widget_id must be a string, got " .. type(widget_id)) end @@ -253,57 +457,111 @@ function M.register(widget_id, widget_def) if widgets[widget_id] ~= nil then error("panel.register: duplicate widget_id '" .. widget_id .. "'") 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 + 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 --- M.unregister(widget_id) ---- Removes a widget from the registry. If the widget is currently active, ---- closes the panel first (clears active + ctx_menu). +--- Removes a widget from the registry. If the widget is currently open, +--- closes the panel first (removes from open_order; clears ctx_menu if +--- this was the last-open window). function M.unregister(widget_id) - if active == widget_id then - active = nil - ctx_menu = nil + if windows[widget_id] and windows[widget_id].open then + M.close(widget_id) end widgets[widget_id] = nil + windows[widget_id] = nil end --- 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) if widgets[widget_id] == nil then error("panel.open: unknown widget_id '" .. tostring(widget_id) .. "' (register before open)") 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 ---- M.close() ---- Closes the active panel and clears any open context-menu. -function M.close() - active = nil - ctx_menu = nil +--- M.close(widget_id?) +--- With id: closes that specific widget. Without id: closes the focused +--- (= last-opened) for v0.1.1 bw-compat. +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 + 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 --- 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) - if active == widget_id then - M.close() + if M.is_open(widget_id) then + M.close(widget_id) 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 +--- M.is_open(widget_id?) → bool +--- With id: is that specific widget open? +--- Without id: is any widget open? (v0.1.1 bw-compat) +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 --- 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() - return active ~= nil and widgets[active] ~= nil - and widgets[active].pause_on_open == true + for _, id in ipairs(open_order) do + local w = windows[id] + if w and w.widget_def.pause_on_open == true then + return true + end + end + return false end -- ----------------------------------------------------------------------- @@ -426,7 +684,7 @@ function M.update(dt) end end - if not active then + if #open_order == 0 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 @@ -468,98 +726,27 @@ end -- ----------------------------------------------------------------------- --- 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. +--- Must be called each render frame. Iterates open_order bottom-up +--- (last = focused = topmost) and draws each window. Renders any open +--- context-menu on top of all windows. No-op if nothing is open. 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) - - -- 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 - 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) + 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 + + -- Context-menu (rendered on top of all windows) + if ctx_menu then + render_context_menu() + end end -- ----------------------------------------------------------------------- --- Test backdoor +-- Test backdoors (v0.1.0 + v0.2.0 additions) -- ----------------------------------------------------------------------- --- M._dispatch_event_for_test(event) — for tests only. @@ -569,4 +756,42 @@ function M._dispatch_event_for_test(event) _dispatch_event(event) 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 diff --git a/manifest.lib b/manifest.lib index def1ccf..c986dbf 100644 --- a/manifest.lib +++ b/manifest.lib @@ -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":[]}