menu_form.tracker(): MenuTracker#which rows moved by hand, against a snapshot. a config load has no event, so what moved
- Returns
MenuTracker
with the menu closed, or in a batch of 5+ between two looks, goes into the snapshot.
Splits a top group into its main rows and an "Advanced" list.
"requires": { "menu_form": "^1.0" }local menu_form = require("lib/menu_form")Players install nothing: the script brings the library, any version inside the range. Edit scripts/lib/menu_form/ in a dev session to try a change before you publish.
menu_form.tracker(): MenuTracker#which rows moved by hand, against a snapshot. a config load has no event, so what moved
MenuTrackerwith the menu closed, or in a batch of 5+ between two looks, goes into the snapshot.
the page of a top group; the same object on a second call
tab stringtop string first segment of the group pathMenuPagemenu_form.toggle(id: string)#switch a page between its main rows and its advanced list
id string "tab.top"menu_form.toggle_fine(id: string, sid: string)#switch a page between its main rows and one section's list (Page:fine)
id string "tab.top"sid stringmenu_form.apply()#bring every page in line; run after a menu rebuild
menu_form.tick()#per frame. a click in the menu fires no on_change, so values are polled, part of the rows
each frame with the menu open. a list stays open over a menu close: the sub-page the player stood on lives in it
menu_form.release()#give every hidden row back: the script that owns the pages is going away. a reload takes
the controls with it, so no handle is kept; the next apply finds or makes them again
base: tableMenuTracker.see(key: string, c: userdata)#first sight of a row: its value goes into the snapshot
key stringc userdataMenuTracker.touched(key: string, c: userdata): boolean#true when the row differs from the snapshot
key stringc userdatabooleanMenuTracker.poll(open: boolean): table?#one whole look; once on close, or often with few rows
open boolean the menu is opentable? key -> valueMenuTracker.step(n: integer): table?#part of a look, for a frame: a look at every row of every hero is a hitch
n integer rowstable? key -> value, when the look ended heremark a row as advanced. rows of subgroups go here too: the list swaps the whole top group
group string full group path in the tablabel string the row's store labelMenuPageevery row of the page goes to the list, a row added later too, except what main names.
MenuPagefor a page where the main rows are the short list
a main row of an advanced_rest page. without a label: every row of the group
group string full group path in the tablabel stringMenuPageforget every main row named so far: for a page whose owner lays it out again
MenuPageMenuPage.fine(sid: string, keys: string[], opts?: {group?: string, order?: integer, back_order?: integer, head?: string, back?: string}): MenuPage#a section's own list: its rows leave the page for a button at the section's end ("Fine
sid string section id, unique on the pagekeys string[] group .. "\0" .. label of each rowopts {group?: string, order?: integer, back_order?: integer, head?: string, back?: string}MenuPagetuning · N"), which swaps the page to the head of the section and these rows, and back. the page's advanced list does not count them. a second call with the same id replaces the rows group: where the button lives (default the top group); order: its draw order, back_order while the list is open; head: a row kept in view while the list is open; back: the page name its back caption prints
rows whose value something else wrote: a move of theirs is not a hand
fn fun(key: string): boolean key = group .. "\0" .. labelMenuPageSplits a top group into its main rows and an "Advanced" list. A button at the bottom of the group
swaps the page to the advanced rows and back; the list stays open over a menu close. Config
keys and values do not change. Hidden rows stay in search. Built on ui.group_controls,
ui.reference, ui.button, c:visible, c:findable, c:is_default, so any pack can use it.
uTEA's tables are utea/core/menu_view_data.lua (outside the heroes) and
utea/core/menu_view_heroes.lua (hero pages), both generated (#555).
local Form = require("lib/menu_form")
local P = Form.page("Misc", "my pack")
P:advanced("my pack", "Retry delay")
P:advanced("my pack.Fine", "Sample rate") -- rows of subgroups are named by their full group
Form.apply() -- after building the menu, and after a rebuild
-- once a frame, also before a game starts:
Form.tick()
-- on unload:
Form.release()
A page where the main rows are the short list names those instead, and every other row, a later one too, goes to the list:
Form.page("Combo", "Heroes.Axe"):advanced_rest()
:main("Heroes.Axe", "1. Call") -- one row
:main("Heroes.Axe.Combo editor") -- every row of a group, made at runtime or not
:quiet(function(key) return styled[key] end) -- key = group .. "\0" .. label
quiet names rows something else writes: their move does not keep them on the page and is not
counted on the button.
A page with no marked rows gets no button. A row whose owner hid it stays hidden, and is not
counted on the button. In the list a main row stays when a list row is numbered under it (1. for
1.1, and 1. and 1.1 for 1.1.1): it is the heading of their card.
What stays on the main page although it is marked advanced:
| row | why |
|---|---|
| value changed by hand this launch | the player sees what they did |
| bound hotkey | the Keys page lists shown hotkeys only |
A value that came from a config stays in the list and is counted on the button ("Advanced · 12 ·
changed 3"). The api has no config-load event, so M.tracker() tells the two apart: what moved
with the menu closed, or in a batch of 5+ rows in one look, goes into the snapshot. A look takes
400 rows a frame (t:step(n)), so 6000 rows are 15 frames. A second consumer of the same rule can
use the tracker alone: t:see(key, c), t:touched(key, c), t:step(n), t:poll(menu_open).
A page laid out in sections gives each section its own list (Page:fine, #651). Its rows leave the
page and the "Advanced" count for a button at the end of the section, "Fine tuning · N"; a press
shows the section's head row and these rows only, with the button turned into the way back.
reset_main() forgets the main rows named so far, for an owner that lays the page out again:
Form.page("Combo", "Heroes.Pudge"):reset_main():main("Heroes.Pudge", "1. Meat Hook")
:fine("1", { "Heroes.Pudge\0" .. "1.1 Hook lead (%)" },
{ group = "Heroes.Pudge", order = 1900, back_order = 999,
head = "Heroes.Pudge\0" .. "1. Meat Hook", back = "Pudge" })
The "Advanced" button then shows only while a row is left outside every section list.
The list is one page of the same group: subgroups stay tabs, because a row's group is part of its
config key, unless the owner draws them on the parent page (ui.group_meta(tab, group, { inline = true })). A subgroup with no row shown has no tab on that page.
Test: tools\lua\lua.exe scripts\lib\menu_form\selftest.lua from the repo root.
The code of 1.2.0 as the client gets it. The bundle's bytecode is built from these files.
init.lua507 lines--- lib/menu_form -- a group's main rows on its page, the rest in a separate "Advanced" list (#555).
--- the page stays one level: a button at the bottom swaps the group to its advanced rows and
--- back, no row unfolds in place. config keys and values do not move. a hidden row stays in
--- search. a row the player changed by hand stays on the main page; a value that came from a
--- config does not, it is counted on the button. built on open calls only, any pack can use it.
do
local g = rawget(_G, "__EVOLVE_LIB_REG")
if g and g.menu_form then return g.menu_form end
end
local M = {}
local BTN = "Advanced settings" -- the button's store label, a button has no config key
local ORDER = 1000100 -- after menu_fine's rows
local STEP = 400 -- rows looked at per frame, menu open: 127 heroes are 5000+ rows
local MASS = 5 -- rows moved between two looks: not a hand, a config load
local TXT = {
en = { n = "Advanced · %d", ch = "Advanced · %d · changed %d", back = "Back to main settings",
hint = "Thresholds and exceptions. A value you change here stays on the main page until the next launch",
back_hint = "The main settings of this group" },
ru = { n = "Расширенные · %d", ch = "Расширенные · %d · изменено %d", back = "Назад к основным",
hint = "Пороги и исключения. Значение, которое вы здесь поменяете, останется на основной странице до следующего запуска",
back_hint = "Основные настройки этой группы" },
zh = { n = "高级 · %d", ch = "高级 · %d · 已更改 %d", back = "返回主要设置",
hint = "阈值和例外。在这里更改的值会留在主页面,直到下次启动",
back_hint = "此分组的主要设置" },
}
-- a section's own list (Page:fine)
local FINE = "Fine tuning · " -- + section id, a button's store label
local FTXT = {
en = { n = "Fine tuning · %d", ch = "Fine tuning · %d · changed %d", back = "‹ Back to %s",
hint = "Thresholds of this section. A value you change here stays in view until the next launch" },
ru = { n = "Тонкая настройка · %d", ch = "Тонкая настройка · %d · изменено %d", back = "‹ Назад к %s",
hint = "Пороги этой секции. Значение, которое вы здесь поменяете, останется на виду до следующего запуска" },
zh = { n = "微调 · %d", ch = "微调 · %d · 已更改 %d", back = "‹ 返回 %s",
hint = "此部分的阈值。在这里更改的值会一直显示,直到下次启动" },
}
local reg = rawget(_G, "__EVOLVE_LIB_REG") or {}
rawset(_G, "__EVOLVE_LIB_REG", reg)
-- survives a reload of this file: pages hold what they hid
local ST = reg.menu_form_state or { pages = {}, list = {}, of = {} }
reg.menu_form_state = ST
local function call(c, name, ...)
local ok, v = pcall(function(...) return c[name](c, ...) end, ...)
if ok then return v end
end
local function lang()
local ok, l = pcall(ui.menu_lang)
return ok and TXT[l] and l or "en"
end
local function menu_open()
local ok, v = pcall(ui.menu_open)
return not ok or v ~= false
end
-- what a row holds, as one string
local function value_of(c)
local t = call(c, "type")
-- a hotkey's get is "held now", its binding only shows through is_default
if t == "hotkey" then return tostring(call(c, "is_default")) end
if t == "button" or t == "label" then return "" end
local v = call(c, "get")
local tv = type(v)
if tv == "table" then
local p = {}
for k, x in pairs(v) do p[#p + 1] = tostring(k) .. "=" .. tostring(x) end
table.sort(p)
return table.concat(p, ",")
end
if tv == "userdata" then
local ok, s = pcall(function() return v.r .. "," .. v.g .. "," .. v.b .. "," .. v.a end)
return ok and s or tostring(call(c, "is_default"))
end
return tostring(v)
end
---@class MenuTracker
---@field base table
local Tracker = {}
Tracker.__index = Tracker
--- which rows moved by hand, against a snapshot. a config load has no event, so what moved
--- with the menu closed, or in a batch of 5+ between two looks, goes into the snapshot.
---@return MenuTracker
function M.tracker()
return setmetatable({ base = {}, last = {}, rows = {}, order = {}, moved = {}, n = 0, at = 0, open = false }, Tracker)
end
--- first sight of a row: its value goes into the snapshot
---@param key string
---@param c userdata
function Tracker:see(key, c)
if not self.rows[key] then self.order[#self.order + 1] = key end
self.rows[key] = c
if self.base[key] == nil then
local v = value_of(c)
self.base[key], self.last[key] = v, v
end
end
--- true when the row differs from the snapshot
---@param key string
---@param c userdata
---@return boolean
function Tracker:touched(key, c)
local b = self.base[key]
return b ~= nil and value_of(c) ~= b
end
local function look(self, key)
local v = value_of(self.rows[key])
if v ~= self.last[key] then
self.moved[key] = v
self.n = self.n + 1
self.last[key] = v
end
end
-- a look is over: what moved in it goes to the snapshot, or stays a hand change
local function settle(self, open)
local moved = self.moved
if not self.open or self.n >= MASS then
for key, v in pairs(moved) do self.base[key] = v end
end
self.moved, self.n, self.at, self.open = {}, 0, 0, open
return moved
end
--- one whole look; once on close, or often with few rows
---@param open boolean the menu is open
---@return table? moved key -> value
function Tracker:poll(open)
if not open and not self.open then return end
for i = self.at + 1, #self.order do look(self, self.order[i]) end
return settle(self, open)
end
--- part of a look, for a frame: a look at every row of every hero is a hitch
---@param n integer rows
---@return table? moved key -> value, when the look ended here
function Tracker:step(n)
local total = #self.order
local to = math.min(self.at + n, total)
for i = self.at + 1, to do look(self, self.order[i]) end
self.at = to
if to >= total then return settle(self, true) end
end
-- a script reload took the controls: handles go, what was the snapshot stays
local function forget(self)
self.rows, self.order, self.moved, self.n, self.at, self.open = {}, {}, {}, 0, 0, false
end
ST.tracker = ST.tracker and setmetatable(ST.tracker, Tracker) or M.tracker()
local T = ST.tracker
local function ref(tab, group, label)
local ok, c = pcall(ui.reference, tab, group, label)
if not (ok and c) then return nil end
-- a miss is an empty handle, not nil
if call(c, "type") == "none" or call(c, "label") == "" then return nil end
return c
end
local function fine_caption(P, F)
if not F.btn then return end
local X = FTXT[lang()]
if P.open_sid == F.sid then
call(F.btn, "caption", string.format(X.back, F.back or P.top))
else
call(F.btn, "caption", F.changed > 0 and string.format(X.ch, F.n, F.changed) or string.format(X.n, F.n))
end
call(F.btn, "hint", X.hint)
end
local function caption(P)
for _, F in pairs(P.fines or {}) do fine_caption(P, F) end
if not P.btn then return end
local X = TXT[lang()]
if P.open then
call(P.btn, "caption", X.back)
call(P.btn, "hint", X.back_hint)
else
call(P.btn, "caption", P.changed > 0 and string.format(X.ch, P.n, P.changed) or string.format(X.n, P.n))
call(P.btn, "hint", X.hint)
end
end
local function hide(P, key, c)
local h = P.hid[key]
if h then
h.c = c -- may be a new control under the old key
call(c, "visible", false)
call(c, "findable", true)
return
end
local info = call(c, "info")
-- a row its owner hid stays the owner's
if type(info) == "table" and info.visible == false then return end
call(c, "visible", false)
P.hid[key] = { c = c, findable = type(info) == "table" and info.findable == true }
call(c, "findable", true) -- off the page, not out of reach
end
local function show(P, key, c)
local h = P.hid[key]
if not h then return end
call(c, "visible", true)
call(c, "findable", h.findable)
P.hid[key] = nil
end
-- asks the menu every time: a handle from before a reload still reads, the control is gone
local function ensure_button(P)
local b = ref(P.tab, P.top, BTN)
if not b then
local id = P.id
local ok, made = pcall(ui.button, P.tab .. "." .. P.top, BTN, function()
local r = rawget(_G, "__EVOLVE_LIB_REG")
if r and r.menu_form then r.menu_form.toggle(id) end
end)
b = ok and made or nil
end
if b then call(b, "order", ORDER) end
P.btn = b
end
local function ensure_fine_button(P, F)
local b = ref(P.tab, F.group, FINE .. F.sid)
if not b then
local id, sid = P.id, F.sid
local ok, made = pcall(ui.button, P.tab .. "." .. F.group, FINE .. F.sid, function()
local r = rawget(_G, "__EVOLVE_LIB_REG")
if r and r.menu_form then r.menu_form.toggle_fine(id, sid) end
end)
b = ok and made or nil
end
if b then
call(b, "order", F.order)
call(b, "indent", 1)
end
F.btn = b
end
---@class MenuPage
local Page = {}
Page.__index = Page
--- the page of a top group; the same object on a second call
---@param tab string
---@param top string first segment of the group path
---@return MenuPage
function M.page(tab, top)
local id = tab .. "." .. top
local P = ST.pages[id]
if not P then
P = { id = id, tab = tab, top = top, adv = {}, keep = {}, keep_group = {}, hid = {},
open = false, n = 0, changed = 0 }
ST.pages[id] = P
ST.list[#ST.list + 1] = P
end
return setmetatable(P, Page)
end
--- mark a row as advanced. rows of subgroups go here too: the list swaps the whole top group
---@param group string full group path in the tab
---@param label string the row's store label
---@return MenuPage
function Page:advanced(group, label)
self.adv[group .. "\0" .. label] = true
return self
end
--- every row of the page goes to the list, a row added later too, except what main names.
--- for a page where the main rows are the short list
---@return MenuPage
function Page:advanced_rest()
self.rest = true
return self
end
--- a main row of an advanced_rest page. without a label: every row of the group
---@param group string full group path in the tab
---@param label? string
---@return MenuPage
function Page:main(group, label)
if label then self.keep[group .. "\0" .. label] = true else self.keep_group[group] = true end
return self
end
--- forget every main row named so far: for a page whose owner lays it out again
---@return MenuPage
function Page:reset_main()
self.keep, self.keep_group = {}, {}
return self
end
--- a section's own list: its rows leave the page for a button at the section's end ("Fine
--- tuning · N"), which swaps the page to the head of the section and these rows, and back. the
--- page's advanced list does not count them. a second call with the same id replaces the rows
---@param sid string section id, unique on the page
---@param keys string[] group .. "\0" .. label of each row
---@param opts? {group?: string, order?: integer, back_order?: integer, head?: string, back?: string}
--- group: where the button lives (default the top group); order: its draw order, back_order while
--- the list is open; head: a row kept in view while the list is open; back: the page name its
--- back caption prints
---@return MenuPage
function Page:fine(sid, keys, opts)
opts = opts or {}
self.fines = self.fines or {}
self.fine_of = self.fine_of or {}
local F = self.fines[sid]
if F then for k in pairs(F.keys) do if self.fine_of[k] == sid then self.fine_of[k] = nil end end end
F = F or { sid = sid, n = 0, changed = 0 }
F.keys, F.group, F.order, F.head, F.back = {}, opts.group or self.top, opts.order or ORDER - 1, opts.head, opts.back
F.back_order = opts.back_order or F.order
for _, k in ipairs(keys or {}) do F.keys[k] = true; self.fine_of[k] = sid end
self.fines[sid] = F
return self
end
--- rows whose value something else wrote: a move of theirs is not a hand
---@param fn fun(key: string): boolean key = group .. "\0" .. label
---@return MenuPage
function Page:quiet(fn)
self.is_quiet = fn
return self
end
-- the Keys page lists shown hotkeys only: a bound key off the page would drop out of it
local function bound(c)
return call(c, "type") == "hotkey" and call(c, "is_default") == false
end
local function stays(P, key, c)
if T:touched(key, c) and not (P.is_quiet and P.is_quiet(key)) then return true end
return bound(c)
end
-- "1.2.1 Net worth" -> "1.2.1"; "1. Call" -> "1"
local function number_of(label)
local n = label:match("^(%d[%d%.]*)%s")
return n and (n:gsub("%.$", "")) or nil
end
local function apply_page(P)
local ok, list = pcall(ui.group_controls, P.tab, P.top, true)
if not ok or type(list) ~= "table" then return end
local fine_of, fine = P.fine_of or {}, P.fines or {}
if P.open_sid and not fine[P.open_sid] then P.open_sid = nil end
local rows, heads, any, left = {}, {}, false, false
for _, c in ipairs(list) do
local l = call(c, "label")
if l and l ~= BTN and l:sub(1, #FINE) ~= FINE then
local g, name = tostring(call(c, "group")), tostring(l)
local key = g .. "\0" .. name
local num = number_of(name)
local adv = P.adv[key]
if P.rest and not P.keep[key] and not P.keep_group[g] then adv = true end
local sid = adv and fine_of[key] or nil
if adv then
any = true
T:see(key, c)
ST.of[key] = P
if not sid then
left = true
-- every number above it is a heading in the list
local up = num
while up do
up = up:match("^(.*)%.%d+$")
if up then heads[g .. "\0" .. up] = true end
end
end
end
rows[#rows + 1] = { key = key, c = c, adv = adv, fine = sid, head = num and g .. "\0" .. num }
end
end
if not any then return end
-- no way to the rows: leave them where they are
if left or P.open then
ensure_button(P)
if not P.btn then return end
else
P.btn = ref(P.tab, P.top, BTN)
end
for _, F in pairs(fine) do
if next(F.keys) then ensure_fine_button(P, F) else F.btn = nil end
F.n, F.changed = 0, 0
end
local open_head = P.open_sid and fine[P.open_sid].head
local count, changed = 0, 0
for _, r in ipairs(rows) do
local want
if P.open_sid then
want = r.fine == P.open_sid or r.key == open_head
elseif P.open then
-- a main switch heads its advanced rows, they have no card without it
want = (r.adv and not r.fine) or (r.head and heads[r.head])
else
want = not r.adv or stays(P, r.key, r.c)
end
if want then show(P, r.key, r.c) else hide(P, r.key, r.c) end
if r.adv then
local info = call(r.c, "info")
local here = P.hid[r.key] or (type(info) == "table" and info.visible ~= false)
local ch = call(r.c, "is_default") == false and not (P.is_quiet and P.is_quiet(r.key))
local F = r.fine and fine[r.fine]
if F then
if here then F.n = F.n + 1 end
if ch then F.changed = F.changed + 1 end
else
if here then count = count + 1 end
if ch then changed = changed + 1 end
end
end
end
P.n, P.changed = count, changed
if P.btn then call(P.btn, "visible", P.open or (not P.open_sid and count > 0)) end
for _, F in pairs(fine) do
if F.btn then
local mine = P.open_sid == F.sid
call(F.btn, "visible", mine or (not P.open_sid and not P.open and F.n > 0))
-- the way back stands above the section: a folded card would hide it
call(F.btn, "order", mine and F.back_order or F.order)
call(F.btn, "indent", mine and 0 or 1)
end
end
caption(P)
end
--- switch a page between its main rows and its advanced list
---@param id string "tab.top"
function M.toggle(id)
local P = ST.pages[id]
if not P then return end
P.open = not P.open
P.open_sid = nil
apply_page(P)
end
--- switch a page between its main rows and one section's list (Page:fine)
---@param id string "tab.top"
---@param sid string
function M.toggle_fine(id, sid)
local P = ST.pages[id]
if not P then return end
P.open_sid = P.open_sid ~= sid and sid or nil
P.open = false
apply_page(P)
end
--- bring every page in line; run after a menu rebuild
function M.apply()
for _, P in ipairs(ST.list) do apply_page(P) end
end
-- pages that hold a row that moved
local function redraw(moved)
if not moved or next(moved) == nil then return end
local done = {}
for key in pairs(moved) do
local P = ST.of[key]
if P and not done[P] then done[P] = true; apply_page(P) end
end
end
--- per frame. a click in the menu fires no on_change, so values are polled, part of the rows
--- each frame with the menu open. a list stays open over a menu close: the sub-page the player
--- stood on lives in it
function M.tick()
if not menu_open() then
if ST.was_open then
ST.was_open = false
redraw(T:poll(false))
end
return
end
ST.was_open = true
redraw(T:step(STEP))
local L = lang()
if L ~= ST.lang then
ST.lang = L
for _, P in ipairs(ST.list) do caption(P) end
end
end
--- give every hidden row back: the script that owns the pages is going away. a reload takes
--- the controls with it, so no handle is kept; the next apply finds or makes them again
function M.release()
for _, P in ipairs(ST.list) do
for key, h in pairs(P.hid) do show(P, key, h.c) end
P.open, P.btn, P.open_sid = false, nil, nil
for _, F in pairs(P.fines or {}) do F.btn = nil end
end
forget(T)
end
M.BTN = BTN
M.FINE = FINE
reg.menu_form = M
return M
pack.json2 lines{ "kind": "lib", "version": "1.2.0" }
No release notes recorded for this listing.
No published script needs it yet.
How many seconds a unit dropped at a point stays out of reach of what saves him: his towers, his fountain, his heroes walking at him, and his heroes in the fog from where `lib/fog_seen` last put them
"requires": { "escape": "^1.0" }local escape = require("lib/escape")Moves a saved setting to a new default once, if it still holds the old one.
"requires": { "config_defaults": "^1.0" }local config_defaults = require("lib/config_defaults")Screen presets for what evolve draws: "minimum", "usual" and "everything" over the groups the menu lists under the "screen" category (sections over heroes, map, timers, panels; the camera section is l
"requires": { "screen_presets": "^1.0" }local screen_presets = require("lib/screen_presets")