-- ===================================================================== -- 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) -- Release-edge detection must sync EVERY frame (even when disabled or unbound) -- so prev_click_down doesn't drift across enable/disable boundaries. local cur_down = (click_action ~= nil) and input.is_action_down(click_action) or false local released = (prev_click_down and not cur_down) prev_click_down = cur_down if not enabled_flag then return end if click_action == nil then return end 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