-- 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_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:set_cursor(pos, lines, opts) local pos = math.max(1, math.min(pos, #self.options)) local top = self.view_top if not lines then top = pos goto update 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 end -- move view to keep selected option visible if pos < top then top = pos elseif pos > top + lines - 1 then top = pos - lines + 1 end 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:set_sort(bool, f) if not self.sorted == not bool then return end local key = self.type == 'search' and 'search_options' or 'options' if bool then self['orig_' .. key] = self[key] self[key] = util.copy_table(self[key]) f(self[key]) else self[key] = self['orig_' .. key] self['orig_' .. key] = nil end if self.type == 'search' then self:update_search_matches() end self.sorted = bool 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) self.search_text = str self:update_search_matches() end function menu_mt:update_search_matches() if #self.search_text == 0 then self.options = self.search_options return end -- no utf8 :( local case_sensitive = not not self.search_text:find('%u') local options = {} for _, v in ipairs(self.search_options) do local matches = {} local name = v.name if not case_sensitive then name = name:lower() end local i, j = 0, 0 while true do i, j = name:find(self.search_text, j + 1, true) if not i then break end matches[#matches+1] = {start = i, stop = j} end if #matches > 0 then local t = util.copy_table(v) t.matches = matches options[#options+1] = t end end self.options = options end return state