feat: v0.4.0 Focus + Drag + Resize

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>
This commit is contained in:
Calic
2026-06-15 03:04:38 +02:00
parent 89cd1727f6
commit 71a585b459
3 changed files with 593 additions and 54 deletions

180
README.md
View File

@@ -1,11 +1,12 @@
# lib-core.panel
Generic overlay-panel framework. v0.3.0 layers **z-tiers** (hud / normal
/ top), **persistent windows**, and **input-block routing** (none / self
/ all) on top of v0.2.0's multi-active model with 11 named layout-slot
templates + custom-fn hook. Render iterates per-tier (hud → normal →
top); input dispatch iterates reverse (top → hud, within-tier reverse
open-order); modal `input_block="all"` swallows misses.
Generic overlay-panel framework. v0.4.0 adds the **focus API** (`focus`
/ `raise` / `lower` / `get_focused`), **click-to-focus** auto-routing,
**drag-by-title-bar** (`draggable=true`), and **resize-by-SE-corner**
(`resizable=true`) on top of v0.3.0's z-tier + persistent + input-block
model. Drag and resize move bounds outside the layout-fn via a
window-record `bounds_override`; min/max_size constraints and a
32px-of-title-bar screen-clamp keep windows recoverable.
The single-active model from v0.1.1 is preserved as a backward-
compatibility shim — `panel.open(id)`, `panel.close()`, `panel.is_open()`
@@ -15,10 +16,10 @@ without an id-arg keep their old semantics. v0.3.0 additionally makes
toolbars/HUDs); use `panel.close(id)` for explicit modder-controlled
close.
**Version:** 0.3.0
**Version:** 0.4.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, window-manager
**Tags:** panel, overlay, ui, input, context-menu, window-manager, focus, drag, resize
## Topology
@@ -35,16 +36,36 @@ graph LR
```
<!-- topology:end -->
## Scope (v0.3.0)
## Scope (v0.4.0)
v0.3.0 layers z-tiers + persistent + input-block on top of v0.2.0's
multi-active model, fully backward-compatible with v0.2.0 and v0.1.1
callers. The lib is the generic UI-Framework substrate — domain-free
per ADR-0001/ADR-0049, consumed by Display-Libs (inventory-list-
display, crafting-display, notify-display) and modules (vagrant-
skeleton, map-editor).
v0.4.0 adds focus API + click-to-focus + drag + resize on top of v0.3.0,
fully backward-compatible with v0.3.0, v0.2.0, and v0.1.1 callers. The
lib is the generic UI-Framework substrate — domain-free per ADR-0001/
ADR-0049, consumed by Display-Libs (inventory-list-display, crafting-
display, notify-display) and modules (vagrant-skeleton, map-editor).
### Supported (v0.3.0)
### Supported (v0.4.0 — new)
- **Focus API**: `panel.focus(id)` / `panel.raise(id)` (alias) bring a
widget to the top within open_order; `panel.lower(id)` moves it to
the bottom; `panel.get_focused()` returns the currently-focused id
(top-tier priority, else last-opened). hud-tier widgets are never
promoted by `focus()` (they are cosmetic overlays by design).
- **Click-to-focus**: a click that hits a non-hud window auto-routes
through `focus()` before forwarding to the widget's handler.
- **Drag**: `opts.draggable=true` makes left-click in the title-bar
area start a drag. Drag updates a per-window `bounds_override` that
shadows the layout-fn. Screen-clamp keeps at least 32px of the
title-bar reachable. Chromeless widgets are excluded (no defined
drag-handle).
- **Resize**: `opts.resizable=true` renders a 12x12 SE-corner handle
and starts a resize on left-click. Resize clamps to `opts.min_size`
(default `{w=120, h=80}`) and `opts.max_size` (default
`{w=99999, h=99999}`).
- `bounds_override` survives the layout-fn until the modder closes the
window or the test backdoor `_test_reset_all()` is called.
### Supported (v0.3.0 — unchanged)
- **Z-tiers**: render order hud → normal → top. Input dispatch reverse
(top → hud, within-tier reverse open-order).
@@ -59,9 +80,7 @@ skeleton, map-editor).
hud→none, normal→self, top→all.
- **Public `panel.point_in_any_panel(x, y)`**: helper for module-side
canvas-click logic — returns true if (x,y) falls within any open
panel's bounds. Useful for modules that read input directly via
`engine.input.*` and want to skip canvas-paint when the click landed
inside a panel widget.
panel's bounds. v0.4.0: respects `bounds_override` from drag/resize.
- Multi-active panels: multiple windows open simultaneously (unchanged
from v0.2.0).
- 11 layout-slot templates + custom-fn hook (unchanged).
@@ -72,15 +91,9 @@ skeleton, map-editor).
- Context-menu via `show_context_menu` (unchanged).
- Default-trigger key-binding via `bind_default_trigger` (unchanged).
### Deferred (v0.4.0+)
- Click-to-focus and explicit focus()/raise()/lower() API.
- Drag-by-title-bar (draggable opt).
- Resize-by-corner (resizable opt).
- min/max-size constraints + screen-clamp.
### Deferred (post-v0.4.0)
- Resize corners other than SE (NE/SW/NW + edge-resize).
- Window-decoration themes (per-window title-bar styles).
- Touch/mobile input adaptation.
- Window animations (slide-in, fade-in).
@@ -109,6 +122,16 @@ skeleton, map-editor).
the FULL panel bounds as `ctx.bounds` (no title-bar inset) and must
render its own background/border. Intended for persistent HUD widgets
(toolbars, layer-pickers, palettes) that have their own visual style.
- `draggable``bool` (default `false`, v0.4.0+). If true, a left-click
in the title-bar area starts a drag-by-mouse. Ignored on chromeless
widgets (no defined drag-handle).
- `resizable``bool` (default `false`, v0.4.0+). If true, the window
renders a 12x12 SE-corner handle that left-click starts a resize.
Works on chromeless widgets too.
- `min_size``{w=number, h=number}` (default `{w=120, h=80}`, v0.4.0+).
Minimum window dimensions when resizing.
- `max_size``{w=number, h=number}` (default `{w=99999, h=99999}`,
v0.4.0+). Maximum window dimensions when resizing.
**Example:**
```lua
@@ -548,3 +571,108 @@ function M.render()
panel.render() -- draws panel overlay on top
end
```
## v0.4.0 — Focus + Drag + Resize
### Focus API
```lua
panel.focus(widget_id) -- bring to top within open_order (no-op for hud)
panel.raise(widget_id) -- alias for focus(); reads as "raise to top"
panel.lower(widget_id) -- move to bottom of open_order
panel.get_focused() -- returns focused widget_id, or nil
```
`focus(id)` removes `id` from `open_order` and re-appends at the top.
**Hud-tier widgets are never focusable**`focus()` and click-to-focus
both no-op for them, since hud is by design a cosmetic overlay tier.
`lower(id)` works for all tiers (lowering within tier is a layering
operation, not focus).
`get_focused()` returns the topmost-tier last-opened widget: if any
top-tier window is open it wins (regardless of normal-/hud-tier windows
later in `open_order`), otherwise it returns the simple last-opened id.
Returns `nil` if no windows are open.
### Click-to-Focus
A left-click that hits a non-hud window's bounds auto-focuses that
widget before forwarding to its handler. This is the standard window-
manager behavior modders expect from desktop UI. The auto-focus call
goes through `M.focus`, so hud-tier widgets are naturally excluded.
### Drag
```lua
panel.register("toolbox", widget, {
layout = function(_sw, _sh) return {x=100, y=100, w=240, h=300} end,
draggable = true,
})
```
Left-click in the **title-bar area** (the top `font_size_title + 2 *
padding` pixels of the window) of a `draggable=true` window starts a
drag. The window's `bounds_override` is updated each frame to follow
the mouse, with a **32-pixel screen-clamp** so the title-bar always
remains reachable on at least one edge — drag can push the window
mostly off-screen but cannot lose it entirely.
Chromeless widgets are excluded from drag-detection (their title-bar
is invisible — there is no defined drag-handle). If a modder sets both
`chromeless = true` and `draggable = true`, the chromeless guard wins;
clicks pass through to the widget's handler unchanged.
### Resize
```lua
panel.register("toolbox", widget, {
layout = function(_sw, _sh) return {x=100, y=100, w=240, h=300} end,
resizable = true,
min_size = { w = 180, h = 150 },
max_size = { w = 600, h = 480 },
})
```
`resizable = true` renders a **12x12 SE-corner handle** (filled rect in
`border_color`) and starts a resize on left-click within the handle.
The handle is drawn even on chromeless widgets — the modder opted in,
and the 12px square is the only visual cue. Resize updates the
window's `bounds_override` `w/h`; resize **does not move** the
top-left corner. Width and height clamp to `opts.min_size` and
`opts.max_size`.
v0.4.0 supports the **SE corner only**. NE/SW/NW + edge-resize are
deferred.
### Bounds-Override Pattern
When drag or resize first fires, the window's record gains a
`bounds_override = {x, y, w, h}` field. From that point on, the helper
`get_bounds_for(win, sw, sh)` returns `bounds_override` instead of
calling `resolve_bounds(opts.layout, sw, sh)`. All render + dispatch +
`point_in_any_panel` callsites use `get_bounds_for`, so the override
is consistent across the API.
`bounds_override` survives until:
- The widget is closed via `panel.close(id)` (cleared).
- The widget is unregistered.
- `panel._test_reset_all()` is called.
There is no public API to clear `bounds_override` — re-opening a closed
draggable widget gives it the layout-fn's bounds again. Persistent
windows that drag during a session keep their dragged position until
the next process start (no save/load yet — deferred post-v0.4.0).
### Test backdoors (v0.4.0 additions)
- `panel._test_simulate_drag_start(id, mx, my)` — force drag-state.
- `panel._test_simulate_drag_move(mx, my)` — drive one drag-step.
- `panel._test_simulate_drag_end()` — release the drag.
- `panel._test_simulate_resize_start(id, mx, my)` — force resize-state.
- `panel._test_simulate_resize_move(mx, my)` — drive one resize-step.
- `panel._test_simulate_resize_end()` — release the resize.
- `panel._test_get_dragging_id()` — currently dragged widget_id, or nil.
- `panel._test_get_resizing_id()` — currently resized widget_id, or nil.
The simulate-backdoors bypass `engine.input` polling so test-libs can
drive drag + resize without a running game loop.

457
init.lua
View File

@@ -1,12 +1,12 @@
-- =====================================================================
-- lib-core.panel v0.3.0 — Generic overlay-panel framework (Window-Manager)
-- lib-core.panel v0.4.0 — Generic overlay-panel framework (Window-Manager)
--
-- v0.3.0 layers z_tiers (hud → normal → top), persistent windows, and
-- input_block routing (none/self/all) on top of v0.2.0's multi-active +
-- layout-slot model. Render iterates per-z-tier (hud → normal → top);
-- input dispatch iterates reverse (top → hud, within-tier reverse-open-
-- order). `persistent=true` registers a window as open-from-register-time
-- and exempts it from bw-compat close() (modder must explicit-close).
-- 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)
@@ -15,6 +15,11 @@
-- - 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
@@ -32,14 +37,14 @@
-- - 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.3 still falls
-- to Lua (GetScreenWidth/GetScreenHeight are C-only). v0.4 still falls
-- back to 1280x720 constants matching the default Sporel window config.
--
-- DEFERRED (v0.3 non-goals):
-- - Focus API (focus / raise / lower) → v0.4
-- - Drag + resize → v0.4
-- 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
-- =====================================================================
-- -----------------------------------------------------------------------
@@ -65,6 +70,31 @@ 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
-- -----------------------------------------------------------------------
@@ -210,6 +240,16 @@ local function resolve_bounds(layout, sw, sh)
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
-- -----------------------------------------------------------------------
@@ -257,7 +297,7 @@ end
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 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
@@ -277,6 +317,16 @@ local function render_one_window(widget_id, win, sw, sh)
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
@@ -305,6 +355,17 @@ local function render_one_window(widget_id, win, sw, sh)
-- 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 = {
@@ -380,7 +441,7 @@ end
--- _dispatch_event(event): route a synthetic or real input event.
--- event = {kind="click", x, y, button="left"|"right"} or {kind="wheel", dy}
---
--- v0.3.0 routing:
--- 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:
@@ -388,9 +449,15 @@ end
--- 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 route to widget.
--- - chromeless=true suppresses the close-button hit-test (the
--- widget owns its full bounds, including the top-right area).
--- - 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).
@@ -417,7 +484,7 @@ local function _dispatch_event(event)
-- 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 = resolve_bounds(win.opts.layout, sw, sh)
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
@@ -425,11 +492,68 @@ local function _dispatch_event(event)
end
end
if win.opts.input_block ~= "none" then
local b = resolve_bounds(win.opts.layout, sw, sh)
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
@@ -543,6 +667,17 @@ end
--- 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))
@@ -587,6 +722,28 @@ function M.register(widget_id, widget_def, opts)
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,
@@ -596,8 +753,16 @@ function M.register(widget_id, widget_def, opts)
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
@@ -667,6 +832,12 @@ function M.close(widget_id)
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
@@ -720,7 +891,7 @@ function M.point_in_any_panel(x, y)
for _, id in ipairs(open_order) do
local w = windows[id]
if w and w.open then
local b = resolve_bounds(w.opts.layout, sw, sh)
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
@@ -730,6 +901,60 @@ function M.point_in_any_panel(x, y)
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
-- -----------------------------------------------------------------------
@@ -876,6 +1101,69 @@ function M.update(dt)
_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
@@ -927,13 +1215,22 @@ function M._dispatch_event_for_test(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.
--- 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
@@ -951,12 +1248,13 @@ function M._test_get_focused_id()
end
--- M._test_get_window_bounds(widget_id) — resolved bounds for an open
--- window, or nil if not 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 resolve_bounds(w.opts.layout, sw, sh)
return get_bounds_for(w, sw, sh)
end
--- M._test_get_screen_size() — current screen size used by layouts.
@@ -996,4 +1294,117 @@ function M._test_get_persistent(widget_id)
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

View File

@@ -1 +1 @@
{"id":"lib-core.panel","version":"0.3.0","api_min":"0.1","deps":[]}
{"id":"lib-core.panel","version":"0.4.0","api_min":"0.1","deps":[]}