Adds focus/raise/lower/get_focused API with click-to-focus auto-routing (hud-tier never focusable), drag-by-title-bar with 32px screen-clamp, resize via SE-corner handle with min/max_size clamps. Drag and resize move bounds outside the layout-fn via a per-window bounds_override that shadows resolve_bounds across render + dispatch + point_in_any_panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1411 lines
58 KiB
Lua
1411 lines
58 KiB
Lua
-- =====================================================================
|
|
-- lib-core.panel v0.4.0 — Generic overlay-panel framework (Window-Manager)
|
|
--
|
|
-- v0.4.0 adds the focus API (`focus`/`raise`/`lower`/`get_focused`),
|
|
-- click-to-focus auto-routing, drag-by-title-bar, and SE-corner resize
|
|
-- on top of v0.3.0's z_tier + persistent + input_block model. Drag and
|
|
-- resize fix bounds outside the layout-fn via `window.bounds_override`;
|
|
-- min/max_size constraints and a 32px-of-title-bar screen-clamp keep
|
|
-- windows recoverable.
|
|
--
|
|
-- Provides:
|
|
-- - Widget registry + lifecycle (register, open, close, toggle)
|
|
-- - Multi-active open_order (last-opened = focused = topmost within tier)
|
|
-- - 11 named layout-slot templates + opts.layout = function(sw, sh)
|
|
-- - Z-tier rendering: hud (bottom) → normal → top (drawn last)
|
|
-- - Persistent windows: open at register-time; ESC-equivalent no-op
|
|
-- - Input-block routing: none / self / all (modal swallows misses)
|
|
-- - Focus API: focus(id) / raise(id) / lower(id) / get_focused()
|
|
-- - Click-to-focus: clicks on non-hud windows auto-focus the hit widget
|
|
-- - Drag by title-bar (draggable=true opt)
|
|
-- - Resize by SE-corner handle (resizable=true opt; 12x12 handle)
|
|
-- - min_size / max_size constraints + 32px-visible screen-clamp on drag
|
|
-- - panel.point_in_any_panel(x,y): public hit-test for module-side canvas
|
|
-- - Per-frame input dispatch with reverse-z-tier reverse-open-order hit
|
|
-- - Context-menu (show, hit-test, auto-close) on top of windows
|
|
-- - Default-trigger binding via lib-core.input (lazy-required)
|
|
-- - Optional pause-gate (any open widget with pause_on_open=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.4 still falls
|
|
-- back to 1280x720 constants matching the default Sporel window config.
|
|
--
|
|
-- DEFERRED (v0.4 non-goals):
|
|
-- - Resize corners other than SE (NE/SW/NW + edge-resize)
|
|
-- - Keyboard navigation within widgets
|
|
-- - Panel animation (fade in/out)
|
|
-- - Save/load window-bounds across sessions
|
|
-- =====================================================================
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- Module-level state (all local — no globals)
|
|
-- -----------------------------------------------------------------------
|
|
|
|
-- Module-table declared early so closures in module-local helpers below
|
|
-- can reference M.X functions. Method assignments (function M.x() ... end)
|
|
-- still land further down once the public surface section starts.
|
|
local M = {}
|
|
|
|
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
|
|
-- bind_default_trigger calls append independent entries (each widget gets
|
|
-- its own input-action so they don't clobber each other in lib-core.input).
|
|
local triggers = {}
|
|
local last_mouse_left = false -- for edge-detection (was down last frame)
|
|
local last_mouse_right = false -- for edge-detection (was down last frame)
|
|
|
|
-- v0.4.0: drag-state (set by _dispatch_event on title-bar click; cleared by
|
|
-- M.update on mouse-release). drag_offset_x/y is the click-point relative
|
|
-- to the window's top-left at drag-start, so the title stays under the
|
|
-- cursor through the move.
|
|
local dragging_id = nil
|
|
local drag_offset_x = 0
|
|
local drag_offset_y = 0
|
|
|
|
-- v0.4.0: resize-state (set by _dispatch_event on SE-corner click; cleared
|
|
-- by M.update on mouse-release). v0.4.0 supports only the SE corner.
|
|
local resizing_id = nil
|
|
local resize_corner = nil -- "SE" only (placeholder for future NE/SW/NW)
|
|
local resize_start_w = 0
|
|
local resize_start_h = 0
|
|
local resize_start_mx = 0
|
|
local resize_start_my = 0
|
|
|
|
-- v0.4.0: resize-handle size (12x12 px hit-zone + visual cue at SE corner)
|
|
local RESIZE_HANDLE_SIZE = 12
|
|
|
|
-- v0.4.0: minimum visible title-bar pixels on screen edges during drag.
|
|
-- Keeps the drag-handle reachable even if the user pushes the window past
|
|
-- the screen border.
|
|
local MIN_VISIBLE_PX = 32
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- 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
|
|
|
|
--- 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)
|
|
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
|
|
|
|
-- =====================================================================
|
|
-- v0.3.0 — Z-Tiers + Input-Block defaults
|
|
-- =====================================================================
|
|
|
|
-- Z-Tier constants. Bottom-to-top render order = HUD (drawn first =
|
|
-- background), then normal (toolbars/persistent panels), then top
|
|
-- (drawn last = modal dialogs). Input dispatch iterates reverse.
|
|
local VALID_Z_TIERS = { hud = true, normal = true, top = true }
|
|
local Z_TIER_ORDER = { "hud", "normal", "top" }
|
|
|
|
-- Default input_block per tier when widget does not specify one.
|
|
-- hud: pure cosmetic overlay, never absorbs input (HUD bars, status).
|
|
-- normal: absorbs clicks within own bounds (persistent toolbars).
|
|
-- top: modal; swallows clicks that miss it too (blocking dialogs).
|
|
local DEFAULT_INPUT_BLOCK_BY_TIER = {
|
|
hud = "none",
|
|
normal = "self",
|
|
top = "all",
|
|
}
|
|
|
|
local VALID_INPUT_BLOCK = { none = true, self = true, all = true }
|
|
|
|
-- =====================================================================
|
|
-- 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
|
|
|
|
--- v0.4.0: returns the bounds-to-use for a window. Drag and resize fix
|
|
--- bounds outside the layout-fn via win.bounds_override; everything else
|
|
--- (initial position, screen-resize follow) falls through to resolve_bounds.
|
|
--- All render + dispatch + point_in_any_panel callsites use this helper
|
|
--- instead of calling resolve_bounds directly.
|
|
local function get_bounds_for(win, sw, sh)
|
|
if win.bounds_override then return win.bounds_override end
|
|
return resolve_bounds(win.opts.layout, sw, sh)
|
|
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
|
|
|
|
-- Close-button rect in the top-right of the title bar. Same math is used
|
|
-- 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 {
|
|
x = panel_x + panel_w - size - padding,
|
|
y = panel_y + padding,
|
|
w = size,
|
|
h = size,
|
|
}
|
|
end
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- Internal: per-window render helper (extracted from v0.1 render())
|
|
-- -----------------------------------------------------------------------
|
|
|
|
local function render_one_window(widget_id, win, sw, sh)
|
|
local widget_def = win.widget_def
|
|
local bounds = get_bounds_for(win, sw, sh)
|
|
local panel_x, panel_y, panel_w, panel_h = bounds.x, bounds.y, bounds.w, bounds.h
|
|
local padding = theme.padding
|
|
|
|
-- v0.3.0: chromeless widgets skip the panel-lib decorations (bg /
|
|
-- border / title bar / close-X) entirely. The widget gets the FULL
|
|
-- panel bounds as ctx.bounds (no title-bar inset). Intended for
|
|
-- in-game persistent HUD widgets (toolbars, layer-pickers, palettes)
|
|
-- that want to render their own chrome.
|
|
if win.opts.chromeless then
|
|
win._content_bounds = {
|
|
x = panel_x, y = panel_y, w = panel_w, h = panel_h,
|
|
}
|
|
win._close_button_rect = nil
|
|
local widget_ctx = {
|
|
bounds = win._content_bounds,
|
|
theme = M.get_theme(),
|
|
is_focused = (widget_id == open_order[#open_order]),
|
|
}
|
|
widget_def.render(widget_ctx)
|
|
-- v0.4.0: resize-handle is drawn even on chromeless widgets when
|
|
-- resizable=true — the modder opted in, and the 12px corner block
|
|
-- is the only visual cue that the window can be resized.
|
|
if win.opts.resizable then
|
|
engine.render.draw_rect(
|
|
panel_x + panel_w - RESIZE_HANDLE_SIZE,
|
|
panel_y + panel_h - RESIZE_HANDLE_SIZE,
|
|
RESIZE_HANDLE_SIZE, RESIZE_HANDLE_SIZE,
|
|
theme.border_color)
|
|
end
|
|
return
|
|
end
|
|
|
|
-- 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
|
|
|
|
-- v0.4.0: resize-handle (small filled square at the SE corner) for
|
|
-- resizable windows. Rendered after the border so it visually sits
|
|
-- on top of the corner.
|
|
if win.opts.resizable then
|
|
engine.render.draw_rect(
|
|
panel_x + panel_w - RESIZE_HANDLE_SIZE,
|
|
panel_y + panel_h - RESIZE_HANDLE_SIZE,
|
|
RESIZE_HANDLE_SIZE, RESIZE_HANDLE_SIZE,
|
|
theme.border_color)
|
|
end
|
|
|
|
-- 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,
|
|
h = panel_h - title_area_h - padding,
|
|
}
|
|
|
|
local widget_ctx = {
|
|
bounds = win._content_bounds,
|
|
theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state
|
|
is_focused = (widget_id == open_order[#open_order]),
|
|
}
|
|
|
|
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}
|
|
---
|
|
--- v0.4.0 routing:
|
|
--- 1. Context-menu (if open) always wins.
|
|
--- 2. Iterate reverse z_tier (top → normal → hud), within tier
|
|
--- reverse-open-order. For each open window:
|
|
--- - If input_block="all" AND click is OUTSIDE its bounds: swallow
|
|
--- the event IMMEDIATELY (modal owns the screen — do not let
|
|
--- lower-tier widgets claim outside-clicks behind it).
|
|
--- - input_block="none": skip hit-test entirely (HUD passes through).
|
|
--- - else: hit-test bounds; on hit:
|
|
--- a. v0.4.0: auto-focus the window (no-op for hud-tier).
|
|
--- b. v0.4.0: drag-start check (left-click in title-bar area of
|
|
--- a draggable, non-chromeless window). Drag-start consumes
|
|
--- the click — no forwarding to handle_input.
|
|
--- c. v0.4.0: resize-start check (left-click in 12x12 SE corner
|
|
--- of a resizable window). Resize-start consumes the click.
|
|
--- d. Close-button check (chromeless excluded).
|
|
--- e. Forward to widget.handle_input.
|
|
--- 3. If no window claimed the event AND any open window has
|
|
--- input_block="all": swallow (modal block).
|
|
--- 4. Else drop (no game-routing — module handles its own input).
|
|
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
|
|
|
|
local sw, sh = get_screen_size()
|
|
local saw_modal = false -- track if any input_block="all" window is open
|
|
|
|
-- Iterate reverse z-tier (top first), within tier reverse-open-order.
|
|
for ti = #Z_TIER_ORDER, 1, -1 do
|
|
local tier_name = Z_TIER_ORDER[ti]
|
|
for i = #open_order, 1, -1 do
|
|
local widget_id = open_order[i]
|
|
local win = windows[widget_id]
|
|
if win and win.open and win.opts.z_tier == tier_name then
|
|
if win.opts.input_block == "all" then
|
|
saw_modal = true
|
|
-- Modal swallow: if the click is outside this modal's
|
|
-- bounds, swallow immediately so lower-tier widgets
|
|
-- behind it cannot claim it. Inside-bounds clicks fall
|
|
-- through to normal routing below.
|
|
if event.kind == "click" and event.x and event.y then
|
|
local mb = get_bounds_for(win, sw, sh)
|
|
if not (event.x >= mb.x and event.x < mb.x + mb.w
|
|
and event.y >= mb.y and event.y < mb.y + mb.h) then
|
|
return
|
|
end
|
|
end
|
|
end
|
|
if win.opts.input_block ~= "none" then
|
|
local b = get_bounds_for(win, sw, sh)
|
|
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
|
|
-- v0.4.0: auto-focus on click for non-hud widgets.
|
|
-- Hud-tier windows are never focusable (M.focus
|
|
-- is a no-op for them) so calling it unconditionally
|
|
-- is safe.
|
|
M.focus(widget_id)
|
|
|
|
-- v0.4.0: drag-start check. Left-click in the
|
|
-- title-bar area of a draggable, non-chromeless
|
|
-- window starts a drag and consumes the click.
|
|
-- Chromeless widgets are excluded because their
|
|
-- title-bar is invisible — there is no defined
|
|
-- drag-handle.
|
|
if event.button == "left"
|
|
and win.opts.draggable
|
|
and not win.opts.chromeless then
|
|
local title_bar_h = theme.font_size_title + theme.padding * 2
|
|
if event.y >= b.y and event.y < b.y + title_bar_h then
|
|
-- But not if the click is on the close-button.
|
|
local cb_check = win._close_button_rect
|
|
or close_button_rect(b.x, b.y, b.w,
|
|
theme.padding, theme.font_size_title)
|
|
if not (event.x >= cb_check.x
|
|
and event.x < cb_check.x + cb_check.w
|
|
and event.y >= cb_check.y
|
|
and event.y < cb_check.y + cb_check.h) then
|
|
dragging_id = widget_id
|
|
drag_offset_x = event.x - b.x
|
|
drag_offset_y = event.y - b.y
|
|
return
|
|
end
|
|
end
|
|
end
|
|
|
|
-- v0.4.0: resize-start check. Left-click in the
|
|
-- 12x12 SE-corner of a resizable window starts
|
|
-- a resize and consumes the click. Render order
|
|
-- gives drag priority on tiny windows where the
|
|
-- title-bar overlaps the SE corner: drag-check
|
|
-- runs first above and would already have
|
|
-- returned. Resize is allowed on chromeless
|
|
-- widgets (the 12px handle is the visual cue).
|
|
if event.button == "left" and win.opts.resizable then
|
|
local hw = RESIZE_HANDLE_SIZE
|
|
local hx = b.x + b.w - hw
|
|
local hy = b.y + b.h - hw
|
|
if event.x >= hx and event.x < hx + hw
|
|
and event.y >= hy and event.y < hy + hw then
|
|
resizing_id = widget_id
|
|
resize_corner = "SE"
|
|
resize_start_w = b.w
|
|
resize_start_h = b.h
|
|
resize_start_mx = event.x
|
|
resize_start_my = event.y
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Close-button intercept (top-right X): left-click
|
|
-- closes the window before forwarding to the widget.
|
|
-- Chromeless widgets have no chrome (no X), so we
|
|
-- must NOT hit-test the phantom close-zone — the
|
|
-- widget owns its full bounds.
|
|
if event.button == "left" and not win.opts.chromeless then
|
|
local cb = win._close_button_rect
|
|
if not cb then
|
|
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
|
|
|
|
local content = win._content_bounds
|
|
if not content then
|
|
if win.opts.chromeless then
|
|
content = { x = b.x, y = b.y, w = b.w, h = b.h }
|
|
else
|
|
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
|
|
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
|
|
elseif event.kind == "wheel" then
|
|
-- Wheel routes to the focused (last-opened) window
|
|
-- regardless of tier — once we reach the focused id
|
|
-- in tier-walk order we forward and stop.
|
|
if widget_id == open_order[#open_order] then
|
|
local content = win._content_bounds
|
|
if not content then
|
|
if win.opts.chromeless then
|
|
content = { x = b.x, y = b.y, w = b.w, h = b.h }
|
|
else
|
|
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
|
|
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
|
|
end
|
|
end
|
|
|
|
-- No window claimed the event. If any modal-block ("all") is open,
|
|
-- swallow the event so it does not reach the game-layer. Otherwise
|
|
-- drop it silently (modules read engine.input directly for canvas
|
|
-- interactions and can call panel.point_in_any_panel(x,y) to skip
|
|
-- clicks that fall within a panel area).
|
|
if saw_modal then return end
|
|
end
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- Public API (M declared at top of file so closures above can reference it)
|
|
-- -----------------------------------------------------------------------
|
|
-- -----------------------------------------------------------------------
|
|
-- Registry / Lifecycle
|
|
-- -----------------------------------------------------------------------
|
|
|
|
--- 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 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.
|
|
---
|
|
--- 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 = "hud" | "normal" (default) | "top"
|
|
--- persistent = bool (default false) — if true, window auto-opens at
|
|
--- register-time and panel.close() without arg becomes a
|
|
--- no-op when it's focused (modder must close(id) explicit).
|
|
--- input_block = "none" | "self" | "all" — defaults per tier:
|
|
--- hud → "none", normal → "self", top → "all".
|
|
--- chromeless = false (default) | true — when true, suppresses the
|
|
--- panel-lib chrome (title-bar, border, X close-button).
|
|
--- Widget's render(ctx) draws into the full bounds
|
|
--- without panel-lib decoration. Useful for HUD-style
|
|
--- widgets that have their own visual design (status
|
|
--- bars, toolbars with their own theme).
|
|
--- draggable = false (default) | true — when true, left-click in
|
|
--- the title-bar area starts a drag. Ignored if the
|
|
--- widget is chromeless (no defined drag-handle).
|
|
--- v0.4.0+.
|
|
--- resizable = false (default) | true — when true, a 12x12 SE-corner
|
|
--- handle is rendered + hit-tested for left-click resize.
|
|
--- Works on chromeless widgets too. v0.4.0+.
|
|
--- min_size = {w=number, h=number} — minimum window size when
|
|
--- resizing. Defaults to {w=120, h=80}. v0.4.0+.
|
|
--- max_size = {w=number, h=number} — maximum window size when
|
|
--- resizing. Defaults to {w=99999, h=99999}. v0.4.0+.
|
|
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
|
|
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
|
|
opts = opts or {}
|
|
|
|
-- v0.3.0: validate z_tier + input_block eagerly so typos loud-error
|
|
-- at register-time (not at the next frame's render/dispatch).
|
|
if opts.z_tier ~= nil and not VALID_Z_TIERS[opts.z_tier] then
|
|
error(string.format(
|
|
"panel.register: unknown z_tier '%s' (must be hud|normal|top)",
|
|
tostring(opts.z_tier)), 2)
|
|
end
|
|
if opts.input_block ~= nil and not VALID_INPUT_BLOCK[opts.input_block] then
|
|
error(string.format(
|
|
"panel.register: unknown input_block '%s' (must be none|self|all)",
|
|
tostring(opts.input_block)), 2)
|
|
end
|
|
|
|
-- 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
|
|
|
|
local z_tier = opts.z_tier or "normal"
|
|
local input_block = opts.input_block or DEFAULT_INPUT_BLOCK_BY_TIER[z_tier]
|
|
local persistent = opts.persistent == true
|
|
local chromeless = opts.chromeless == true
|
|
|
|
-- v0.4.0: validate drag/resize opts.
|
|
local draggable = opts.draggable == true
|
|
local resizable = opts.resizable == true
|
|
if opts.min_size ~= nil and type(opts.min_size) ~= "table" then
|
|
error(string.format(
|
|
"panel.register: min_size must be a table {w=,h=} (got %s)",
|
|
type(opts.min_size)), 2)
|
|
end
|
|
if opts.max_size ~= nil and type(opts.max_size) ~= "table" then
|
|
error(string.format(
|
|
"panel.register: max_size must be a table {w=,h=} (got %s)",
|
|
type(opts.max_size)), 2)
|
|
end
|
|
local min_size = {
|
|
w = (opts.min_size and opts.min_size.w) or 120,
|
|
h = (opts.min_size and opts.min_size.h) or 80,
|
|
}
|
|
local max_size = {
|
|
w = (opts.max_size and opts.max_size.w) or 99999,
|
|
h = (opts.max_size and opts.max_size.h) or 99999,
|
|
}
|
|
|
|
widgets[widget_id] = widget_def
|
|
windows[widget_id] = {
|
|
widget_def = widget_def,
|
|
opts = {
|
|
layout = opts.layout, -- nil OR string OR fn
|
|
z_tier = z_tier,
|
|
persistent = persistent,
|
|
input_block = input_block,
|
|
chromeless = chromeless,
|
|
draggable = draggable,
|
|
resizable = resizable,
|
|
min_size = min_size,
|
|
max_size = max_size,
|
|
},
|
|
open = false,
|
|
-- v0.4.0: bounds_override is nil until drag/resize sets it. When
|
|
-- non-nil it shadows resolve_bounds via get_bounds_for. Cleared
|
|
-- on close (handled in M.close).
|
|
bounds_override = nil,
|
|
}
|
|
|
|
-- v0.3.0: persistent windows are open from register-time and pushed
|
|
-- onto open_order so they appear in render + hit-test immediately.
|
|
if persistent then
|
|
open_order[#open_order + 1] = widget_id
|
|
windows[widget_id].open = true
|
|
end
|
|
end
|
|
|
|
--- M.unregister(widget_id)
|
|
--- 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 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)
|
|
--- 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
|
|
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(widget_id?)
|
|
--- With id: closes that specific widget (DOES close persistent — explicit
|
|
--- modder action). Without id: closes the focused (= last-opened) for
|
|
--- v0.1.1 bw-compat — BUT v0.3.0 makes this a no-op when the focused
|
|
--- window is persistent (ESC-equivalent should not dismiss persistent
|
|
--- toolbars/HUDs).
|
|
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]
|
|
-- v0.3.0: persistent windows are exempt from arg-less close
|
|
-- (so ESC / `panel.close()` cannot dismiss persistent panels).
|
|
if windows[widget_id] and windows[widget_id].opts.persistent then
|
|
return
|
|
end
|
|
end
|
|
local w = windows[widget_id]
|
|
if not w or not w.open then return end
|
|
-- Explicit close(id) DOES close persistent windows (modder-controlled).
|
|
w.open = false
|
|
local idx = find_in_array(open_order, widget_id)
|
|
if idx then table.remove(open_order, idx) end
|
|
-- v0.4.0: cancel in-flight drag/resize if it references this window.
|
|
if dragging_id == widget_id then dragging_id = nil end
|
|
if resizing_id == widget_id then
|
|
resizing_id = nil
|
|
resize_corner = nil
|
|
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 open, closes it. Otherwise opens it.
|
|
function M.toggle(widget_id)
|
|
if M.is_open(widget_id) then
|
|
M.close(widget_id)
|
|
else
|
|
M.open(widget_id)
|
|
end
|
|
end
|
|
|
|
--- 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 ANY open widget has pause_on_open=true.
|
|
function M.is_pausing()
|
|
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
|
|
|
|
--- M.point_in_any_panel(x, y) → bool
|
|
--- Returns true if screen-coord (x,y) falls within the bounds of ANY
|
|
--- currently-open panel window (regardless of z_tier or input_block).
|
|
--- Intended for module-side canvas-click logic: modules read input
|
|
--- directly via engine.input.* but should skip canvas-paint when the
|
|
--- click landed within a panel-widget area. Returns false if no panels
|
|
--- are open.
|
|
function M.point_in_any_panel(x, y)
|
|
if #open_order == 0 then return false end
|
|
local sw, sh = get_screen_size()
|
|
for _, id in ipairs(open_order) do
|
|
local w = windows[id]
|
|
if w and w.open then
|
|
local b = get_bounds_for(w, sw, sh)
|
|
if x >= b.x and x < b.x + b.w
|
|
and y >= b.y and y < b.y + b.h then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- v0.4.0 — Focus API
|
|
-- -----------------------------------------------------------------------
|
|
|
|
--- M.focus(widget_id)
|
|
--- Brings widget_id to the top within its z_tier (within open_order).
|
|
--- No-op if the widget is not registered, not open, or in hud-tier.
|
|
--- Hud-tier widgets are by design never "focused" — they are cosmetic
|
|
--- overlays and click-to-focus auto-routes do not promote them.
|
|
function M.focus(widget_id)
|
|
local w = windows[widget_id]
|
|
if not w or not w.open then return end
|
|
if w.opts.z_tier == "hud" then return end
|
|
local idx = find_in_array(open_order, widget_id)
|
|
if idx then
|
|
table.remove(open_order, idx)
|
|
end
|
|
open_order[#open_order + 1] = widget_id
|
|
end
|
|
|
|
--- M.raise(widget_id) — alias for focus(); reads as "raise to top".
|
|
M.raise = M.focus
|
|
|
|
--- M.lower(widget_id)
|
|
--- Moves widget_id to the bottom of open_order. No-op if the widget is
|
|
--- not registered or not open. Unlike focus, lower works for hud-tier
|
|
--- widgets too (lowering within tier is a layering operation, not focus).
|
|
function M.lower(widget_id)
|
|
local w = windows[widget_id]
|
|
if not w or not w.open then return end
|
|
local idx = find_in_array(open_order, widget_id)
|
|
if idx then
|
|
table.remove(open_order, idx)
|
|
table.insert(open_order, 1, widget_id)
|
|
end
|
|
end
|
|
|
|
--- M.get_focused() → widget_id or nil
|
|
--- Returns the currently-focused widget_id. Focus is the last-opened id
|
|
--- in the topmost occupied z_tier. If a top-tier window is open it wins
|
|
--- regardless of normal/hud-tier windows above it in open_order. If no
|
|
--- top-tier window is open, returns the last-opened id (which may be a
|
|
--- normal- or hud-tier window).
|
|
function M.get_focused()
|
|
if #open_order == 0 then return nil end
|
|
for i = #open_order, 1, -1 do
|
|
local id = open_order[i]
|
|
if windows[id] and windows[id].opts.z_tier == "top" then
|
|
return id
|
|
end
|
|
end
|
|
return open_order[#open_order]
|
|
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.
|
|
--- Multiple calls register independent triggers (each widget gets its own
|
|
--- input-action named "panel_toggle_<widget_id>" so bindings don't clobber).
|
|
--- 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
|
|
local action_name = "panel_toggle_" .. widget_id
|
|
input.bind(action_name, {key})
|
|
table.insert(triggers, { action_name = action_name, 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 all default triggers (lazy requires lib-core.input if any are bound)
|
|
if #triggers > 0 then
|
|
local input = require("lib-core.input")
|
|
for _, t in ipairs(triggers) do
|
|
if input.was_action_pressed(t.action_name) then
|
|
M.toggle(t.widget_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
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
|
|
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
|
|
|
|
-- v0.4.0: drag update. While dragging and the left mouse button is
|
|
-- still held, update bounds_override per cur-mouse - drag_offset.
|
|
-- Screen-clamp keeps at least MIN_VISIBLE_PX of title-bar visible
|
|
-- so the user can always grab and drag the window back on-screen.
|
|
if dragging_id then
|
|
if cur_left then
|
|
local win = windows[dragging_id]
|
|
if win and win.open then
|
|
local sw, sh = get_screen_size()
|
|
local current_b = get_bounds_for(win, sw, sh)
|
|
local new_x = mx - drag_offset_x
|
|
local new_y = my - drag_offset_y
|
|
-- Clamp x: at least MIN_VISIBLE_PX of width visible on
|
|
-- both edges. (-current_b.w + MIN_VISIBLE_PX) = the leftmost
|
|
-- x for which MIN_VISIBLE_PX still pokes out at the right.
|
|
new_x = math.max(-current_b.w + MIN_VISIBLE_PX,
|
|
math.min(sw - MIN_VISIBLE_PX, new_x))
|
|
-- Clamp y: title-bar must stay within the top edge so the
|
|
-- user can always grab it; allow vertical extent past
|
|
-- bottom but keep top of title-bar at y >= 0.
|
|
new_y = math.max(0, math.min(sh - MIN_VISIBLE_PX, new_y))
|
|
win.bounds_override = {
|
|
x = new_x, y = new_y, w = current_b.w, h = current_b.h,
|
|
}
|
|
else
|
|
dragging_id = nil
|
|
end
|
|
else
|
|
-- Mouse released: drag ends.
|
|
dragging_id = nil
|
|
end
|
|
end
|
|
|
|
-- v0.4.0: resize update. While resizing and the left mouse button is
|
|
-- still held, update bounds_override w/h per resize_start + delta,
|
|
-- clamped to min/max_size.
|
|
if resizing_id then
|
|
if cur_left then
|
|
local win = windows[resizing_id]
|
|
if win and win.open then
|
|
local sw, sh = get_screen_size()
|
|
local current_b = get_bounds_for(win, sw, sh)
|
|
local new_w = resize_start_w + (mx - resize_start_mx)
|
|
local new_h = resize_start_h + (my - resize_start_my)
|
|
local min_w = win.opts.min_size.w
|
|
local min_h = win.opts.min_size.h
|
|
local max_w = win.opts.max_size.w
|
|
local max_h = win.opts.max_size.h
|
|
new_w = math.max(min_w, math.min(max_w, new_w))
|
|
new_h = math.max(min_h, math.min(max_h, new_h))
|
|
win.bounds_override = {
|
|
x = current_b.x, y = current_b.y, w = new_w, h = new_h,
|
|
}
|
|
else
|
|
resizing_id = nil
|
|
resize_corner = nil
|
|
end
|
|
else
|
|
resizing_id = nil
|
|
resize_corner = nil
|
|
end
|
|
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. v0.3.0: iterates per-z-tier
|
|
--- (hud → normal → top) and within each tier walks open_order bottom-up
|
|
--- so HUD draws first (= background) and modal top tier draws last (=
|
|
--- topmost). Renders any open context-menu on top of all windows. No-op
|
|
--- if nothing is open.
|
|
function M.render()
|
|
if #open_order == 0 then return end
|
|
local sw, sh = get_screen_size()
|
|
for _, tier_name in ipairs(Z_TIER_ORDER) do
|
|
for _, widget_id in ipairs(open_order) do
|
|
local w = windows[widget_id]
|
|
if w and w.open and w.opts.z_tier == tier_name then
|
|
render_one_window(widget_id, w, sw, sh)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Context-menu (rendered on top of all windows + tiers)
|
|
if ctx_menu then
|
|
render_context_menu()
|
|
end
|
|
end
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- Test backdoors (v0.1.0 + v0.2.0 additions)
|
|
-- -----------------------------------------------------------------------
|
|
|
|
--- 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
|
|
|
|
--- M._test_reset_all() — clears widgets + windows + open_order + ctx_menu.
|
|
--- Also clears v0.4.0 drag/resize state. 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
|
|
dragging_id = nil
|
|
drag_offset_x = 0
|
|
drag_offset_y = 0
|
|
resizing_id = nil
|
|
resize_corner = nil
|
|
resize_start_w = 0
|
|
resize_start_h = 0
|
|
resize_start_mx = 0
|
|
resize_start_my = 0
|
|
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. v0.4.0: returns bounds_override when set
|
|
--- (so drag/resize tests can observe the effective bounds).
|
|
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 get_bounds_for(w, 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
|
|
|
|
--- M._test_get_render_order() — returns array of widget_ids in the
|
|
--- order M.render() would iterate them (per-tier hud→normal→top, within
|
|
--- tier in open_order). v0.3.0 addition.
|
|
function M._test_get_render_order()
|
|
local out = {}
|
|
for _, tier_name in ipairs(Z_TIER_ORDER) do
|
|
for _, widget_id in ipairs(open_order) do
|
|
local w = windows[widget_id]
|
|
if w and w.open and w.opts.z_tier == tier_name then
|
|
out[#out + 1] = widget_id
|
|
end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
--- M._test_get_input_block(widget_id) — returns the resolved
|
|
--- input_block string for an open window, or nil. v0.3.0 addition.
|
|
function M._test_get_input_block(widget_id)
|
|
local w = windows[widget_id]
|
|
if not w then return nil end
|
|
return w.opts.input_block
|
|
end
|
|
|
|
--- M._test_get_persistent(widget_id) — returns the persistent flag for
|
|
--- a registered window, or nil if not registered. v0.3.0 addition.
|
|
function M._test_get_persistent(widget_id)
|
|
local w = windows[widget_id]
|
|
if not w then return nil end
|
|
return w.opts.persistent
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- v0.4.0 test backdoors (drag + resize simulation without engine.input)
|
|
-- =====================================================================
|
|
|
|
--- M._test_simulate_drag_start(widget_id, mx, my)
|
|
--- Forces drag-state as if a left-click at (mx, my) had landed in the
|
|
--- title-bar of widget_id. Bypasses bounds-hit and hit-test gates so
|
|
--- tests don't have to compute the title-bar y range. Loud-error if
|
|
--- the widget is not open. v0.4.0.
|
|
function M._test_simulate_drag_start(widget_id, mx, my)
|
|
local w = windows[widget_id]
|
|
if not w or not w.open then
|
|
error("panel._test_simulate_drag_start: widget '" .. tostring(widget_id)
|
|
.. "' is not open")
|
|
end
|
|
local sw, sh = get_screen_size()
|
|
local b = get_bounds_for(w, sw, sh)
|
|
dragging_id = widget_id
|
|
drag_offset_x = mx - b.x
|
|
drag_offset_y = my - b.y
|
|
M.focus(widget_id)
|
|
end
|
|
|
|
--- M._test_simulate_drag_move(mx, my)
|
|
--- Drives one drag-step at (mx, my) as if M.update saw the mouse there
|
|
--- with the left button still held. Performs the same clamp + bounds
|
|
--- math M.update does. No-op if no drag is in progress. v0.4.0.
|
|
function M._test_simulate_drag_move(mx, my)
|
|
if not dragging_id then return end
|
|
local win = windows[dragging_id]
|
|
if not win or not win.open then
|
|
dragging_id = nil
|
|
return
|
|
end
|
|
local sw, sh = get_screen_size()
|
|
local current_b = get_bounds_for(win, sw, sh)
|
|
local new_x = mx - drag_offset_x
|
|
local new_y = my - drag_offset_y
|
|
new_x = math.max(-current_b.w + MIN_VISIBLE_PX,
|
|
math.min(sw - MIN_VISIBLE_PX, new_x))
|
|
new_y = math.max(0, math.min(sh - MIN_VISIBLE_PX, new_y))
|
|
win.bounds_override = {
|
|
x = new_x, y = new_y, w = current_b.w, h = current_b.h,
|
|
}
|
|
end
|
|
|
|
--- M._test_simulate_drag_end()
|
|
--- Releases the drag (as if the mouse button went up). v0.4.0.
|
|
function M._test_simulate_drag_end()
|
|
dragging_id = nil
|
|
end
|
|
|
|
--- M._test_simulate_resize_start(widget_id, mx, my)
|
|
--- Forces resize-state as if a left-click at (mx, my) had landed in the
|
|
--- SE-corner of widget_id. Loud-error if the widget is not open. v0.4.0.
|
|
function M._test_simulate_resize_start(widget_id, mx, my)
|
|
local w = windows[widget_id]
|
|
if not w or not w.open then
|
|
error("panel._test_simulate_resize_start: widget '" .. tostring(widget_id)
|
|
.. "' is not open")
|
|
end
|
|
local sw, sh = get_screen_size()
|
|
local b = get_bounds_for(w, sw, sh)
|
|
resizing_id = widget_id
|
|
resize_corner = "SE"
|
|
resize_start_w = b.w
|
|
resize_start_h = b.h
|
|
resize_start_mx = mx
|
|
resize_start_my = my
|
|
M.focus(widget_id)
|
|
end
|
|
|
|
--- M._test_simulate_resize_move(mx, my)
|
|
--- Drives one resize-step at (mx, my) with min/max-size clamping. v0.4.0.
|
|
function M._test_simulate_resize_move(mx, my)
|
|
if not resizing_id then return end
|
|
local win = windows[resizing_id]
|
|
if not win or not win.open then
|
|
resizing_id = nil
|
|
resize_corner = nil
|
|
return
|
|
end
|
|
local sw, sh = get_screen_size()
|
|
local current_b = get_bounds_for(win, sw, sh)
|
|
local new_w = resize_start_w + (mx - resize_start_mx)
|
|
local new_h = resize_start_h + (my - resize_start_my)
|
|
local min_w = win.opts.min_size.w
|
|
local min_h = win.opts.min_size.h
|
|
local max_w = win.opts.max_size.w
|
|
local max_h = win.opts.max_size.h
|
|
new_w = math.max(min_w, math.min(max_w, new_w))
|
|
new_h = math.max(min_h, math.min(max_h, new_h))
|
|
win.bounds_override = {
|
|
x = current_b.x, y = current_b.y, w = new_w, h = new_h,
|
|
}
|
|
end
|
|
|
|
--- M._test_simulate_resize_end() — releases the resize. v0.4.0.
|
|
function M._test_simulate_resize_end()
|
|
resizing_id = nil
|
|
resize_corner = nil
|
|
end
|
|
|
|
--- M._test_get_dragging_id() — currently dragged widget_id, or nil. v0.4.0.
|
|
function M._test_get_dragging_id()
|
|
return dragging_id
|
|
end
|
|
|
|
--- M._test_get_resizing_id() — currently resized widget_id, or nil. v0.4.0.
|
|
function M._test_get_resizing_id()
|
|
return resizing_id
|
|
end
|
|
|
|
return M
|