commit c50714de519939daaf9cb443f92724e85578c91e Author: Axel Meyer Date: Thu May 14 15:01:52 2026 +0200 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aef5a76 --- /dev/null +++ b/README.md @@ -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. diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..151bcfa --- /dev/null +++ b/init.lua @@ -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 diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..59e36f1 --- /dev/null +++ b/manifest.lib @@ -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"}]} \ No newline at end of file