From 64aa1c8777c11e1feae9bbdfcf64e79518d42323 Mon Sep 17 00:00:00 2001 From: Calic Date: Sat, 13 Jun 2026 23:09:43 +0200 Subject: [PATCH] =?UTF-8?q?initial:=20panel=20v0.1.0=20=E2=80=94=20generic?= =?UTF-8?q?=20panel=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 24 +++ README.md | 305 +++++++++++++++++++++++++++++ init.lua | 528 +++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.lib | 1 + 4 files changed, 858 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 init.lua create mode 100644 manifest.lib diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2026 Calic. All rights reserved. + +This software is part of the Sporel platform — **Tier 1 (Official / +Proprietary)** content per the Three-Tier Licensing Model documented in +`meta/docs/archive/design/vision.md §Licensing Model` (current source; +migration to `meta/docs/architecture/licensing-model.md` pending). + +⚠ **WIP — Legal review required before public launch.** The terms below +reflect design intent only; the formalized license framework will be +finalized through legal counsel before the first public release. Until +then, this notice serves as a placeholder defending the platform owner's +rights against unintentional re-licensing. + +No license is granted to copy, modify, distribute, sublicense, or otherwise +use this software in any form without prior written permission from the +copyright holder. + +References: +- Tier 1 (this file): all rights reserved, proprietary, sold/distributed + via official channels (Steam, etc.) +- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue- + share — see vision.md §Licensing Model +- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies + to community-uploaded libs/modules/assets, not this repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..90e94e9 --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +# lib-core.panel + +Generic overlay-panel framework. Manages a single active widget at a time, +handles input dispatch (mouse click + wheel, edge-detected), renders a +titled panel overlay, and supports a context-menu layer. Designed as the +glue layer between game modules and the engine render/input surfaces. + +**Version:** 0.1.0 +**Lib-ID:** lib-core.panel +**Requires:** engine.render.*, engine.input.*, lib-core.input (lazy, for default-trigger) +**Tags:** panel, overlay, ui, input, context-menu + +## Topology + + +```mermaid +graph LR + this["lib-core.panel"] + engine_render["engine.render.*"] + engine_input["engine.input.*"] + lib_core_input["lib-core.input"] + this --> engine_render + this --> engine_input + this -.->|lazy| lib_core_input +``` + + +## Scope (v0.1.0) + +v0.1 ships a minimal single-active-widget panel system: + +- Widget registry (register / unregister / open / close / toggle) +- Theme system (13 configurable keys, capability-by-declaration guard) +- Per-frame update with edge-detected mouse click + wheel dispatch +- Context-menu (show, auto-reposition to screen bounds, hit-test, auto-close) +- Default-trigger key binding via lib-core.input (lazy-required) +- pause_on_open flag for game-pause gating + +**Intentional non-goals (deferred):** +- Multi-widget z-order / stacking panels +- Always-on HUD widgets (non-modal overlays) +- Keyboard navigation within widgets +- Panel animation (fade in / out) +- Screen-size from engine (no Lua-accessible get_screen_size; v0.1 falls back to 1280x720 constants matching default Sporel window config) + +## API + +### `panel.register(widget_id, widget_def)` + +**Syntax:** `panel.register(widget_id: string, widget_def: table) -> void` + +**Example:** +```lua +panel.register("inventory", { + title = "Inventory", + pause_on_open = true, + render = function(ctx) + -- ctx.bounds = {x, y, w, h}; ctx.theme; ctx.is_focused + engine.render.draw_text("(empty)", ctx.bounds.x, ctx.bounds.y, + ctx.theme.font_size_body, ctx.theme.text_color) + end, + handle_input = function(ctx, event) + -- event.kind = "click"|"wheel" + -- event.x, event.y, event.button (click only) + -- event.dy (wheel only) + end, +}) +``` + +Registers a widget in the panel registry. `widget_def.render` and +`widget_def.handle_input` must be functions; `widget_def.title` must be a +string. Loud-error on duplicate `widget_id` or missing required fields. + +--- + +### `panel.unregister(widget_id)` + +**Syntax:** `panel.unregister(widget_id: string) -> void` + +Removes the widget from the registry. If the widget is currently active, +closes the panel (sets active to nil, clears any open context-menu). + +--- + +### `panel.open(widget_id)` + +**Syntax:** `panel.open(widget_id: string) -> void` + +Sets `widget_id` as the active widget. Loud-error if `widget_id` has not +been registered. + +--- + +### `panel.close()` + +**Syntax:** `panel.close() -> void` + +Closes the active widget and clears any open context-menu. + +--- + +### `panel.toggle(widget_id)` + +**Syntax:** `panel.toggle(widget_id: string) -> void` + +If `widget_id` is currently active, closes it. Otherwise opens it. Handy +for key-binding toggle semantics without manual state tracking. + +--- + +### `panel.is_open()` + +**Syntax:** `panel.is_open() -> bool` + +Returns `true` if any widget is currently active. + +--- + +### `panel.is_pausing()` + +**Syntax:** `panel.is_pausing() -> bool` + +Returns `true` if the active widget has `pause_on_open = true`. Modules +can use this to gate their update loop (skip physics/AI while panel is +open). + +--- + +### `panel.set_theme(overrides)` + +**Syntax:** `panel.set_theme(overrides: table) -> void` + +**Example:** +```lua +panel.set_theme({ + bg_color = 0x101010F8, + padding = 12, + font_size_body = 16, +}) +``` + +Merges `overrides` into the active theme. Only keys declared in +`DEFAULT_THEME` are accepted (capability-by-declaration). Loud-error on +any unknown key with message `"panel.set_theme: unknown theme key ''"`. + +--- + +### `panel.get_theme()` + +**Syntax:** `panel.get_theme() -> table` + +Returns a shallow copy of the current merged theme. Safe to store; does +not alias internal state. + +--- + +### `panel.bind_default_trigger(key, widget_id)` + +**Syntax:** `panel.bind_default_trigger(key: string|nil, widget_id: string) -> void` + +**Example:** +```lua +panel.register("inventory", { ... }) +panel.bind_default_trigger("tab", "inventory") +-- or: panel.bind_default_trigger(nil, "inventory") -- defaults to "tab" +``` + +Binds a keyboard key as the default toggle trigger for `widget_id`. `key` +defaults to `"tab"` when `nil`. Lazy-requires `lib-core.input` to avoid +module-load-time cycles. Loud-error if `widget_id` is not registered. + +--- + +### `panel.update(dt)` + +**Syntax:** `panel.update(dt: number) -> void` + +Must be called each game-update frame. Checks the default trigger key, +reads mouse state, emits edge-detected click events (left and right +independently), and dispatches wheel events. No-ops if no widget is active +(but still tracks mouse state to avoid spurious edges on next open). + +--- + +### `panel.render()` + +**Syntax:** `panel.render() -> void` + +Must be called each render frame (inside the engine render phase). Draws +the panel background, border, and title, then invokes `widget_def.render(ctx)`. +If a context-menu is open, renders it on top. No-op if no widget is active. + +--- + +### `panel.show_context_menu(x, y, actions)` + +**Syntax:** `panel.show_context_menu(x: number, y: number, actions: table) -> void` + +**Example:** +```lua +panel.show_context_menu(event.x, event.y, { + { label = "Use", callback = function(info) use_item() end }, + { label = "Drop", callback = function(info) drop_item() end }, +}) +``` + +Pops up a context-menu at `(x, y)`. `actions` must be a non-empty array of +`{label: string, callback: function}` tables. Auto-repositions to remain +within the screen boundary. The callback receives `{close_menu: function}` +(which is a no-op in v0.1 since the menu closes itself before invoking the +callback). Click outside any row auto-closes the menu. + +--- + +### `panel._dispatch_event_for_test(event)` — for tests only + +**Syntax:** `panel._dispatch_event_for_test(event: table) -> void` + +Direct passthrough to the internal `_dispatch_event` function. Allows +test-modules to simulate input events without a running game loop (since +`M.update` is not exercised headless). + +## Theme Schema + +| Key | Default | Description | +|-----|---------|-------------| +| `bg_color` | `0x202020F0` | Panel background (RGBA packed) | +| `border_color` | `0x808080FF` | Panel border | +| `text_color` | `0xE0E0E0FF` | Primary text | +| `text_color_dim` | `0xA0A0A0FF` | Dimmed / secondary text | +| `selection_color` | `0x404080FF` | Row / item selection highlight | +| `context_menu_bg` | `0x303030F8` | Context-menu background | +| `context_menu_hover` | `0x404060FF` | Context-menu row hover | +| `font_size_title` | `18` | Title bar font size (px) | +| `font_size_body` | `14` | Content font size (px) | +| `padding` | `8` | Inner padding (px) | +| `row_height` | `24` | Row height for lists / menus (px) | +| `panel_width_frac` | `0.5` | Panel width as fraction of screen | +| `panel_height_frac` | `0.7` | Panel height as fraction of screen | + +## Widget-Lifecycle-Contract + +Widget `render(ctx)` is called each render frame while the widget is active. +Widget `handle_input(ctx, event)` is called for each dispatched input event. + +Both receive a `ctx` table: + +```lua +ctx = { + bounds = { + x = number, -- content area top-left x + y = number, -- content area top-left y + w = number, -- content area width + h = number, -- content area height + }, + theme = table, -- current merged theme (read-only by convention) + is_focused = bool, -- true when this widget is the active one +} +``` + +The `event` table passed to `handle_input`: + +```lua +-- Mouse click: +event = { kind = "click", x = number, y = number, button = "left"|"right" } + +-- Mouse wheel: +event = { kind = "wheel", dy = number } -- dy > 0 = scroll up, dy < 0 = scroll down +``` + +Context-menu intercepts clicks before they reach `handle_input`. The widget +calls `panel.show_context_menu(x, y, actions)` from within `handle_input` +when a right-click (or any application-specific trigger) warrants a menu. + +## Glue-Pattern + +Minimal module setup (copy-paste-able): + +```lua +local panel = require("lib-core.panel") + +-- 1. Register your widget once (e.g. in module init or M.load) +panel.register("inventory", { + title = "Inventory", + pause_on_open = true, + render = function(ctx) inventory_ui.draw(ctx) end, + handle_input = function(ctx, event) inventory_ui.on_input(ctx, event) end, +}) + +-- 2. Bind a toggle key (optional; defaults to Tab) +panel.bind_default_trigger("i", "inventory") + +-- 3. Wire into your module's update + render +function M.update(dt) + panel.update(dt) + if not panel.is_pausing() then + -- normal game logic here + end +end + +function M.render() + -- ... draw world ... + panel.render() -- draws panel overlay on top +end +``` diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..de34e2f --- /dev/null +++ b/init.lua @@ -0,0 +1,528 @@ +-- ===================================================================== +-- lib-core.panel v0.1.0 — Generic overlay-panel framework +-- +-- Provides a single-active-widget panel system with: +-- - Widget registry + lifecycle (register, open, close, toggle) +-- - Centrally-rendered overlay with configurable theme +-- - Per-frame input dispatch (mouse click + wheel, edge-detected) +-- - Context-menu (show, hit-test, auto-close) +-- - Default-trigger binding via lib-core.input (lazy-required) +-- - Optional pause-gate (pause_on_open=true widget makes is_pausing()=true) +-- +-- Engine surfaces used: +-- - engine.input.get_mouse_pos() → x, y +-- - engine.input.is_mouse_down(button) → bool +-- - engine.input.get_mouse_wheel() → number +-- - engine.input.MOUSE_LEFT / MOUSE_RIGHT +-- - engine.render.draw_rect(x, y, w, h, color) +-- - engine.render.draw_rect_lines(x, y, w, h, color, thickness) +-- - engine.render.draw_text(text, x, y, font_size, color) +-- - engine.render.measure_text(text, font_size) → w, h +-- +-- Screen-size limitation: engine.render does NOT expose get_screen_size() +-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.1 falls back to +-- 1280x720 constants matching the default Sporel window config. +-- +-- DEFERRED (v0.1 non-goals): +-- - Multi-widget z-order / stacking +-- - Always-on HUD widgets (non-modal overlays) +-- - Keyboard navigation within widgets +-- - Panel animation (fade in/out) +-- ===================================================================== + +-- ----------------------------------------------------------------------- +-- Module-level state (all local — no globals) +-- ----------------------------------------------------------------------- + +local widgets = {} -- widget_id (string) → widget_def table +local active = nil -- active widget_id or nil +local theme = {} -- merged DEFAULT_THEME + overrides +local ctx_menu = nil -- context-menu state table or nil +local trigger_action_name = nil -- input action name for default trigger +local trigger_widget_id = nil -- which widget_id to toggle on trigger +local last_mouse_left = false -- for edge-detection (was down last frame) +local last_mouse_right = false -- for edge-detection (was down last frame) + +-- ----------------------------------------------------------------------- +-- Theme +-- ----------------------------------------------------------------------- + +-- 13 canonical keys. DO NOT add keys here without updating set_theme validation. +local DEFAULT_THEME = { + bg_color = 0x202020F0, + border_color = 0x808080FF, + text_color = 0xE0E0E0FF, + text_color_dim = 0xA0A0A0FF, + selection_color = 0x404080FF, + context_menu_bg = 0x303030F8, + context_menu_hover = 0x404060FF, + font_size_title = 18, + font_size_body = 14, + padding = 8, + row_height = 24, + panel_width_frac = 0.5, + panel_height_frac = 0.7, +} + +-- Init theme from DEFAULT_THEME at module load +for k, v in pairs(DEFAULT_THEME) do + theme[k] = v +end + +-- ----------------------------------------------------------------------- +-- Fallback screen constants (no engine.render.get_screen_size in Lua API) +-- ----------------------------------------------------------------------- +local FALLBACK_SCREEN_W = 1280 +local FALLBACK_SCREEN_H = 720 + +-- ----------------------------------------------------------------------- +-- Internal helpers +-- ----------------------------------------------------------------------- + +--- Retrieve the current screen dimensions. Falls back to constants because +--- engine.render does not expose a Lua-callable screen-size function. +local function get_screen_size() + return FALLBACK_SCREEN_W, FALLBACK_SCREEN_H +end + +--- Estimate menu width from action labels (rough; render can refine via +--- measure_text, but context-menu render in v0.1 uses this pre-computed w). +local function estimate_menu_width(actions, padding) + local max_w = 0 + for _, action in ipairs(actions) do + local chars = #(action.label or "") + -- 8 px/char is a rough estimate for the default font; refine via + -- engine.render.measure_text in render-phase (v0.2 deferral). + local w = chars * 8 + 2 * padding + if w > max_w then max_w = w end + end + return max_w +end + +-- ----------------------------------------------------------------------- +-- Internal: context-menu dispatch + render helpers +-- ----------------------------------------------------------------------- + +--- Build per-row hit-rects on ctx_menu and store as ctx_menu.row_rects. +--- Called before dispatch and before render to keep both in sync. +local function build_ctx_menu_row_rects() + if not ctx_menu then return end + local rects = {} + for i, _ in ipairs(ctx_menu.actions) do + local row_y = ctx_menu.y + (i - 1) * theme.row_height + rects[i] = { + x = ctx_menu.x, + y = row_y, + w = ctx_menu.w, + h = theme.row_height, + } + end + ctx_menu.row_rects = rects +end + +--- Returns current mouse position safely (0,0 if not in game context). +local function safe_mouse_pos() + if engine and engine.input and engine.input.get_mouse_pos then + return engine.input.get_mouse_pos() + end + return 0, 0 +end + +-- ----------------------------------------------------------------------- +-- Internal: event dispatch +-- ----------------------------------------------------------------------- + +--- _dispatch_event(event): route a synthetic or real input event. +--- event = {kind="click", x, y, button="left"|"right"} or {kind="wheel", dy} +local function _dispatch_event(event) + -- Context-menu intercept: any click is consumed by the menu. + if ctx_menu and event.kind == "click" then + -- Hit-test rows + build_ctx_menu_row_rects() + local hit = false + for i, rect in ipairs(ctx_menu.row_rects) do + if event.x >= rect.x and event.x < rect.x + rect.w + and event.y >= rect.y and event.y < rect.y + rect.h then + hit = true + local action = ctx_menu.actions[i] + ctx_menu = nil -- close menu before callback (re-entrant safety) + if action and action.callback then + action.callback({ close_menu = function() ctx_menu = nil end }) + end + break + end + end + if not hit then + -- Click outside context-menu area → auto-close + ctx_menu = nil + end + return + end + + -- Normal dispatch to active widget + if not active or not widgets[active] then return end + + local screen_w, screen_h = get_screen_size() + local panel_w = screen_w * theme.panel_width_frac + local panel_h = screen_h * theme.panel_height_frac + local panel_x = (screen_w - panel_w) / 2 + local panel_y = (screen_h - panel_h) / 2 + local padding = theme.padding + local title_area_h = theme.font_size_title + padding * 2 + + local content_bounds = { + x = panel_x + padding, + y = panel_y + title_area_h, + w = panel_w - padding * 2, + h = panel_h - title_area_h - padding, + } + + local widget_ctx = { + bounds = content_bounds, + theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state + is_focused = true, + } + + widgets[active].handle_input(widget_ctx, event) +end + +-- ----------------------------------------------------------------------- +-- Public module table +-- ----------------------------------------------------------------------- + +local M = {} + +-- ----------------------------------------------------------------------- +-- Registry / Lifecycle +-- ----------------------------------------------------------------------- + +--- M.register(widget_id, widget_def) +--- Registers a new widget. widget_def must have: +--- .render(ctx) — function, called each render frame when widget is active +--- .handle_input(ctx, event) — function, called for each input event +--- .title — string, displayed in the panel title bar +--- Optional: .pause_on_open = true — makes is_pausing() return true while open. +function M.register(widget_id, widget_def) + if type(widget_id) ~= "string" then + error("panel.register: widget_id must be a string, got " .. type(widget_id)) + end + if type(widget_def) ~= "table" then + error("panel.register: widget_def must be a table, got " .. type(widget_def)) + end + if type(widget_def.render) ~= "function" then + error("panel.register: widget_def.render must be a function") + end + if type(widget_def.handle_input) ~= "function" then + error("panel.register: widget_def.handle_input must be a function") + end + if type(widget_def.title) ~= "string" then + error("panel.register: widget_def.title must be a string") + end + if widgets[widget_id] ~= nil then + error("panel.register: duplicate widget_id '" .. widget_id .. "'") + end + widgets[widget_id] = widget_def +end + +--- M.unregister(widget_id) +--- Removes a widget from the registry. If the widget is currently active, +--- closes the panel first (clears active + ctx_menu). +function M.unregister(widget_id) + if active == widget_id then + active = nil + ctx_menu = nil + end + widgets[widget_id] = nil +end + +--- M.open(widget_id) +--- Sets widget_id as the active (displayed) widget. Loud-error if not registered. +function M.open(widget_id) + if widgets[widget_id] == nil then + error("panel.open: unknown widget_id '" .. tostring(widget_id) .. "' (register before open)") + end + active = widget_id +end + +--- M.close() +--- Closes the active panel and clears any open context-menu. +function M.close() + active = nil + ctx_menu = nil +end + +--- M.toggle(widget_id) +--- If widget_id is currently active, closes it. Otherwise opens it. +function M.toggle(widget_id) + if active == widget_id then + M.close() + else + M.open(widget_id) + end +end + +--- M.is_open() → bool +--- Returns true if any widget is currently active. +function M.is_open() + return active ~= nil +end + +--- M.is_pausing() → bool +--- Returns true if the active widget has pause_on_open=true. +function M.is_pausing() + return active ~= nil and widgets[active] ~= nil + and widgets[active].pause_on_open == true +end + +-- ----------------------------------------------------------------------- +-- Theme +-- ----------------------------------------------------------------------- + +--- M.set_theme(overrides) +--- Merges overrides into the active theme. Only keys declared in DEFAULT_THEME +--- are accepted; unknown keys loud-error with "unknown theme key ''". +function M.set_theme(overrides) + if type(overrides) ~= "table" then + error("panel.set_theme: expected table, got " .. type(overrides)) + end + for k, v in pairs(overrides) do + if DEFAULT_THEME[k] == nil then + error("panel.set_theme: unknown theme key '" .. tostring(k) .. "'") + end + theme[k] = v + end +end + +--- M.get_theme() → table +--- Returns a shallow copy of the current theme. +function M.get_theme() + local copy = {} + for k, v in pairs(theme) do + copy[k] = v + end + return copy +end + +-- ----------------------------------------------------------------------- +-- Default-trigger binding +-- ----------------------------------------------------------------------- + +--- M.bind_default_trigger(key, widget_id) +--- Binds a keyboard key as the default toggle trigger for a widget. +--- key defaults to "tab" when nil. Lazy-requires lib-core.input to avoid +--- module-load-time circular dependency. +--- Loud-error if widget_id is not registered ("register before bind"). +function M.bind_default_trigger(key, widget_id) + if key == nil then key = "tab" end + if widgets[widget_id] == nil then + error("panel.bind_default_trigger: unknown widget_id '" .. tostring(widget_id) + .. "' (register before bind)") + end + local input = require("lib-core.input") -- lazy require: avoid load-time cycle + input.bind("panel_toggle", {key}) + trigger_action_name = "panel_toggle" + trigger_widget_id = widget_id +end + +-- ----------------------------------------------------------------------- +-- Context-menu +-- ----------------------------------------------------------------------- + +--- M.show_context_menu(x, y, actions) +--- Pops up a context menu at (x, y) with the given actions array. +--- actions = array of {label=string, callback=function} entries. +--- Auto-repositions to stay within screen bounds. +function M.show_context_menu(x, y, actions) + if type(actions) ~= "table" or #actions == 0 then + error("panel.show_context_menu: actions must be a non-empty array of {label, callback} tables") + end + for i, action in ipairs(actions) do + if type(action) ~= "table" then + error("panel.show_context_menu: action #" .. i .. " must be a table") + end + if type(action.label) ~= "string" then + error("panel.show_context_menu: action #" .. i .. " must have a string label") + end + if type(action.callback) ~= "function" then + error("panel.show_context_menu: action #" .. i .. " must have a callback function") + end + end + + local padding = theme.padding + local row_h = theme.row_height + local menu_w = estimate_menu_width(actions, padding) + local menu_h = #actions * row_h + + local screen_w, screen_h = get_screen_size() + + -- Auto-reposition: clamp so menu stays on screen + if x + menu_w > screen_w then x = screen_w - menu_w end + if x < 0 then x = 0 end + if y + menu_h > screen_h then y = screen_h - menu_h end + if y < 0 then y = 0 end + + ctx_menu = { + x = x, + y = y, + w = menu_w, + h = menu_h, + actions = actions, + row_rects = {}, -- populated by build_ctx_menu_row_rects() + } + build_ctx_menu_row_rects() +end + +-- ----------------------------------------------------------------------- +-- Per-frame update + input dispatch +-- ----------------------------------------------------------------------- + +--- M.update(dt) +--- Must be called each game-update frame. Handles: +--- - Default-trigger key check (toggling bound widget) +--- - Mouse edge-detection for click events (dispatches on press, not hold) +--- - Mouse-wheel event dispatch +function M.update(dt) + -- Check default trigger (lazy requires lib-core.input internally if bound) + if trigger_action_name then + local input = require("lib-core.input") + if input.was_action_pressed(trigger_action_name) then + M.toggle(trigger_widget_id) + end + end + + if not active then + -- Still need to update last_mouse_* state even when closed so that + -- opening mid-frame doesn't produce a spurious edge on the next frame. + if engine and engine.input then + last_mouse_left = engine.input.is_mouse_down(engine.input.MOUSE_LEFT) + last_mouse_right = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT) + end + return + end + + -- Read current mouse state + local mx, my = engine.input.get_mouse_pos() + local cur_left = engine.input.is_mouse_down(engine.input.MOUSE_LEFT) + local cur_right = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT) + + -- Edge-detect: pressed THIS frame (was up, now down) + local pressed_left = cur_left and not last_mouse_left + local pressed_right = cur_right and not last_mouse_right + + if pressed_left then + _dispatch_event({ kind = "click", x = mx, y = my, button = "left" }) + end + if pressed_right then + _dispatch_event({ kind = "click", x = mx, y = my, button = "right" }) + end + + -- Wheel dispatch + local wheel_amt = engine.input.get_mouse_wheel() + if wheel_amt ~= 0 then + _dispatch_event({ kind = "wheel", dy = wheel_amt }) + end + + -- Update last-state for next frame's edge-detection + last_mouse_left = cur_left + last_mouse_right = cur_right +end + +-- ----------------------------------------------------------------------- +-- Render +-- ----------------------------------------------------------------------- + +--- M.render() +--- Must be called each render frame. Draws the active widget's panel +--- (background, border, title bar) then invokes widget_def.render(ctx). +--- If a context-menu is open, renders it on top. +--- No-op if no widget is active. +function M.render() + if not active then return end + + local widget_def = widgets[active] + if not widget_def then return end + + -- Screen size: fallback to 1280x720 (engine.render has no Lua-accessible + -- get_screen_size; see module header for explanation) + local screen_w, screen_h = get_screen_size() + + local panel_w = screen_w * theme.panel_width_frac + local panel_h = screen_h * theme.panel_height_frac + local panel_x = (screen_w - panel_w) / 2 + local panel_y = (screen_h - panel_h) / 2 + local padding = theme.padding + + -- Background + engine.render.draw_rect(panel_x, panel_y, panel_w, panel_h, theme.bg_color) + + -- Border + engine.render.draw_rect_lines(panel_x, panel_y, panel_w, panel_h, + theme.border_color, 2) + + -- Title + local title = widget_def.title + engine.render.draw_text(title, + panel_x + padding, + panel_y + padding, + theme.font_size_title, + theme.text_color) + + -- Content area (below title bar) + local title_area_h = theme.font_size_title + padding * 2 + local content_bounds = { + x = panel_x + padding, + y = panel_y + title_area_h, + w = panel_w - padding * 2, + h = panel_h - title_area_h - padding, + } + + local widget_ctx = { + bounds = content_bounds, + theme = M.get_theme(), -- shallow copy; widget cannot mutate panel state + is_focused = true, + } + + widget_def.render(widget_ctx) + + -- Context-menu (rendered on top of widget content) + if ctx_menu then + local mx, my = safe_mouse_pos() + -- Menu background + engine.render.draw_rect(ctx_menu.x, ctx_menu.y, + ctx_menu.w, ctx_menu.h, + theme.context_menu_bg) + + build_ctx_menu_row_rects() + for i, action in ipairs(ctx_menu.actions) do + local rect = ctx_menu.row_rects[i] + local row_bg = theme.context_menu_bg + + -- Hover highlight: mouse y within this row + if my >= rect.y and my < rect.y + rect.h + and mx >= rect.x and mx < rect.x + rect.w then + row_bg = theme.context_menu_hover + engine.render.draw_rect(rect.x, rect.y, rect.w, rect.h, row_bg) + end + + -- Label + engine.render.draw_text(action.label, + rect.x + padding, + rect.y + (theme.row_height - theme.font_size_body) / 2, + theme.font_size_body, + theme.text_color) + end + end +end + +-- ----------------------------------------------------------------------- +-- Test backdoor +-- ----------------------------------------------------------------------- + +--- M._dispatch_event_for_test(event) — for tests only. +--- Direct passthrough to the internal _dispatch_event function so that +--- panel-test can simulate input events without a running game loop. +function M._dispatch_event_for_test(event) + _dispatch_event(event) +end + +return M diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..596dd7e --- /dev/null +++ b/manifest.lib @@ -0,0 +1 @@ +{"id":"lib-core.panel","version":"0.1.0","api_min":"0.1","deps":[]}