From 71a585b459e72a73a02e3fcf57a1534fe994ef60 Mon Sep 17 00:00:00 2001 From: Calic Date: Mon, 15 Jun 2026 03:04:38 +0200 Subject: [PATCH] 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) --- README.md | 180 +++++++++++++++++--- init.lua | 465 ++++++++++++++++++++++++++++++++++++++++++++++++--- manifest.lib | 2 +- 3 files changed, 593 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 9e7cc20..254c170 100644 --- a/README.md +++ b/README.md @@ -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 ``` -## 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. diff --git a/init.lua b/init.lua index cb20dbb..2e94e16 100644 --- a/init.lua +++ b/init.lua @@ -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 + 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 diff --git a/manifest.lib b/manifest.lib index 1cc96f1..8c05b35 100644 --- a/manifest.lib +++ b/manifest.lib @@ -1 +1 @@ -{"id":"lib-core.panel","version":"0.3.0","api_min":"0.1","deps":[]} \ No newline at end of file +{"id":"lib-core.panel","version":"0.4.0","api_min":"0.1","deps":[]} \ No newline at end of file