-- Copyright 2025 David Vazgenovich Shakaryan local util = require('util') local state = {} local mt = {} mt.__index = mt local menu_mt = {} menu_mt.__index = menu_mt function state.new() return setmetatable({ menus = {}, depth = 0, favourites = {}, playing_id = nil, }, mt) end function mt:menu() return self.menus[self.depth] end function mt:push_menu(t) local menu = setmetatable({ options = {}, cursor = 1, view_top = 1, }, menu_mt) for k, v in pairs(t) do menu[k] = v end if menu.type == 'search' then menu.search_options = menu.options menu.search_text = menu.search_text or '' menu.search_term = menu.search_term or '' menu.search_cursor = menu.search_cursor or #menu.search_text + 1 menu:update_search_matches() end self.depth = self.depth + 1 self.menus[self.depth] = menu return menu end -- returns index if found function mt:favourited(id) for i, v in ipairs(self.favourites) do if v == id then return i end end end function mt:add_favourite(id) self.favourites[#self.favourites+1] = id end function mt:remove_favourite_at(i) table.remove(self.favourites, i) end -- inserts the given id into the favourites array before the next favourited -- menu option, starting from the next cursor position, or the end if no such -- option is found. this is meant for in-place favouriting from the favourites -- menu. function mt:insert_favourite_before_next_in_menu(id) local menu = self:menu() for i = menu.cursor+1, #menu.options do local ind = self:favourited(menu.options[i].id) if ind then table.insert(self.favourites, ind, id) return end end self:add_favourite(id) end function menu_mt:save_checkpoint() self.checkpoint = { search_text = self.search_text, search_term = self.search_term, search_filter = self.search_filter, cursor = self.cursor, view_top = self.view_top, } end function menu_mt:restore_checkpoint() local t = self.checkpoint self:set_search_text( t.search_text, nil, t.search_term, t.search_filter) self.cursor = t.cursor self.view_top = t.view_top end function menu_mt:set_cursor(pos, lines, opts) local pos = math.max(1, math.min(pos, #self.options)) local top = self.view_top local margin = 0 if not lines or lines < 2 then top = pos goto update end if opts and opts.margin then margin = math.min(opts.margin, math.floor((lines - 1) / 2)) end if opts and opts.centre then top = pos - math.floor((lines - 1) / 2) elseif opts and opts.keep_offset then top = top + pos - self.cursor elseif opts and opts.view_top then top = opts.view_top elseif margin > 0 then margin = math.max(0, math.min( margin, self.cursor - self.view_top, self.view_top + lines - 1 - self.cursor)) end -- keep selected option visible and view in bounds top = math.max(pos - lines + 1 + margin, math.min(top, pos - margin)) top = math.max(1, math.min(top, #self.options - lines + 1)) ::update:: if pos == self.cursor and top == self.view_top then return false end self.cursor = pos self.view_top = top return true end function menu_mt:options_key() return self.type == 'search' and 'search_options' or 'options' end function menu_mt:set_sort(f) local key = self:options_key() local orig_key = 'orig_' .. key if f then if not self[orig_key] then self[orig_key] = self[key] end self[key] = util.copy_table(self[orig_key]) f(self[key]) elseif self.sort_f then self[key] = self[orig_key] self[orig_key] = nil end self.sort_f = f if self.type == 'search' then self:update_search_matches() end end function menu_mt:is_sorted() return self.sort_f ~= nil end function menu_mt:master_options() local key = self:options_key() return self.sort_f and self['orig_' .. key] or self[key] end function menu_mt:set_options(options) local key = self:options_key() if self.sort_f then self['orig_' .. key] = options self[key] = util.copy_table(options) self.sort_f(self[key]) else self[key] = options end if self.type == 'search' then self:update_search_matches() end -- previous position could have become invalid. reset to top and let -- caller deal with remembering position if it is needed. self:set_cursor(1) end function menu_mt:set_search_cursor(pos) local pos = math.max(1, math.min(#self.search_text + 1, pos)) if pos == self.search_cursor then return false end self.search_cursor = pos return true end function menu_mt:set_search_text(str, pos, term, filter) self.search_text = str self.search_term = term self.search_filter = filter self:set_search_cursor(pos or self.search_cursor) self:update_search_matches() end function menu_mt:_match_fields(v, fields, case_sensitive) local nm = fields.name and util.find_matches( v.name, self.search_term, case_sensitive) local im = fields.info and util.find_matches( v.info, self.search_term, case_sensitive) if nm or im then -- search options may contain dynamic data that is updated on -- redraw. using a proxy table instead of copying prevents -- potential updates on every change of search text. return setmetatable( {matches = nm, info_matches = im}, {__index = v}) end end local F_NAME = {name = true} function menu_mt:update_search_matches() if not self.search_filter and #self.search_term == 0 then self.options = self.search_options return end -- no utf8 :( local case_sensitive = not not self.search_term:find('%u') local options = {} for _, v in ipairs(self.search_options) do local fields = not self.search_filter and F_NAME or self.search_filter(v) if fields then options[#options+1] = self.search_term == '' and v or self:_match_fields(v, fields, case_sensitive) end end self.options = options end return state