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.