feat(P.3.6): lib-core.selection v0.1.0 — initial release
Handle-Registry + Click/Drag-Box State-Machine + Selection-Set-API + alpha-tinted render-helpers. Release-edge detection internal via prev_click_down (no was_action_released in lib-core.input). See: meta/docs/superpowers/specs/2026-05-14-p3-6-lib-selection-design.md
This commit is contained in:
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# lib-core.selection — v0.1.0
|
||||||
|
|
||||||
|
Click-Select + Drag-Box-Select + Selection-Set-State.
|
||||||
|
|
||||||
|
## Quick Setup
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local selection = require("lib-core.selection")
|
||||||
|
local input = require("lib-core.input")
|
||||||
|
|
||||||
|
input.bind("lmb", { "mouse_left" })
|
||||||
|
input.bind("mod_add", { "shift" })
|
||||||
|
input.bind("mod_toggle", { "ctrl" })
|
||||||
|
|
||||||
|
selection.bind_action("lmb")
|
||||||
|
selection.bind_modifier_add("mod_add")
|
||||||
|
selection.bind_modifier_toggle("mod_toggle")
|
||||||
|
|
||||||
|
local handle = selection.register(function()
|
||||||
|
return { x = entity.x, y = entity.y, w = entity.w, h = entity.h }
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- per-frame:
|
||||||
|
function update(ctx, dt) selection.update(dt) end
|
||||||
|
function render(ctx)
|
||||||
|
camera.begin()
|
||||||
|
-- entities…
|
||||||
|
selection.render_highlights() -- world-space
|
||||||
|
camera.finish()
|
||||||
|
selection.render_drag_box() -- screen-space
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- Registry: `register(aabb_fn) → handle`, `unregister(h)`, `count_registered()`
|
||||||
|
- Set-Query: `list()`, `count()`, `contains(h)`, `is_empty()`
|
||||||
|
- Set-Mutation: `add(h)`, `remove(h)`, `toggle(h)`, `clear()`
|
||||||
|
- Bindings: `bind_action(name)`, `bind_modifier_add(name)`, `bind_modifier_toggle(name)`
|
||||||
|
- State-Config: `set_drag_threshold(px)` / `drag_threshold()`, `set_enabled(bool)` / `enabled()`
|
||||||
|
- State-Query: `drag_state() → "idle"|"ambiguous"|"dragging"`, `box_rect() → {x,y,w,h}|nil`
|
||||||
|
- Visual: `set_drag_box_color(r,g,b,a)` / `drag_box_color()`, `set_highlight_color(r,g,b,a)` / `highlight_color()`
|
||||||
|
- Per-Frame: `update(dt)`, `render_drag_box()`, `render_highlights()`
|
||||||
|
|
||||||
|
See `meta/docs/superpowers/specs/2026-05-14-p3-6-lib-selection-design.md` for full design.
|
||||||
398
init.lua
Normal file
398
init.lua
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- lib-core.selection v0.1.0 — Click-Select + Drag-Box-Select + Selection-Set-State
|
||||||
|
-- See: meta/docs/superpowers/specs/2026-05-14-p3-6-lib-selection-design.md
|
||||||
|
--
|
||||||
|
-- Handle-Registry-Pattern (analog interaction): module registers AABB-callbacks,
|
||||||
|
-- lib polls during hit-test. State-Machine (idle/ambiguous/dragging) für
|
||||||
|
-- Click-vs-Drag-Disambiguation via pixel-threshold. Modifier-Bindings via
|
||||||
|
-- action-names (mod_add, mod_toggle). AABB-Hit-Test (last-registered-wins).
|
||||||
|
-- Render: alpha-tinted draw_rect helpers (kein Outline).
|
||||||
|
--
|
||||||
|
-- DEPRECATED-MVPs siehe Spec §7 + inline-comments unten.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
local input = require("lib-core.input")
|
||||||
|
local camera = require("lib-core.camera")
|
||||||
|
|
||||||
|
-- Registry: array of {handle, aabb_fn}. Last-registered = end-of-array.
|
||||||
|
local selectables = {}
|
||||||
|
local next_handle = 1
|
||||||
|
|
||||||
|
-- Selection-Set: table of handle → true.
|
||||||
|
local selected = {}
|
||||||
|
|
||||||
|
-- State-Machine.
|
||||||
|
local drag_state = "idle" -- "idle" | "ambiguous" | "dragging"
|
||||||
|
local press_x = 0
|
||||||
|
local press_y = 0
|
||||||
|
local cur_x = 0
|
||||||
|
local cur_y = 0
|
||||||
|
local drag_threshold = 4 -- px
|
||||||
|
local prev_click_down = false -- release-edge detection
|
||||||
|
|
||||||
|
-- Bindings.
|
||||||
|
local click_action = nil
|
||||||
|
local mod_add_action = nil
|
||||||
|
local mod_toggle_action = nil
|
||||||
|
|
||||||
|
-- Runtime-Toggle.
|
||||||
|
local enabled_flag = true
|
||||||
|
|
||||||
|
-- Visual settings (RGBA-tuples as {r,g,b,a} tables).
|
||||||
|
local drag_box_rgba = { 255, 255, 255, 32 }
|
||||||
|
local highlight_rgba = { 60, 255, 80, 80 }
|
||||||
|
|
||||||
|
-- ---------- internal helpers ----------
|
||||||
|
|
||||||
|
local function check_string(arg_name, value)
|
||||||
|
if type(value) ~= "string" or value == "" then
|
||||||
|
error(string.format("selection.%s: must be non-empty string", arg_name))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_selectable_index(handle)
|
||||||
|
for i, s in ipairs(selectables) do
|
||||||
|
if s.handle == handle then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find_aabb_fn(handle)
|
||||||
|
for _, s in ipairs(selectables) do
|
||||||
|
if s.handle == handle then return s.aabb_fn end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clamp_color(name, v)
|
||||||
|
if type(v) ~= "number" or v < 0 or v > 255 then
|
||||||
|
error(string.format("selection.%s: color components must be 0..255", name))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolve_mode()
|
||||||
|
if mod_toggle_action and input.is_action_down(mod_toggle_action) then
|
||||||
|
return "toggle"
|
||||||
|
end
|
||||||
|
if mod_add_action and input.is_action_down(mod_add_action) then
|
||||||
|
return "add"
|
||||||
|
end
|
||||||
|
return "replace"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function commit_click(sx, sy, mode)
|
||||||
|
local wx, wy = camera.screen_to_world(sx, sy)
|
||||||
|
local hit = nil
|
||||||
|
-- Last-registered-wins: iterate in registration order, keep last match.
|
||||||
|
for _, s in ipairs(selectables) do
|
||||||
|
local a = s.aabb_fn()
|
||||||
|
if a and type(a) == "table"
|
||||||
|
and type(a.x) == "number" and type(a.y) == "number"
|
||||||
|
and type(a.w) == "number" and type(a.h) == "number" then
|
||||||
|
if wx >= a.x and wx <= a.x + a.w
|
||||||
|
and wy >= a.y and wy <= a.y + a.h then
|
||||||
|
hit = s.handle
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if mode == "replace" then
|
||||||
|
selected = {}
|
||||||
|
if hit then selected[hit] = true end
|
||||||
|
elseif mode == "add" then
|
||||||
|
if hit then selected[hit] = true end
|
||||||
|
elseif mode == "toggle" then
|
||||||
|
if hit then
|
||||||
|
if selected[hit] then selected[hit] = nil
|
||||||
|
else selected[hit] = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function commit_box(press_sx, press_sy, rel_sx, rel_sy, mode)
|
||||||
|
-- Project both screen corners to world (camera-state-aware).
|
||||||
|
local wx1, wy1 = camera.screen_to_world(press_sx, press_sy)
|
||||||
|
local wx2, wy2 = camera.screen_to_world(rel_sx, rel_sy)
|
||||||
|
local bxmin = math.min(wx1, wx2)
|
||||||
|
local bymin = math.min(wy1, wy2)
|
||||||
|
local bxmax = math.max(wx1, wx2)
|
||||||
|
local bymax = math.max(wy1, wy2)
|
||||||
|
|
||||||
|
local hits = {}
|
||||||
|
for _, s in ipairs(selectables) do
|
||||||
|
local a = s.aabb_fn()
|
||||||
|
if a and type(a) == "table"
|
||||||
|
and type(a.x) == "number" and type(a.y) == "number"
|
||||||
|
and type(a.w) == "number" and type(a.h) == "number" then
|
||||||
|
-- Any-overlap (AABB-intersection):
|
||||||
|
if not (a.x > bxmax or a.x + a.w < bxmin
|
||||||
|
or a.y > bymax or a.y + a.h < bymin) then
|
||||||
|
hits[#hits + 1] = s.handle
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if mode == "replace" then
|
||||||
|
selected = {}
|
||||||
|
for _, h in ipairs(hits) do selected[h] = true end
|
||||||
|
elseif mode == "add" then
|
||||||
|
for _, h in ipairs(hits) do selected[h] = true end
|
||||||
|
elseif mode == "toggle" then
|
||||||
|
for _, h in ipairs(hits) do
|
||||||
|
if selected[h] then selected[h] = nil
|
||||||
|
else selected[h] = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------- public API ----------
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- Registry
|
||||||
|
|
||||||
|
function M.register(aabb_fn)
|
||||||
|
if type(aabb_fn) ~= "function" then
|
||||||
|
error("selection.register: aabb_fn must be a function")
|
||||||
|
end
|
||||||
|
local h = next_handle
|
||||||
|
next_handle = next_handle + 1
|
||||||
|
selectables[#selectables + 1] = { handle = h, aabb_fn = aabb_fn }
|
||||||
|
return h
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.unregister(handle)
|
||||||
|
local idx = find_selectable_index(handle)
|
||||||
|
if idx then
|
||||||
|
table.remove(selectables, idx)
|
||||||
|
selected[handle] = nil -- also remove from selection if present
|
||||||
|
end
|
||||||
|
-- silent no-op if handle unknown
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.count_registered()
|
||||||
|
return #selectables
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Set-Query
|
||||||
|
|
||||||
|
function M.list()
|
||||||
|
local out = {}
|
||||||
|
for h, _ in pairs(selected) do
|
||||||
|
out[#out + 1] = h
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.count()
|
||||||
|
local n = 0
|
||||||
|
for _ in pairs(selected) do n = n + 1 end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.contains(handle)
|
||||||
|
return selected[handle] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.is_empty()
|
||||||
|
return next(selected) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Set-Mutation (programmatic)
|
||||||
|
|
||||||
|
function M.add(handle)
|
||||||
|
if find_aabb_fn(handle) == nil then
|
||||||
|
error("selection.add: handle not registered")
|
||||||
|
end
|
||||||
|
selected[handle] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.remove(handle)
|
||||||
|
selected[handle] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.toggle(handle)
|
||||||
|
if find_aabb_fn(handle) == nil then
|
||||||
|
error("selection.toggle: handle not registered")
|
||||||
|
end
|
||||||
|
if selected[handle] then selected[handle] = nil
|
||||||
|
else selected[handle] = true end
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.clear()
|
||||||
|
selected = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Bindings
|
||||||
|
|
||||||
|
function M.bind_action(action_name)
|
||||||
|
check_string("bind_action", action_name)
|
||||||
|
click_action = action_name
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.bind_modifier_add(action_name)
|
||||||
|
check_string("bind_modifier_add", action_name)
|
||||||
|
mod_add_action = action_name
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.bind_modifier_toggle(action_name)
|
||||||
|
check_string("bind_modifier_toggle", action_name)
|
||||||
|
mod_toggle_action = action_name
|
||||||
|
end
|
||||||
|
|
||||||
|
-- State-Machine-Config
|
||||||
|
|
||||||
|
function M.set_drag_threshold(px)
|
||||||
|
if type(px) ~= "number" or px <= 0 then
|
||||||
|
error("selection.set_drag_threshold: must be positive number")
|
||||||
|
end
|
||||||
|
drag_threshold = px
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.drag_threshold()
|
||||||
|
return drag_threshold
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.set_enabled(b)
|
||||||
|
if type(b) ~= "boolean" then
|
||||||
|
error("selection.set_enabled: must be boolean")
|
||||||
|
end
|
||||||
|
enabled_flag = b
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.enabled()
|
||||||
|
return enabled_flag
|
||||||
|
end
|
||||||
|
|
||||||
|
-- State-Machine-Query
|
||||||
|
|
||||||
|
function M.drag_state()
|
||||||
|
return drag_state
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.box_rect()
|
||||||
|
if drag_state ~= "dragging" then return nil end
|
||||||
|
local bx = math.min(press_x, cur_x)
|
||||||
|
local by = math.min(press_y, cur_y)
|
||||||
|
local bw = math.abs(cur_x - press_x)
|
||||||
|
local bh = math.abs(cur_y - press_y)
|
||||||
|
return { x = bx, y = by, w = bw, h = bh }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Visual-Config
|
||||||
|
|
||||||
|
function M.set_drag_box_color(r, g, b, a)
|
||||||
|
clamp_color("set_drag_box_color.r", r)
|
||||||
|
clamp_color("set_drag_box_color.g", g)
|
||||||
|
clamp_color("set_drag_box_color.b", b)
|
||||||
|
clamp_color("set_drag_box_color.a", a)
|
||||||
|
drag_box_rgba = { r, g, b, a }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.drag_box_color()
|
||||||
|
return { drag_box_rgba[1], drag_box_rgba[2], drag_box_rgba[3], drag_box_rgba[4] }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.set_highlight_color(r, g, b, a)
|
||||||
|
clamp_color("set_highlight_color.r", r)
|
||||||
|
clamp_color("set_highlight_color.g", g)
|
||||||
|
clamp_color("set_highlight_color.b", b)
|
||||||
|
clamp_color("set_highlight_color.a", a)
|
||||||
|
highlight_rgba = { r, g, b, a }
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.highlight_color()
|
||||||
|
return { highlight_rgba[1], highlight_rgba[2], highlight_rgba[3], highlight_rgba[4] }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Per-Frame
|
||||||
|
|
||||||
|
function M.update(dt)
|
||||||
|
if not enabled_flag then return end
|
||||||
|
if click_action == nil then return end
|
||||||
|
|
||||||
|
-- Release-edge detection (lib-core.input has no was_action_released).
|
||||||
|
local cur_down = input.is_action_down(click_action)
|
||||||
|
local released = (prev_click_down and not cur_down)
|
||||||
|
prev_click_down = cur_down
|
||||||
|
|
||||||
|
local mx, my = engine.input.get_mouse_pos()
|
||||||
|
|
||||||
|
if drag_state == "idle" then
|
||||||
|
if input.was_action_pressed(click_action) then
|
||||||
|
press_x, press_y = mx, my
|
||||||
|
cur_x, cur_y = mx, my
|
||||||
|
drag_state = "ambiguous"
|
||||||
|
end
|
||||||
|
elseif drag_state == "ambiguous" then
|
||||||
|
cur_x, cur_y = mx, my
|
||||||
|
local dx, dy = cur_x - press_x, cur_y - press_y
|
||||||
|
if (dx * dx + dy * dy) >= (drag_threshold * drag_threshold) then
|
||||||
|
drag_state = "dragging"
|
||||||
|
end
|
||||||
|
elseif drag_state == "dragging" then
|
||||||
|
cur_x, cur_y = mx, my
|
||||||
|
end
|
||||||
|
|
||||||
|
if released and drag_state ~= "idle" then
|
||||||
|
local mode = resolve_mode()
|
||||||
|
if drag_state == "ambiguous" then
|
||||||
|
commit_click(press_x, press_y, mode)
|
||||||
|
else -- "dragging"
|
||||||
|
commit_box(press_x, press_y, cur_x, cur_y, mode)
|
||||||
|
end
|
||||||
|
drag_state = "idle"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Render-Helpers
|
||||||
|
--
|
||||||
|
-- CONVENTION (not enforced):
|
||||||
|
-- render_highlights() MUSS zwischen camera.begin()/camera.finish() aufgerufen werden.
|
||||||
|
-- render_drag_box() MUSS AUSSERHALB camera.begin/finish aufgerufen werden.
|
||||||
|
|
||||||
|
function M.render_drag_box()
|
||||||
|
if drag_state ~= "dragging" then return end
|
||||||
|
local bx = math.min(press_x, cur_x)
|
||||||
|
local by = math.min(press_y, cur_y)
|
||||||
|
local bw = math.abs(cur_x - press_x)
|
||||||
|
local bh = math.abs(cur_y - press_y)
|
||||||
|
local c = engine.render.rgba(drag_box_rgba[1], drag_box_rgba[2],
|
||||||
|
drag_box_rgba[3], drag_box_rgba[4])
|
||||||
|
engine.render.draw_rect(bx, by, bw, bh, c)
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.render_highlights()
|
||||||
|
if next(selected) == nil then return end
|
||||||
|
local c = engine.render.rgba(highlight_rgba[1], highlight_rgba[2],
|
||||||
|
highlight_rgba[3], highlight_rgba[4])
|
||||||
|
for handle, _ in pairs(selected) do
|
||||||
|
local aabb_fn = find_aabb_fn(handle)
|
||||||
|
if aabb_fn then
|
||||||
|
local a = aabb_fn()
|
||||||
|
if a and type(a) == "table"
|
||||||
|
and type(a.x) == "number" and type(a.y) == "number"
|
||||||
|
and type(a.w) == "number" and type(a.h) == "number" then
|
||||||
|
engine.render.draw_rect(a.x, a.y, a.w, a.h, c)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- DEPRECATED-MVP: was_action_released in lib-core.input (currently tracked internally
|
||||||
|
-- via prev_click_down — promote to input-lib when 2nd consumer needs it)
|
||||||
|
-- DEPRECATED-MVP: pixel-perfect click hit-test (needs sprite-API + opacity-mask)
|
||||||
|
-- DEPRECATED-MVP: register(aabb_fn, z) z-order disambiguator
|
||||||
|
-- DEPRECATED-MVP: on_changed(fn) observer-callback (polling-API sufficient for RTS-MVP)
|
||||||
|
-- DEPRECATED-MVP: capability-/interface-based deps (oneOf-resolver — own architectural slice)
|
||||||
|
-- DEPRECATED-MVP: lib-core.drag extraction (when 2nd consumer appears: inventory-DnD,
|
||||||
|
-- map-editor-paint, area-pick minigame)
|
||||||
|
-- DEPRECATED-MVP: draw_rect_outline engine-add (when alpha-fill aesthetic insufficient)
|
||||||
|
-- DEPRECATED-MVP: spatial-index (Quadtree/Grid) when N > ~1000 selectables
|
||||||
|
-- DEPRECATED-MVP: selection.first() / iter() for stable iteration order
|
||||||
|
-- DEPRECATED-MVP: per-selectable highlight-color (when friend/enemy distinction needed)
|
||||||
|
-- DEPRECATED-MVP: window-focus-loss drag-state reset
|
||||||
|
-- DEPRECATED-MVP: double-click-select-all-of-type (RTS polish)
|
||||||
|
-- DEPRECATED-MVP: drag-beyond-window-edge auto-cam-pan (couples to camera-lib)
|
||||||
|
|
||||||
|
return M
|
||||||
1
manifest.lib
Normal file
1
manifest.lib
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"id":"lib-core.selection","version":"0.1.0","api_min":"0.1","deps":[{"id":"lib-core.input","version":"0.3.0"},{"id":"lib-core.camera","version":"0.3.0"}]}
|
||||||
Reference in New Issue
Block a user