Port multi-select, box-select and typing-select (BeginMultiSelect/EndMultiSelect, SetNextItemSelectionUserData, MultiSelectItemHeader/Footer, BeginBoxSelect/EndBoxSelect, GetTypingSelectRequest/TypingSelectFind*, ImGuiSelectionBasicStorage/ExternalStorage, Metrics nodes); wired into Selectable/Checkbox/TreeNode/clipper/tables; t_multiselect
This commit is contained in:
@@ -13,6 +13,7 @@ CHECKS=(
|
||||
"readme-min|README OK|MINIFY=1 $R --main tools/test/scripts/t_readme.luau --frames 30"
|
||||
"cached|CACHED OK|$R --main tools/test/scripts/t_cached_window.luau --frames 130"
|
||||
"fixes|FIXES OK|$R --main tools/test/scripts/t_fixes.luau --frames 55"
|
||||
"multiselect|MULTISELECT OK|$R --main tools/test/scripts/t_multiselect.luau --frames 380"
|
||||
"nodemo|NODEMO OK|NO_DEMO=1 $R --main tools/test/scripts/t_nodemo.luau --frames 25"
|
||||
"widgets|OK|$R --main tools/test/scripts/t_widgets.luau --frames 80"
|
||||
"tables|OK|$R --main tools/test/scripts/t_tables.luau --frames 150"
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
-- Multi-select / box-select / typing-select: ImGuiSelectionBasicStorage + Selectable list (click, Ctrl+click, Shift+click, Ctrl+A,
|
||||
-- Escape, box-select drag, keyboard nav + Shift, typing-select), ImGuiListClipper + IncludeItemByIndex(RangeSrcItem),
|
||||
-- ImGuiSelectionExternalStorage on a bool array, checkboxes and tree nodes inside a multi-select scope.
|
||||
local G = require(game:GetService("ReplicatedStorage"):WaitForChild("ImGui"))
|
||||
local ImGui, ImVec2 = G.ImGui, G.ImVec2
|
||||
local MSF = G.ImGuiMultiSelectFlags
|
||||
ImGui.CreateContext(); G.ImGui_ImplRoblox.Init()
|
||||
local io = ImGui.GetIO()
|
||||
io.ConfigFlags = bit32.bor(io.ConfigFlags, G.ImGuiConfigFlags.NavEnableKeyboard)
|
||||
local failed = {}
|
||||
local function check(ok, msg) if not ok then table.insert(failed, msg); print("FAIL " .. msg) end end
|
||||
|
||||
local NAMES = { "Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Coconut", "Date", "Durian", "Elderberry", "Fig",
|
||||
"Grape", "Guava", "Kiwi", "Lemon", "Lime", "Mango", "Melon", "Orange", "Papaya", "Peach" }
|
||||
local N = #NAMES
|
||||
|
||||
-- Iterate with GetNextSelectedItem() (sorted by key) -> "a,b,c"
|
||||
local function sel_str(sel)
|
||||
local out, it, id, has_more = {}, nil, 0, false
|
||||
while true do
|
||||
has_more, it, id = sel:GetNextSelectedItem(it)
|
||||
if not has_more then break end
|
||||
table.insert(out, tostring(id))
|
||||
end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
local function bools_str(t, n)
|
||||
local out = {}
|
||||
for i = 1, n do if t[i] then table.insert(out, tostring(i - 1)) end end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
local sel_list = G.ImGuiSelectionBasicStorage()
|
||||
local sel_clip = G.ImGuiSelectionBasicStorage()
|
||||
local sel_tree = G.ImGuiSelectionBasicStorage()
|
||||
local ext_bools = {}
|
||||
local sel_ext = G.ImGuiSelectionExternalStorage()
|
||||
sel_ext.UserData = ext_bools
|
||||
sel_ext.AdapterSetItemSelected = function(self, idx, selected) self.UserData[idx + 1] = selected end
|
||||
local chk_bools = {}
|
||||
local sel_chk = G.ImGuiSelectionExternalStorage()
|
||||
sel_chk.UserData = chk_bools
|
||||
sel_chk.AdapterSetItemSelected = function(self, idx, selected) self.UserData[idx + 1] = selected end
|
||||
|
||||
local rects = {} -- [name] = { min, max } (copies)
|
||||
local ids = {} -- [name] = item id
|
||||
local function rect() local a, b = ImGui.GetItemRectMin(), ImGui.GetItemRectMax(); return { ImVec2(a.x, a.y), ImVec2(b.x, b.y) } end
|
||||
local function center(name) local r = rects[name]; return (r[1].x + r[2].x) / 2, (r[1].y + r[2].y) / 2 end
|
||||
local function key(k) io:AddKeyEvent(k, true); io:AddKeyEvent(k, false) end
|
||||
local function mods(ctrl, shift) io:AddKeyEvent(G.ImGuiMod_Ctrl, ctrl == true); io:AddKeyEvent(G.ImGuiMod_Shift, shift == true) end
|
||||
|
||||
-- Schedule: each step = { input = fn (before NewFrame), check = fn (after Render) }. Steps run on consecutive frames.
|
||||
local steps = {}
|
||||
local function step(input, chk) table.insert(steps, { input = input, check = chk }) end
|
||||
local function idle(n) for _ = 1, n or 1 do step(nil, nil) end end
|
||||
-- click: press (+mods) on frame A, release on frame B, then one idle frame for EndMultiSelect requests to be applied
|
||||
local function click(name, ctrl, shift)
|
||||
step(function() mods(ctrl, shift); io:AddMousePosEvent(center(name)); io:AddMouseButtonEvent(0, true) end)
|
||||
step(function() io:AddMouseButtonEvent(0, false) end)
|
||||
step(function() mods(false, false) end)
|
||||
end
|
||||
|
||||
local typed_focus_idx = -1
|
||||
local clip_scroll_request = false
|
||||
local f = 0
|
||||
|
||||
idle(3)
|
||||
-- 1. List: click selects one
|
||||
click("L2"); step(nil, function() check(sel_str(sel_list) == "2", "click: " .. sel_str(sel_list)) end)
|
||||
-- 2. Ctrl+click toggles
|
||||
click("L5", true); step(nil, function() check(sel_str(sel_list) == "2,5", "ctrl+click add: " .. sel_str(sel_list)) end)
|
||||
click("L2", true); step(nil, function() check(sel_str(sel_list) == "5", "ctrl+click remove: " .. sel_str(sel_list)) end)
|
||||
-- 3. Shift+click range (from RangeSrc = 2, the last Ctrl+clicked item)
|
||||
click("L8", false, true); step(nil, function() check(sel_str(sel_list) == "2,3,4,5,6,7,8", "shift+click range: " .. sel_str(sel_list)) end)
|
||||
-- 4. Ctrl+A
|
||||
step(function() io:AddKeyEvent(G.ImGuiMod_Ctrl, true); io:AddKeyEvent(G.ImGuiKey.A, true) end)
|
||||
step(function() io:AddKeyEvent(G.ImGuiKey.A, false); io:AddKeyEvent(G.ImGuiMod_Ctrl, false) end)
|
||||
step(nil, function() check(sel_list.Size == N, "ctrl+A size " .. sel_list.Size) end)
|
||||
-- 5. Escape clears (ClearOnEscape)
|
||||
step(function() key(G.ImGuiKey.Escape) end)
|
||||
step(nil, function() check(sel_list.Size == 0 and sel_str(sel_list) == "", "escape clear: " .. sel_str(sel_list)) end)
|
||||
-- 6. Box-select drag (BoxSelect1d) from item 2 to item 5
|
||||
step(function() io:AddMousePosEvent(center("L2")); io:AddMouseButtonEvent(0, true) end)
|
||||
step(function() local x, y = center("L2"); io:AddMousePosEvent(x + 2, y + 10) end)
|
||||
step(function() local x, y = center("L4"); io:AddMousePosEvent(x + 4, y) end)
|
||||
step(function() local x, y = center("L5"); io:AddMousePosEvent(x + 4, y) end,
|
||||
function() check(ImGui.GetCurrentContext().BoxSelectState.IsActive, "box-select active while dragging") end)
|
||||
step(nil)
|
||||
step(function() io:AddMouseButtonEvent(0, false) end)
|
||||
step(nil, function() check(sel_str(sel_list) == "2,3,4,5", "box-select: " .. sel_str(sel_list)); check(not ImGui.GetCurrentContext().BoxSelectState.IsActive, "box-select ended") end)
|
||||
-- 7. Keyboard nav: click 3, Down -> 4 (auto-select), Shift+Down x2 -> 4..6
|
||||
click("L3"); step(nil, function() check(sel_str(sel_list) == "3", "click before nav: " .. sel_str(sel_list)) end)
|
||||
step(function() key(G.ImGuiKey.DownArrow) end); idle(2)
|
||||
step(nil, function() check(sel_str(sel_list) == "4", "nav down auto-select: " .. sel_str(sel_list)) end)
|
||||
step(function() io:AddKeyEvent(G.ImGuiMod_Shift, true); key(G.ImGuiKey.DownArrow) end); idle(1)
|
||||
step(function() key(G.ImGuiKey.DownArrow) end); idle(1)
|
||||
step(function() io:AddKeyEvent(G.ImGuiMod_Shift, false) end); idle(1)
|
||||
step(nil, function() check(sel_str(sel_list) == "4,5,6", "shift+nav range: " .. sel_str(sel_list)) end)
|
||||
-- 8. Typing-select: "ch" -> Cherry (5), then single-char mode "b" "b" -> Banana (2) then Blueberry (3)
|
||||
step(function() io:AddInputCharacter(string.byte("c")); io:AddInputCharacter(string.byte("h")) end); idle(2)
|
||||
step(nil, function() check(ImGui.GetCurrentContext().NavId == ids.L4, "typing 'ch' -> Cherry"); check(sel_str(sel_list) == "4", "typing focus selects: " .. sel_str(sel_list)) end)
|
||||
idle(120) -- TYPING_SELECT_RESET_TIMER
|
||||
step(function() io:AddInputCharacter(string.byte("b")) end); idle(2)
|
||||
step(nil, function() check(ImGui.GetCurrentContext().NavId == ids.L2, "typing 'b' -> Banana") end)
|
||||
step(function() io:AddInputCharacter(string.byte("b")) end); idle(2)
|
||||
step(nil, function() check(ImGui.GetCurrentContext().NavId == ids.L3, "typing 'bb' -> Blueberry") end)
|
||||
idle(120)
|
||||
|
||||
-- 9. Clipper (1000 items): click 5, scroll down, Shift+click item 40 -> 5..40 (RangeSrcItem 5 kept submitted by IncludeItemByIndex)
|
||||
click("C5")
|
||||
-- move NavId away with Ctrl+Down (no selection change, RangeSrc stays 5) so the clipper doesn't keep item 5 alive as the nav item
|
||||
step(function() io:AddKeyEvent(G.ImGuiMod_Ctrl, true); key(G.ImGuiKey.DownArrow) end); idle(1)
|
||||
step(function() key(G.ImGuiKey.DownArrow) end); idle(1)
|
||||
step(function() key(G.ImGuiKey.DownArrow) end); idle(1)
|
||||
step(function() io:AddKeyEvent(G.ImGuiMod_Ctrl, false) end)
|
||||
step(nil, function() check(sel_str(sel_clip) == "5", "clipper ctrl+nav keeps selection: " .. sel_str(sel_clip)) end)
|
||||
step(function() clip_scroll_request = true end); idle(2)
|
||||
step(nil, function() check(rects.C40 ~= nil and rects.C5 == nil, "clipper scrolled (item 5 not visible)") end)
|
||||
click("C40", false, true)
|
||||
step(nil, function() check(sel_clip.Size == 36, "clipper shift+click range size " .. sel_clip.Size); check(sel_clip:Contains(5) and sel_clip:Contains(40) and not sel_clip:Contains(4) and not sel_clip:Contains(41), "clipper range bounds") end)
|
||||
|
||||
-- 10. External storage on bool array: click 1, Shift+click 3, Ctrl+click 2
|
||||
click("E1"); click("E3", false, true)
|
||||
step(nil, function() check(bools_str(ext_bools, 10) == "1,2,3", "external storage range: " .. bools_str(ext_bools, 10)) end)
|
||||
click("E2", true)
|
||||
step(nil, function() check(bools_str(ext_bools, 10) == "1,3", "external storage ctrl+click: " .. bools_str(ext_bools, 10)) end)
|
||||
|
||||
-- 11. Checkboxes (NoAutoSelect|NoAutoClear): click 1 toggles on, Shift+click 4 -> 1..4 on, click 2 toggles off
|
||||
click("K1"); step(nil, function() check(bools_str(chk_bools, 8) == "1", "checkbox click: " .. bools_str(chk_bools, 8)) end)
|
||||
click("K4", false, true); step(nil, function() check(bools_str(chk_bools, 8) == "1,2,3,4", "checkbox shift+click: " .. bools_str(chk_bools, 8)) end)
|
||||
click("K2"); step(nil, function() check(bools_str(chk_bools, 8) == "1,3,4", "checkbox toggle off: " .. bools_str(chk_bools, 8)) end)
|
||||
|
||||
-- 12. Tree nodes: click 0, Ctrl+click 2, Shift+click 4 (range from 2)
|
||||
click("T0"); click("T2", true)
|
||||
step(nil, function() check(sel_str(sel_tree) == "0,2", "tree ctrl+click: " .. sel_str(sel_tree)) end)
|
||||
click("T4", false, true)
|
||||
step(nil, function() check(sel_str(sel_tree) == "2,3,4", "tree shift+click: " .. sel_str(sel_tree)) end)
|
||||
|
||||
-- 13. Metrics window renders the TypingSelect / MultiSelect debug nodes
|
||||
local show_metrics = false
|
||||
step(function() show_metrics = true end); idle(3)
|
||||
step(nil, function() if #failed == 0 then print("MULTISELECT OK") end end)
|
||||
|
||||
local function list_window()
|
||||
ImGui.SetNextWindowPos(ImVec2(10, 10)); ImGui.SetNextWindowSize(ImVec2(200, 420))
|
||||
ImGui.Begin("L")
|
||||
local flags = bit32.bor(MSF.ClearOnEscape, MSF.BoxSelect1d)
|
||||
local ms_io = ImGui.BeginMultiSelect(flags, sel_list.Size, N)
|
||||
sel_list:ApplyRequests(ms_io)
|
||||
-- typing-select
|
||||
local focus_idx = -1
|
||||
if ImGui.IsWindowFocused() then
|
||||
local req = ImGui.GetTypingSelectRequest(G.ImGuiTypingSelectFlags.AllowSingleCharMode)
|
||||
local nav_idx = (type(ms_io.NavIdItem) == "number") and ms_io.NavIdItem or -1
|
||||
focus_idx = ImGui.TypingSelectFindMatch(req, N, function(_, idx) return NAMES[idx + 1] end, nil, nav_idx)
|
||||
end
|
||||
for n = 0, N - 1 do
|
||||
if n == focus_idx then ImGui.SetKeyboardFocusHere() end
|
||||
ImGui.SetNextItemSelectionUserData(n)
|
||||
ImGui.Selectable(NAMES[n + 1], sel_list:Contains(n))
|
||||
rects["L" .. n] = rect(); ids["L" .. n] = ImGui.GetItemID()
|
||||
end
|
||||
ms_io = ImGui.EndMultiSelect()
|
||||
sel_list:ApplyRequests(ms_io)
|
||||
ImGui.End()
|
||||
end
|
||||
|
||||
local function clipper_window()
|
||||
ImGui.SetNextWindowPos(ImVec2(220, 10)); ImGui.SetNextWindowSize(ImVec2(200, 300))
|
||||
ImGui.Begin("C")
|
||||
if clip_scroll_request then ImGui.SetScrollY(ImGui.GetTextLineHeightWithSpacing() * 30); clip_scroll_request = false end
|
||||
local COUNT = 1000
|
||||
local ms_io = ImGui.BeginMultiSelect(MSF.ClearOnEscape, sel_clip.Size, COUNT)
|
||||
sel_clip:ApplyRequests(ms_io)
|
||||
local clipper = G.ImGuiListClipper()
|
||||
clipper:Begin(COUNT)
|
||||
if ms_io.RangeSrcItem ~= -1 then clipper:IncludeItemByIndex(ms_io.RangeSrcItem) end -- Ensure RangeSrc item is not clipped.
|
||||
for k in rects do if string.sub(k, 1, 1) == "C" then rects[k] = nil end end
|
||||
while clipper:Step() do
|
||||
for n = clipper.DisplayStart, clipper.DisplayEnd - 1 do
|
||||
ImGui.SetNextItemSelectionUserData(n)
|
||||
ImGui.Selectable(("Object %04d"):format(n), sel_clip:Contains(n))
|
||||
if ImGui.IsItemVisible() then rects["C" .. n] = rect() end
|
||||
end
|
||||
end
|
||||
ms_io = ImGui.EndMultiSelect()
|
||||
sel_clip:ApplyRequests(ms_io)
|
||||
ImGui.End()
|
||||
end
|
||||
|
||||
local function external_window()
|
||||
ImGui.SetNextWindowPos(ImVec2(430, 10)); ImGui.SetNextWindowSize(ImVec2(180, 220))
|
||||
ImGui.Begin("E")
|
||||
local ms_io = ImGui.BeginMultiSelect(MSF.None, -1, 10)
|
||||
sel_ext:ApplyRequests(ms_io)
|
||||
for n = 0, 9 do
|
||||
ImGui.SetNextItemSelectionUserData(n)
|
||||
ImGui.Selectable("Ext " .. n, ext_bools[n + 1] == true)
|
||||
rects["E" .. n] = rect()
|
||||
end
|
||||
ms_io = ImGui.EndMultiSelect()
|
||||
sel_ext:ApplyRequests(ms_io)
|
||||
ImGui.End()
|
||||
end
|
||||
|
||||
local function checkbox_window()
|
||||
ImGui.SetNextWindowPos(ImVec2(430, 240)); ImGui.SetNextWindowSize(ImVec2(180, 200))
|
||||
ImGui.Begin("K")
|
||||
local ms_io = ImGui.BeginMultiSelect(bit32.bor(MSF.NoAutoSelect, MSF.NoAutoClear, MSF.ClearOnEscape), -1, 8)
|
||||
sel_chk:ApplyRequests(ms_io)
|
||||
for n = 0, 7 do
|
||||
ImGui.SetNextItemSelectionUserData(n)
|
||||
local _, v = ImGui.Checkbox("Check " .. n, chk_bools[n + 1] == true)
|
||||
rects["K" .. n] = rect()
|
||||
local _ = v -- the selection is updated through requests (ApplyRequests), like upstream's demo
|
||||
end
|
||||
ms_io = ImGui.EndMultiSelect()
|
||||
sel_chk:ApplyRequests(ms_io)
|
||||
ImGui.End()
|
||||
end
|
||||
|
||||
local function tree_window()
|
||||
ImGui.SetNextWindowPos(ImVec2(220, 320)); ImGui.SetNextWindowSize(ImVec2(200, 160))
|
||||
ImGui.Begin("T")
|
||||
local ms_io = ImGui.BeginMultiSelect(MSF.None, sel_tree.Size, 5)
|
||||
sel_tree:ApplyRequests(ms_io)
|
||||
for n = 0, 4 do
|
||||
local flags = bit32.bor(G.ImGuiTreeNodeFlags.Leaf, G.ImGuiTreeNodeFlags.NoTreePushOnOpen, G.ImGuiTreeNodeFlags.SpanAvailWidth)
|
||||
if sel_tree:Contains(n) then flags = bit32.bor(flags, G.ImGuiTreeNodeFlags.Selected) end
|
||||
ImGui.SetNextItemSelectionUserData(n)
|
||||
ImGui.TreeNodeEx("Node " .. n, flags)
|
||||
rects["T" .. n] = rect()
|
||||
end
|
||||
ms_io = ImGui.EndMultiSelect()
|
||||
sel_tree:ApplyRequests(ms_io)
|
||||
ImGui.End()
|
||||
end
|
||||
|
||||
game:GetService("RunService").Stepped:Connect(function()
|
||||
f += 1
|
||||
G.ImGui_ImplRoblox.NewFrame()
|
||||
local s = steps[f]
|
||||
if s and s.input then s.input() end
|
||||
ImGui.NewFrame()
|
||||
list_window()
|
||||
clipper_window()
|
||||
external_window()
|
||||
checkbox_window()
|
||||
tree_window()
|
||||
if show_metrics then
|
||||
ImGui.SetNextWindowPos(ImVec2(620, 10)); ImGui.SetNextWindowSize(ImVec2(400, 600))
|
||||
ImGui.ShowMetricsWindow()
|
||||
local ctx = ImGui.GetCurrentContext()
|
||||
ImGui.Begin("DebugNodes")
|
||||
ImGui.DebugNodeTypingSelectState(ctx.TypingSelectState)
|
||||
for n = 0, ctx.MultiSelectStorage:GetMapSize() - 1 do
|
||||
local st = ctx.MultiSelectStorage:GetByIndex(n)
|
||||
if st then ImGui.SetNextItemOpen(true); ImGui.DebugNodeMultiSelectState(st) end
|
||||
end
|
||||
ImGui.End()
|
||||
end
|
||||
ImGui.Render(); G.ImGui_ImplRoblox.RenderDrawData(ImGui.GetDrawData())
|
||||
if s and s.check then s.check() end
|
||||
end)
|
||||
print(("t_multiselect: %d steps"):format(#steps))
|
||||
Reference in New Issue
Block a user