local Element = require('elements/Element') ---@alias MenuAction {name: string; icon: string; label?: string; filter_hidden?: boolean;} -- Menu data structure accepted by `Menu:open(menu)`. ---@alias MenuData {id?: string; type?: string; title?: string; hint?: string; footnote: string; search_style?: 'on_demand' | 'palette' | 'disabled'; item_actions?: MenuAction[]; item_actions_place?: 'inside' | 'outside'; callback?: string[]; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataChild[]; selected_index?: integer; on_search?: string|string[]; on_paste?: string|string[]; on_move?: string|string[]; on_close?: string|string[]; search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string} ---@alias MenuDataChild MenuDataItem|MenuData ---@alias MenuDataItem {title?: string; hint?: string; icon?: string; value: any; actions?: MenuAction[]; actions_place?: 'inside' | 'outside'; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'} ---@alias MenuOptions {mouse_nav?: boolean;} -- Internal data structure created from `MenuData`. ---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; footnote: string; search_style?: 'on_demand' | 'palette' | 'disabled'; item_actions?: MenuAction[]; item_actions_place?: 'inside' | 'outside'; callback?: string[]; selected_index?: number; action_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackChild[]; on_search?: string|string[]; on_paste?: string|string[]; on_move?: string|string[]; on_close?: string|string[]; search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string} ---@alias MenuStackChild MenuStackItem|MenuStack ---@alias MenuStackItem {title?: string; hint?: string; icon?: string; value: any; actions?: MenuAction[]; actions_place?: 'inside' | 'outside'; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; title_width: number; hint_width: number; ass_safe_hint?: string} ---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean} ---@alias Search {query: string; timeout: unknown; min_top: number; max_width: number; source: {width: number; top: number; scroll_y: number; selected_index?: integer; items?: MenuStackChild[]}} ---@alias MenuEventActivate {type: 'activate'; index: number; value: any; action?: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean; is_pointer: boolean; keep_open?: boolean; menu_id: string;} ---@alias MenuEventMove {type: 'move'; from_index: number; to_index: number; menu_id: string;} ---@alias MenuEventSearch {type: 'search'; query: string; menu_id: string;} ---@alias MenuEventKey {type: 'key'; id: string; key: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean; menu_id: string; selected_item?: {index: number; value: any; action?: string;}} ---@alias MenuEventPaste {type: 'paste'; value: string; menu_id: string; selected_item?: {index: number; value: any; action?: string;}} ---@alias MenuEventBack {type: 'back';} ---@alias MenuEventClose {type: 'close';} ---@alias MenuEvent MenuEventActivate | MenuEventMove | MenuEventSearch | MenuEventKey | MenuEventPaste | MenuEventBack | MenuEventClose ---@alias MenuCallback fun(data: MenuEvent) ---@class Menu : Element local Menu = class(Element) ---@param data MenuData ---@param callback MenuCallback ---@param opts? MenuOptions function Menu:open(data, callback, opts) local open_menu = Menu:is_open() if open_menu then open_menu.is_being_replaced = true open_menu:close(true) end return Menu:new(data, callback, opts) end ---@param menu_type? string ---@return Menu|nil function Menu:is_open(menu_type) return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil end ---@param immediate? boolean Close immediately without fadeout animation. ---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed. ---@overload fun(callback: fun()) function Menu:close(immediate, callback) if type(immediate) ~= 'boolean' then callback = immediate end local menu = self == Menu and Elements.menu or self if menu and not menu.destroyed then if menu.is_closing then menu:tween_stop() return end local function close() local on_close = menu.root.on_close -- removed in menu:destroy() Elements:remove('menu') -- calls menu:destroy() under the hood Elements:update_proximities() cursor:queue_autohide() -- Call :close() callback if callback then callback() end -- Call callbacks/events defined on menu config local close_event = {type = 'close'} if not on_close or menu:command_or_event(on_close, {}, close_event) ~= 'event' then menu.callback(close_event) end request_render() end menu.is_closing = true if immediate then close() else menu:fadeout(close) end end end ---@param data MenuData ---@param callback MenuCallback ---@param opts? MenuOptions ---@return Menu function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end ---@param data MenuData ---@param callback MenuCallback ---@param opts? MenuOptions function Menu:init(data, callback, opts) Element.init(self, 'menu', {render_order = 1001}) -----@type fun() self.callback = callback self.opts = opts or {} self.offset_x = 0 -- Used for submenu transition animation. self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items self.item_height = nil self.min_width = nil self.item_spacing = 1 self.item_padding = nil self.separator_size = nil self.padding = nil self.gap = nil self.font_size = nil self.font_size_hint = nil self.scroll_step = nil -- Item height + item spacing. self.scroll_height = nil -- Items + spacings - container height. self.opacity = 0 -- Used to fade in/out. self.type = data.type ---@type MenuStack Root MenuStack. self.root = nil ---@type MenuStack Current MenuStack. self.current = nil ---@type MenuStack[] All menus in a flat array. self.all = nil ---@type table Map of submenus by their ids, such as `'Tools > Aspect ratio'`. self.by_id = {} self.type_to_search = options.menu_type_to_search self.is_being_replaced = false self.is_closing = false self.drag_last_y = nil self.is_dragging = false if utils.shared_script_property_set then utils.shared_script_property_set('uosc-menu-type', self.type or 'undefined') end mp.set_property_native('user-data/uosc/menu/type', self.type or 'undefined') self:update(data) for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu.id) end if self.mouse_nav then self.current.selected_index = nil end self:tween_property('opacity', 0, 1) self:enable_key_bindings() Elements:maybe('curtain', 'register', self.id) end function Menu:destroy() Element.destroy(self) self.is_closing = false if not self.is_being_replaced then Elements:maybe('curtain', 'unregister', self.id) end if utils.shared_script_property_set then utils.shared_script_property_set('uosc-menu-type', nil) end mp.set_property_native('user-data/uosc/menu/type', nil) end ---@param data MenuData function Menu:update(data) local new_root = {is_root = true, submenu_path = {}} local new_all = {} local new_menus = {} -- menus that didn't exist before this `update()` local new_by_id = {} local menus_to_serialize = {{new_root, data}} local old_current_id = self.current and self.current.id local menu_state_props = {'selected_index', 'action_index', 'scroll_y', 'fling', 'search'} local internal_props_set = create_set(itable_append({'is_root', 'submenu_path', 'id', 'items'}, menu_state_props)) table_assign_exclude(new_root, data, internal_props_set) local i = 0 while i < #menus_to_serialize do i = i + 1 local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2] local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id if menu_data.id then menu.id = menu_data.id elseif not menu.is_root then menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i) else menu.id = '{root}' end menu.icon = 'chevron_right' -- Normalize `search_debounce` if type(menu_data.search_debounce) == 'number' then menu.search_debounce = math.max(0, menu_data.search_debounce --[[@as number]]) elseif menu_data.search_debounce == 'submit' then menu.search_debounce = 'submit' else menu.search_debounce = menu.on_search and 300 or 0 end -- Update items local first_active_index = nil menu.items = { {title = t('Empty'), value = 'ignore', italic = 'true', muted = 'true', selectable = false, align = 'center'}, } for i, item_data in ipairs(menu_data.items or {}) do if item_data.active and not first_active_index then first_active_index = i end local item = {} table_assign_exclude(item, item_data, internal_props_set) if item.keep_open == nil then item.keep_open = menu.keep_open end -- Submenu if item_data.items then item.parent_menu = menu item.submenu_path = itable_join(menu.submenu_path, {i}) menus_to_serialize[#menus_to_serialize + 1] = {item, item_data} end menu.items[i] = item end if menu.is_root then menu.selected_index = menu_data.selected_index or first_active_index end -- Retain old state local old_menu = self.by_id[menu.id] if old_menu then table_assign_props(menu, old_menu, menu_state_props) else new_menus[#new_menus + 1] = menu end new_all[#new_all + 1] = menu new_by_id[menu.id] = menu end self.root, self.all, self.by_id = new_root, new_all, new_by_id self.current = self.by_id[old_current_id] or self.root self:update_content_dimensions() self:reset_navigation() -- Ensure palette menus have active searches, and clean empty searches from menus that lost the `palette` flag local update_dimensions_again = false for _, menu in ipairs(self.all) do local is_palette = menu.search_style == 'palette' if not menu.search and (is_palette or (menu.search_suggestion and itable_index_of(new_menus, menu))) then update_dimensions_again = true self:search_init(menu.id) elseif not is_palette and menu.search and menu.search.query == '' then update_dimensions_again = true menu.search = nil end end -- We update before _and_ after because search_inits need the initial un-searched -- menu's position and scroll state to save on the `search.source` table. if update_dimensions_again then self:update_content_dimensions() self:reset_navigation() end -- Apply search suggestions for _, menu in ipairs(new_menus) do if menu.search_suggestion then menu.search.query = menu.search_suggestion end end for _, menu in ipairs(self.all) do if menu.search then -- the menu items are new objects and the search needs to contain those menu.search.source.items = not menu.on_search and menu.items or nil -- Only internal searches are immediately submitted if not menu.on_search then self:search_internal(menu.id, true) end end if menu.selected_index then self:select_by_offset(0, menu) end end self:search_ensure_key_bindings() end ---@param items MenuDataChild[] function Menu:update_items(items) local data = table_assign({}, self.root) data.items = items self:update(data) end function Menu:update_content_dimensions() self.item_height = round(options.menu_item_height * state.scale) self.min_width = round(options.menu_min_width * state.scale) self.separator_size = round(1 * state.scale) self.scrollbar_size = round(2 * state.scale) self.padding = round(options.menu_padding * state.scale) self.gap = round(2 * state.scale) self.font_size = round(self.item_height * 0.48 * options.font_scale) self.font_size_hint = self.font_size - 1 self.item_padding = round((self.item_height - self.font_size) * 0.6) self.scroll_step = self.item_height + self.item_spacing local title_opts = {size = self.font_size, italic = false, bold = false} local hint_opts = {size = self.font_size_hint} for _, menu in ipairs(self.all) do title_opts.bold, title_opts.italic = true, false local max_width = text_width(menu.title, title_opts) + 2 * self.padding + 2 * self.item_padding -- Estimate width of a widest item for _, item in ipairs(menu.items) do local icon_width = item.icon and self.font_size or 0 item.title_width = text_width(item.title, title_opts) item.hint_width = text_width(item.hint, hint_opts) local spacings_in_item = 1 + (item.title_width > 0 and 1 or 0) + (item.hint_width > 0 and 1 or 0) + (icon_width > 0 and 1 or 0) local estimated_width = item.title_width + item.hint_width + icon_width + (self.item_padding * spacings_in_item) if estimated_width > max_width then max_width = estimated_width end end menu.max_width = max_width + 2 * self.padding end self:update_dimensions() end function Menu:update_dimensions() -- Coordinates and sizes are of the scrollable area. Title is rendered -- above it, so we need to account for that in max_height and ay position. -- This is a debt from an era where we had different cursor event handling, -- and dumb titles with no search inputs. It could use a refactor. local margin = round(self.item_height / 2) local external_buttons_reserve = display.width / self.item_height > 14 and self.scroll_step * 6 - margin * 2 or 0 local width_available = display.width - margin * 2 - external_buttons_reserve local height_available = display.height - margin * 2 local min_width = math.min(self.min_width, width_available) for _, menu in ipairs(self.all) do local width = math.max(menu.search and menu.search.max_width or 0, menu.max_width) menu.width = round(clamp(min_width, width, width_available)) local title_height = (menu.is_root and menu.title or menu.search) and self.scroll_step + self.padding or 0 local footnote_height = self.font_size * 1.5 local max_height = height_available - title_height - footnote_height local content_height = self.scroll_step * #menu.items menu.height = math.min(content_height - self.item_spacing, max_height) menu.top = clamp( title_height + margin, menu.search and math.min(menu.search.min_top, menu.search.source.top) or height_available, round((height_available - menu.height + title_height) / 2) ) if menu.search then menu.search.min_top = math.min(menu.search.min_top, menu.top) menu.search.max_width = math.max(menu.search.max_width, menu.width) end menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0) self:set_scroll_to(menu.scroll_y, menu.id) -- clamps scroll_y to scroll limits end self:update_coordinates() end -- Updates element coordinates to match currently open (sub)menu. function Menu:update_coordinates() local ax = round((display.width - self.current.width) / 2) + self.offset_x self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height) end function Menu:reset_navigation() local menu = self.current -- Reset indexes and scroll self:set_scroll_to(menu.scroll_y) -- clamps scroll_y to scroll limits if menu.items and #menu.items > 0 then -- Normalize existing selected_index always, and force it only in keyboard navigation if not self.mouse_nav then self:select_by_offset(0) end else self:select_index(nil) end -- Walk up the parent menu chain and activate items that lead to current menu local parent = menu.parent_menu while parent do parent.selected_index = itable_index_of(parent.items, menu) menu, parent = parent, parent.parent_menu end request_render() end function Menu:set_offset_x(offset) local delta = offset - self.offset_x self.offset_x = offset self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by) end function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end -- If `menu_id` is provided, will return menu with that id or `nil`. If `menu_id` is `nil`, will return current menu. ---@param menu_id? string ---@return MenuStack | nil function Menu:get_menu(menu_id) return menu_id == nil and self.current or self.by_id[menu_id] end function Menu:get_first_active_index(menu_id) local menu = self:get_menu(menu_id) if not menu then return end for index, item in ipairs(menu.items) do if item.active then return index end end end ---@param pos? number ---@param menu_id? string function Menu:set_scroll_to(pos, menu_id) local menu = self:get_menu(menu_id) if not menu then return end menu.scroll_y = clamp(0, pos or 0, menu.scroll_height) request_render() end ---@param delta? number ---@param menu_id? string function Menu:set_scroll_by(delta, menu_id) local menu = self:get_menu(menu_id) if not menu then return end self:set_scroll_to(menu.scroll_y + delta, menu_id) end ---@param pos? number ---@param menu_id? string ---@param fling_options? table function Menu:scroll_to(pos, menu_id, fling_options) local menu = self:get_menu(menu_id) if not menu then return end menu.fling = { y = menu.scroll_y, distance = clamp(-menu.scroll_y, pos - menu.scroll_y, menu.scroll_height - menu.scroll_y), time = mp.get_time(), duration = 0.1, easing = ease_out_sext, } if fling_options then table_assign(menu.fling, fling_options) end request_render() end ---@param delta? number ---@param menu_id? string ---@param fling_options? Fling function Menu:scroll_by(delta, menu_id, fling_options) local menu = self:get_menu(menu_id) if not menu then return end self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu_id, fling_options) end ---@param index? integer ---@param menu_id? string ---@param immediate? boolean function Menu:scroll_to_index(index, menu_id, immediate) local menu = self:get_menu(menu_id) if not menu then return end if (index and index >= 1 and index <= #menu.items) then local position = round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2)) if immediate then self:set_scroll_to(position, menu_id) else self:scroll_to(position, menu_id) end end end ---@param index? integer ---@param menu_id? string function Menu:select_index(index, menu_id) local menu = self:get_menu(menu_id) if not menu then return end menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil self:select_action(menu.action_index, menu_id) -- normalize selected action index request_render() end ---@param index? integer ---@param menu_id? string function Menu:select_action(index, menu_id) local menu = self:get_menu(menu_id) if not menu then return end local actions = menu.items[menu.selected_index] and menu.items[menu.selected_index].actions or menu.item_actions if not index or not actions or type(actions) ~= 'table' or index < 1 or index > #actions then menu.action_index = nil return end menu.action_index = index request_render() end ---@param delta? integer ---@param menu_id? string function Menu:navigate_action(delta, menu_id) local menu = self:get_menu(menu_id) if not menu then return end local actions = menu.items[menu.selected_index] and menu.items[menu.selected_index].actions or menu.item_actions if actions and delta ~= 0 then -- Circular navigation where zero gets converted to nil local index = (menu.action_index or (delta > 0 and 0 or #actions + 1)) + delta self:select_action(index <= #actions and index > 0 and (index - 1) % #actions + 1 or nil, menu_id) else self:select_action(nil, menu_id) end request_render() end function Menu:next_action() self:navigate_action(1) end function Menu:prev_action() self:navigate_action(-1) end ---@param value? any ---@param menu_id? string function Menu:select_value(value, menu_id) local menu = self:get_menu(menu_id) if not menu then return end local index = itable_find(menu.items, function(item) return item.value == value end) self:select_index(index) end ---@param menu_id? string function Menu:deactivate_items(menu_id) local menu = self:get_menu(menu_id) if not menu then return end for _, item in ipairs(menu.items) do item.active = false end request_render() end ---@param index? integer ---@param menu_id? string function Menu:activate_index(index, menu_id) local menu = self:get_menu(menu_id) if not menu then return end if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end request_render() end ---@param index? integer ---@param menu_id? string function Menu:activate_one_index(index, menu_id) local menu = self:get_menu(menu_id) if not menu then return end self:activate_index(index, menu_id) end ---@param value? any ---@param menu_id? string function Menu:activate_value(value, menu_id) local menu = self:get_menu(menu_id) if not menu then return end local index = itable_find(menu.items, function(item) return item.value == value end) self:activate_index(index, menu_id) end ---@param value? any ---@param menu_id? string function Menu:activate_one_value(value, menu_id) local menu = self:get_menu(menu_id) if not menu then return end local index = itable_find(menu.items, function(item) return item.value == value end) self:activate_one_index(index, menu_id) end ---@param id string One of menus in `self.all`. function Menu:activate_menu(id) local menu = self:get_menu(id) if menu then self.current = menu self:update_coordinates() self:reset_navigation() self:search_ensure_key_bindings() local parent = menu.parent_menu while parent do parent.selected_index = itable_index_of(parent.items, menu) self:scroll_to_index(parent.selected_index, parent) menu, parent = parent, parent.parent_menu end request_render() end end ---@param index? integer ---@param menu_id? string function Menu:delete_index(index, menu_id) local menu = self:get_menu(menu_id) if not menu then return end if (index and index >= 1 and index <= #menu.items) then table.remove(menu.items, index) self:update_content_dimensions() self:scroll_to_index(menu.selected_index, menu_id) end end ---@param value? any ---@param menu_id? string function Menu:delete_value(value, menu_id) local menu = self:get_menu(menu_id) if not menu then return end local index = itable_find(menu.items, function(item) return item.value == value end) self:delete_index(index) end ---@param id string Menu id. ---@param x number `x` coordinate to slide from. function Menu:slide_in_menu(id, x) local menu = self:get_menu(id) if not menu then return end self:activate_menu(id) self:tween(-(display.width / 2 - menu.width / 2 - x), 0, function(offset) self:set_offset_x(offset) end) self.opacity = 1 -- in case tween above canceled fade in animation end function Menu:back() if not self:is_alive() then return end local current = self.current local parent = current.parent_menu if parent then self:slide_in_menu(parent.id, display.width / 2 - current.width / 2 - parent.width / 2 + self.offset_x) else self.callback({type = 'back'}) end end ---@param shortcut? Shortcut ---@param is_pointer? boolean Whether this was called by a pointer. function Menu:activate_selected_item(shortcut, is_pointer) local menu = self.current local item = menu.items[menu.selected_index] if item then -- Is submenu if item.items then if not self.mouse_nav then self:select_index(1, item.id) end self:activate_menu(item.id) self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end) self.opacity = 1 -- in case tween above canceled fade in animation else local actions = item.actions or menu.item_actions local action = actions and actions[menu.action_index] self.callback({ type = 'activate', index = menu.selected_index, value = item.value, is_pointer = is_pointer == true, action = action and action.name, keep_open = item.keep_open or menu.keep_open, modifiers = shortcut and shortcut.modifiers or nil, alt = shortcut and shortcut.alt or false, ctrl = shortcut and shortcut.ctrl or false, shift = shortcut and shortcut.shift or false, menu_id = menu.id, }) end end end ---@param index integer function Menu:move_selected_item_to(index) if self.current.search then return end -- Moving filtered items is an undefined behavior local callback = self.current.on_move local from, items_count = self.current.selected_index, self.current.items and #self.current.items or 0 if callback and from and from ~= index and index >= 1 and index <= items_count then local event = {type = 'move', from_index = from, to_index = index, menu_id = self.current.id} self:command_or_event(callback, {from, index, self.current.id}, event) self:select_index(index, self.current.id) self:scroll_to_index(index, self.current.id, true) end end ---@param delta number function Menu:move_selected_item_by(delta) local current_index, items_count = self.current.selected_index, self.current.items and #self.current.items or 0 if current_index and items_count > 1 then local new_index = clamp(1, current_index + delta, items_count) if current_index ~= new_index then self:move_selected_item_to(new_index) end end end function Menu:on_display() self:update_dimensions() end function Menu:on_prop_fullormaxed() self:update_content_dimensions() end function Menu:on_options() self:update_content_dimensions() end function Menu:handle_cursor_down() if self.proximity_raw == 0 then self.drag_last_y = cursor.y self.current.fling = nil else self:close() end end ---@param shortcut? Shortcut function Menu:handle_cursor_up(shortcut) if self.proximity_raw == 0 and self.drag_last_y and not self.is_dragging then self:activate_selected_item(shortcut, true) end if self.is_dragging then local distance = cursor:get_velocity().y / -3 if math.abs(distance) > 50 then self.current.fling = { y = self.current.scroll_y, distance = distance, time = cursor.history:head().time, easing = ease_out_quart, duration = 0.5, update_cursor = true, } request_render() end end self.is_dragging = false self.drag_last_y = nil end function Menu:on_global_mouse_move() self.mouse_nav = true if self.drag_last_y then self.is_dragging = self.is_dragging or math.abs(cursor.y - self.drag_last_y) >= 10 local distance = self.drag_last_y - cursor.y if distance ~= 0 then self:set_scroll_by(distance) end if self.is_dragging then self.drag_last_y = cursor.y end end request_render() end function Menu:handle_wheel_up() self:scroll_by(self.scroll_step * -3, nil, {update_cursor = true}) end function Menu:handle_wheel_down() self:scroll_by(self.scroll_step * 3, nil, {update_cursor = true}) end ---@param offset integer ---@param menu? MenuStack function Menu:select_by_offset(offset, menu) menu = menu or self.current -- Blur selected_index when navigating off bounds and submittable search is active. -- Blurred selected_index is an implied focused input, so enter can submit it. if menu.search and menu.search_debounce == 'submit' and ( (menu.selected_index == 1 and offset < 0) or (menu.selected_index == #menu.items and offset > 0) ) then self:select_index(nil, menu.id) else local index = clamp(1, (menu.selected_index or offset >= 0 and 0 or #menu.items + 1) + offset, #menu.items) local prev_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index, 1) local next_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index) if prev_index and next_index then if offset == 0 then self:select_index(index - prev_index <= next_index - index and prev_index or next_index, menu.id) elseif offset > 0 then self:select_index(next_index, menu.id) else self:select_index(prev_index, menu.id) end else self:select_index(prev_index or next_index or nil, menu.id) end end request_render() end ---@param offset integer ---@param immediate? boolean function Menu:navigate_by_offset(offset, immediate) self:select_by_offset(offset) if self.current.selected_index then self:scroll_to_index(self.current.selected_index, self.current.id, immediate) end end function Menu:paste() local menu = self.current local payload = get_clipboard() if not payload then return end if menu.on_paste then local selected_item = menu.items and menu.selected_index and menu.items[menu.selected_index] local actions = selected_item and selected_item.actions or menu.item_actions local selected_action = actions and menu.action_index and actions[menu.action_index] self:command_or_event(menu.on_paste, {payload, menu.id}, { type = 'paste', value = payload, menu_id = menu.id, selected_item = selected_item and { index = menu.selected_index, value = selected_item.value, action = selected_action, }, }) elseif menu.search then self:search_query_update(menu.search.query .. payload) elseif menu.search_style ~= 'disabled' then self:search_start(menu.id) self:search_query_update(payload, menu.id) end end ---@param menu_id string ---@param no_select_first? boolean function Menu:search_internal(menu_id, no_select_first) local menu = self:get_menu(menu_id) if not menu then return end local query = menu.search.query:lower() if query == '' then -- Reset menu state to what it was before search for key, value in pairs(menu.search.source) do menu[key] = value end else -- Inherit `search_submenus` from parent menus local search_submenus, parent_menu = menu.search_submenus, menu.parent_menu while not search_submenus and parent_menu do search_submenus, parent_menu = parent_menu.search_submenus, parent_menu.parent_menu end menu.items = search_items(menu.search.source.items, query, search_submenus) -- Select 1st item in search results if not no_select_first then menu.scroll_y = 0 self:select_index(1, menu_id) end end self:update_content_dimensions() end ---@param items MenuStackChild[] ---@param query string ---@param recursive? boolean ---@param prefix? string ---@return MenuStackChild[] function search_items(items, query, recursive, prefix) local result = {} local concat = table.concat for _, item in ipairs(items) do if item.selectable ~= false then local prefixed_title = prefix and prefix .. ' / ' .. (item.title or '') or item.title if item.items and recursive then itable_append(result, search_items(item.items, query, recursive, prefixed_title)) else local title = item.title and item.title:lower() local hint = item.hint and item.hint:lower() local initials_title = title and concat(initials(title)) --[[@as string]] local romanization = need_romanization() if romanization then ligature_conv_title = title and char_conv(title, true) initials_conv_title = title and concat(initials(char_conv(title, false))) end if title and title:find(query, 1, true) or title and romanization and ligature_conv_title:find(query, 1, true) or hint and hint:find(query, 1, true) or title and initials_title:find(query, 1, true) or title and romanization and initials_conv_title:find(query, 1, true) or hint and concat(initials(hint)):find(query, 1, true) then item = table_assign({}, item) item.title = prefixed_title item.ass_safe_title = nil result[#result + 1] = item end end end end return result end ---@param menu_id? string function Menu:search_submit(menu_id) local menu = self:get_menu(menu_id) if not menu or not menu.search then return end local callback, query = menu.on_search, menu.search.query if callback then self:command_or_event(callback, {query, menu.id}, {type = 'search', query = query, menu_id = menu.id}) else self:search_internal(menu.id) end end ---@param query string ---@param menu_id? string ---@param immediate? boolean function Menu:search_query_update(query, menu_id, immediate) local menu = self:get_menu(menu_id) if not menu or not menu.search then return end menu.search.query = query if menu.search_debounce ~= 'submit' then if menu.search.timeout then menu.search.timeout:kill() end if menu.search.timeout and not immediate then menu.search.timeout:resume() else self:search_submit(menu_id) end else -- `search_debounce='submit'` behavior: We blur selected item when query -- changes to let [enter] key submit searches instead of activating items. self:select_index(nil, menu.id) end request_render() end ---@param event? string ---@param word_mode? boolean Delete by words. function Menu:search_backspace(event, word_mode) local pos, old_query = #self.current.search.query, self.current.search.query local is_palette = self.current.search_style == 'palette' if word_mode and #old_query > 1 then local word_pat, other_pat = '[^%c%s%p]+$', '[%c%s%p]+$' local init_pat = old_query:sub(#old_query):match(word_pat) and word_pat or other_pat -- First we match all same type consecutive chars at the end local tail = old_query:match(init_pat) or '' -- If there's only one, we extend the tail with opposite type chars if tail and #tail == 1 then tail = tail .. old_query:sub(1, #old_query - #tail):match(init_pat == word_pat and other_pat or word_pat) end pos = pos - #tail else -- The while loop is for skipping utf8 continuation bytes while pos > 1 and old_query:byte(pos) >= 0x80 and old_query:byte(pos) <= 0xbf do pos = pos - 1 end pos = pos - 1 end local new_query = old_query:sub(1, pos) if new_query ~= old_query and (is_palette or not self.type_to_search or pos > 0) then self:search_query_update(new_query) elseif not is_palette and self.type_to_search then self:search_cancel() elseif is_palette and event ~= 'repeat' then self:back() end end function Menu:search_text_input(info) local menu = self.current if not menu.search and menu.search_style == 'disabled' then return end if info.event ~= 'up' then local key_text = info.key_text if not key_text then -- might be KP0 to KP9 or KP_DEC key_text = info.key_name:match('KP_?(.+)') if not key_text then return end if key_text == 'DEC' then key_text = '.' end end if not menu.search then self:search_start() end self:search_query_update(menu.search.query .. key_text) end end ---@param menu_id? string function Menu:search_cancel(menu_id) local menu = self:get_menu(menu_id) if not menu or not menu.search or menu.search_style == 'palette' then return end if state.ime_active == false then mp.set_property_bool("input-ime", false) end self:search_query_update('', menu_id, true) menu.search = nil self:search_ensure_key_bindings() self:update_dimensions() self:reset_navigation() end ---@param menu_id? string function Menu:search_init(menu_id) local menu = self:get_menu(menu_id) if not menu then return end if menu.search then return end local timeout if menu.search_debounce ~= 'submit' and menu.search_debounce > 0 then timeout = mp.add_timeout(menu.search_debounce / 1000, self:create_action(function() self:search_submit(menu.id) end)) timeout:kill() end menu.search = { query = '', timeout = timeout, min_top = menu.top, max_width = menu.width, source = { width = menu.width, top = menu.top, scroll_y = menu.scroll_y, selected_index = menu.selected_index, items = not menu.on_search and menu.items or nil, }, } end ---@param menu_id? string function Menu:search_start(menu_id) local menu = self:get_menu(menu_id) if not menu or menu.search_style == 'disabled' then return end if state.ime_active == false then mp.set_property_bool("input-ime", true) end self:search_init(menu_id) self:search_ensure_key_bindings() self:update_dimensions() end ---@param menu_id? string function Menu:search_clear_query(menu_id) local menu = self:get_menu(menu_id) if not menu then return end if not self.current.search_style == 'palette' and self.type_to_search then self:search_cancel(menu_id) else self:search_query_update('', menu_id) end end function Menu:search_enable_key_bindings() if self:has_keybindings('search') then return end local flags = {repeatable = true, complex = true} self:add_key_binding('any_unicode', {self:create_key_handler('search_text_input'), flags}, 'search') -- KP0 to KP9 and KP_DEC are not included in any_unicode -- despite typically producing characters, they don't have a info.key_text self:add_key_binding('kp_dec', {self:create_key_handler('search_text_input'), flags}, 'search') for i = 0, 9 do self:add_key_binding('kp' .. i, {self:create_key_handler('search_text_input'), flags}, 'search') end end function Menu:search_ensure_key_bindings() if self.current.search or (self.type_to_search and self.current.search_style ~= 'disabled') then self:search_enable_key_bindings() else self:remove_key_bindings('search') end end function Menu:enable_key_bindings() -- `+` at the end enables `repeatable` flag local standalone_keys = { 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', '/', 'kp_divide', 'mbtn_back', {'f', 'ctrl'}, {'v', 'ctrl'}, {'c', 'ctrl'}, } local modifiable_keys = {'up+', 'down+', 'left', 'right', 'enter', 'kp_enter', 'bs', 'tab', 'esc', 'pgup+', 'pgdwn+', 'home', 'end', 'del'} local modifiers = {nil, 'alt', 'alt+ctrl', 'alt+shift', 'alt+ctrl+shift', 'ctrl', 'ctrl+shift', 'shift'} local normalized = {kp_enter = 'enter'} local function bind(key, modifier, flags) local binding = modifier and modifier .. '+' .. key or key local shortcut = create_shortcut(normalized[key] or key, modifier) local handler = self:create_action(function(info) self:handle_shortcut(shortcut, info) end) self:add_key_binding(binding, {handler, flags}) end for i, key_mods in ipairs(standalone_keys) do local is_table = type(key_mods) == 'table' local key, mods = is_table and key_mods[1] or key_mods, is_table and key_mods[2] or nil bind(key, mods, {repeatable = false, complex = true}) end for i, key in ipairs(modifiable_keys) do local flags = {repeatable = false, complex = true} if key:sub(-1) == '+' then key = key:sub(1, -2) flags.repeatable = true end for j = 1, #modifiers do bind(key, modifiers[j], flags) end end self:search_ensure_key_bindings() end -- Handles all key and mouse button shortcuts, except unicode inputs. ---@param shortcut Shortcut ---@param info ComplexBindingInfo function Menu:handle_shortcut(shortcut, info) if not self:is_alive() then return end self.mouse_nav = info.is_mouse local menu, id, key, modifiers = self.current, shortcut.id, shortcut.key, shortcut.modifiers local selected_index = menu.selected_index local selected_item = menu and selected_index and menu.items[selected_index] local is_submenu = selected_item and selected_item.items ~= nil local actions = selected_item and selected_item.actions or menu.item_actions local selected_action = actions and menu.action_index and actions[menu.action_index] if info.event == 'up' then return end if (key == 'enter' and selected_item) or (id == 'right' and is_submenu) then self:activate_selected_item(shortcut) elseif id == 'enter' and menu.search and menu.search_debounce == 'submit' then self:search_submit() elseif id == 'up' or id == 'down' then self:navigate_by_offset(id == 'up' and -1 or 1, true) elseif id == 'pgup' or id == 'pgdwn' then local items_per_page = round((menu.height / self.scroll_step) * 0.4) self:navigate_by_offset(items_per_page * (id == 'pgup' and -1 or 1)) elseif id == 'home' or id == 'end' then self:navigate_by_offset(id == 'home' and -math.huge or math.huge) elseif id == 'shift+tab' then self:prev_action() elseif id == 'tab' then self:next_action() elseif id == 'ctrl+up' then self:move_selected_item_by(-1) elseif id == 'ctrl+down' then self:move_selected_item_by(1) elseif id == 'ctrl+pgup' then self:move_selected_item_by(-round((menu.height / self.scroll_step) * 0.4)) elseif id == 'ctrl+pgdwn' then self:move_selected_item_by(round((menu.height / self.scroll_step) * 0.4)) elseif id == 'ctrl+home' then self:move_selected_item_by(-math.huge) elseif id == 'ctrl+end' then self:move_selected_item_by(math.huge) elseif id == '/' or id == 'kp_divide' or id == 'ctrl+f' then self:search_start() elseif key == 'esc' then if menu.search and menu.search_style ~= 'palette' then self:search_cancel() else self:close() end elseif id == 'left' and menu.parent_menu then self:back() elseif key == 'bs' then if menu.search then if modifiers == 'shift' then self:search_clear_query() elseif not modifiers or modifiers == 'ctrl' then self:search_backspace(info.event, modifiers == 'ctrl') end elseif not modifiers and info.event ~= 'repeat' then self:back() end elseif key == 'mbtn_back' then self:back() elseif id == 'ctrl+v' then self:paste() else self.callback(table_assign({}, shortcut, { type = 'key', menu_id = menu.id, selected_item = selected_item and { index = selected_index, value = selected_item.value, action = selected_action, }, })) end end -- Check if menu is not closed or closing. function Menu:is_alive() return not self.is_closing and not self.destroyed end ---@param name string function Menu:create_key_handler(name) return self:create_action(function(...) self.mouse_nav = false self:maybe(name, ...) end) end -- Sends command with params, or triggers a callback event if `command == 'callback'`. -- Intended to handle `on_{event}: 'callback' | string | string[]` events. -- Returns what happened. ---@param command string|number|string[]|number[] ---@param params string[]|number[] ---@param event MenuEvent ---@return 'event' | 'command' | nil function Menu:command_or_event(command, params, event) if command == 'callback' then self.callback(event) return 'event' elseif type(command) == 'table' then ---@diagnostic disable-next-line: deprecated mp.command_native(itable_join(command, params)) return 'command' elseif type(command) == 'string' then mp.command(command .. ' ' .. table.concat(params, ' ')) return 'command' end return nil end function Menu:render() for _, menu in ipairs(self.all) do if menu.fling then local time_delta = state.render_last_time - menu.fling.time local progress = menu.fling.easing(math.min(time_delta / menu.fling.duration, 1)) self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu.id) if progress < 1 then request_render() else menu.fling = nil end end end local display_rect = {ax = 0, ay = 0, bx = display.width, by = display.height} cursor:zone('primary_down', display_rect, self:create_action(function() self:handle_cursor_down() end)) cursor:zone('primary_up', display_rect, self:create_action(function(shortcut) self:handle_cursor_up(shortcut) end)) cursor:zone('wheel_down', self, function() self:handle_wheel_down() end) cursor:zone('wheel_up', self, function() self:handle_wheel_up() end) local ass = assdraw.ass_new() local spacing = self.item_padding local icon_size = self.font_size ---@param menu MenuStack ---@param x number ---@param pos number Horizontal position index. 0 = current menu, <0 parent menus, >1 submenu. local function draw_menu(menu, x, pos) local is_current, is_parent, is_submenu = pos == 0, pos < 0, pos > 0 local menu_opacity = (pos == 0 and 1 or config.opacity.submenu ^ math.abs(pos)) * self.opacity local ax, ay, bx, by = x, menu.top, x + menu.width, menu.top + menu.height local draw_title = menu.is_root and menu.title or menu.search local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')' local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1 local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step) local menu_rect = { ax = ax, ay = ay - (draw_title and self.scroll_step + self.padding or 0) - self.padding, bx = bx, by = by + self.padding, } local blur_action_index = self.mouse_nav and menu.action_index ~= nil -- Background ass:rect(menu_rect.ax, menu_rect.ay, menu_rect.bx, menu_rect.by, { color = bg, opacity = menu_opacity * config.opacity.menu, radius = state.radius > 0 and state.radius + self.padding or 0, }) if is_parent then cursor:zone('primary_down', menu_rect, self:create_action(function() self:slide_in_menu(menu.id, x) end)) end -- Scrollbar if menu.scroll_height > 0 then local groove_height = menu.height - 2 local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40) local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height)) local sax = bx - round(self.scrollbar_size / 2) local sbx = sax + self.scrollbar_size ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8}) end -- Draw submenu if selected local submenu_rect, current_item = nil, is_current and menu.selected_index and menu.items[menu.selected_index] local submenu_is_hovered = false if current_item and current_item.items then submenu_rect = draw_menu(current_item --[[@as MenuStack]], menu_rect.bx + self.gap, 1) cursor:zone('primary_down', submenu_rect, self:create_action(function(shortcut) self:activate_selected_item(shortcut, true) end)) end ---@type MenuAction|nil local selected_action for index = start_index, end_index, 1 do local item = menu.items[index] if not item then break end local item_ax = menu_rect.ax + self.padding local item_bx = menu_rect.bx - self.padding local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1) local item_by = item_ay + self.item_height local item_center_y = item_ay + (self.item_height / 2) local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil local content_ax, content_bx = ax + self.padding + spacing, bx - self.padding - spacing local is_selected = menu.selected_index == index local item_rect_hitbox = { ax = item_ax, ay = math.max(item_ay, menu_rect.ay), bx = menu_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the gap with cursor by = math.min(item_ay + self.scroll_step, menu_rect.by), } -- Select hovered item if is_current and self.mouse_nav and item.selectable ~= false -- Do not select items if cursor is moving towards a submenu and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect)) and (submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) == 0) then menu.selected_index = index if not is_selected then is_selected = true request_render() end end local has_background = is_selected or item.active local next_item = menu.items[index + 1] local next_is_active = next_item and next_item.active local next_has_background = menu.selected_index == index + 1 or next_is_active local font_color = item.active and fgt or bgt local actions = is_selected and (item.actions or menu.item_actions) -- not nil = actions are visible local action = actions and actions[menu.action_index] -- not nil = action is selected if action then selected_action = action end -- Separator if item_by < by and ((not has_background and not next_has_background) or item.separator) then local separator_ay, separator_by = item_by, item_by + self.separator_size if has_background then separator_ay, separator_by = separator_ay + self.separator_size, separator_by + self.separator_size elseif next_has_background then separator_ay, separator_by = separator_ay - self.separator_size, separator_by - self.separator_size end ass:rect(ax + spacing, separator_ay, bx - spacing, separator_by, { color = fg, opacity = menu_opacity * (item.separator and 0.13 or 0.04), }) end -- Background local highlight_opacity = 0 + (item.active and 0.8 or 0) + (is_selected and 0.15 or 0) if highlight_opacity > 0 then ass:rect(ax + self.padding, item_ay, bx - self.padding, item_by, { radius = state.radius, color = fg, opacity = highlight_opacity * menu_opacity, clip = item_clip, }) end local title_clip_bx = content_bx -- Actions local actions_rect if is_selected and actions and #actions > 0 and not item.items then local place = item.actions_place or menu.item_actions_place local margin = self.gap * 2 local size = item_by - item_ay - margin * 2 local rect_width = size * #actions + margin * (#actions - 1) -- Place actions outside of menu when requested and there's enough space for it actions_rect = { ay = item_ay + margin, by = item_by - margin, is_outside = place == 'outside' and display.width - menu_rect.bx + margin * 2 > rect_width, } actions_rect.bx = actions_rect.is_outside and menu_rect.bx + margin + rect_width or item_bx - margin actions_rect.ax = actions_rect.bx for i = 1, #actions, 1 do local action_index = #actions - (i - 1) local action = actions[action_index] -- Hide when the action shouldn't be displayed when the item is a result of a search/filter if not (action.filter_hidden and menu.search) then local is_active = action_index == menu.action_index local bx = actions_rect.ax - (i == 1 and 0 or margin) local rect = { ay = actions_rect.ay, by = actions_rect.by, ax = bx - size, bx = bx, } actions_rect.ax = rect.ax ass:rect(rect.ax, rect.ay, rect.bx, rect.by, { radius = state.radius > 2 and state.radius - 1 or state.radius, color = is_active and fg or bg, border = is_active and self.gap or nil, border_color = bg, opacity = menu_opacity, clip = item_clip, }) ass:icon(rect.ax + size / 2, rect.ay + size / 2, size * 0.66, action.icon, { color = is_active and bg or fg, opacity = menu_opacity, clip = item_clip, }) -- Re-use rect as a hitbox by growing it so it bridges gaps to prevent flickering rect.ay, rect.by, rect.bx = item_ay, item_ay + self.scroll_step, rect.bx + margin -- Select action on cursor hover if self.mouse_nav and get_point_to_rectangle_proximity(cursor, rect) == 0 then cursor:zone('primary_click', rect, self:create_action(function(shortcut) self:activate_selected_item(shortcut, true) end)) blur_action_index = false if not is_active then menu.action_index = action_index selected_action = actions[action_index] request_render() end end end end title_clip_bx = actions_rect.ax - self.gap * 2 end -- Selected item indicator line if is_selected and not selected_action then local size = round(2 * state.scale) local v_padding = math.min(state.radius, math.ceil(self.item_height / 3)) ass:rect(ax + self.padding - size - 1, item_ay + v_padding, ax + self.padding - 1, item_by - v_padding, { radius = 1 * state.scale, color = fg, opacity = menu_opacity, clip = item_clip, }) end -- Icon if item.icon then if not actions_rect or actions_rect.is_outside then local x = (not item.title and not item.hint and item.align == 'center') and menu_rect.ax + (menu_rect.bx - menu_rect.ax) / 2 or content_bx - (icon_size / 2) if item.icon == 'spinner' then ass:spinner(x, item_center_y, icon_size * 1.5, {color = font_color, opacity = menu_opacity * 0.8}) else ass:icon(x, item_center_y, icon_size * 1.5, item.icon, { color = font_color, opacity = menu_opacity, clip = item_clip, }) end end content_bx = content_bx - icon_size - spacing title_clip_bx = math.min(content_bx, title_clip_bx) end local hint_clip_bx = title_clip_bx if item.hint_width > 0 then -- controls title & hint clipping proportional to the ratio of their widths -- both title and hint get at least 50% of the width, unless they are smaller then that local width = content_bx - content_ax - spacing local title_min = math.min(item.title_width, width * 0.5) local hint_min = math.min(item.hint_width, width * 0.5) local title_ratio = item.title_width / (item.title_width + item.hint_width) title_clip_bx = math.min( title_clip_bx, round(content_ax + clamp(title_min, width * title_ratio, width - hint_min)) ) end -- Hint if item.hint then item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint) local clip = '\\clip(' .. title_clip_bx + spacing .. ',' .. math.max(item_ay, ay) .. ',' .. hint_clip_bx .. ',' .. math.min(item_by, by) .. ')' ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, { size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * menu_opacity, clip = clip, }) end -- Title if item.title then item.ass_safe_title = item.ass_safe_title or ass_escape(item.title) local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ',' .. title_clip_bx .. ',' .. math.min(item_by, by) .. ')' local title_x, align = content_ax, 4 if item.align == 'right' then title_x, align = title_clip_bx, 6 elseif item.align == 'center' then title_x, align = content_ax + (title_clip_bx - content_ax) / 2, 5 end ass:txt(title_x, item_center_y, align, item.ass_safe_title, { size = self.font_size, color = font_color, italic = item.italic, bold = item.bold, wrap = 2, opacity = menu_opacity * (item.muted and 0.5 or 1), clip = clip, }) end end -- Footnote / Selected action label if is_current and (menu.footnote or selected_action) then local height_half = self.font_size local icon_x, icon_y = menu_rect.ax + self.padding + self.font_size / 2, menu_rect.by + height_half local is_icon_hovered = false local icon_hitbox = { ax = icon_x - height_half, ay = icon_y - height_half, bx = icon_x + height_half, by = icon_y + height_half, } is_icon_hovered = get_point_to_rectangle_proximity(cursor, icon_hitbox) == 0 local text = selected_action and selected_action.label or is_icon_hovered and menu.footnote local opacity = (is_icon_hovered and 1 or 0.5) * menu_opacity ass:icon(icon_x, icon_y, self.font_size, is_icon_hovered and 'help' or 'help_outline', { color = fg, border = state.scale, border_color = bg, opacity = opacity, }) if text then ass:txt(icon_x + self.font_size * 0.75, icon_y, 4, text, { size = self.font_size, color = fg, border = state.scale, border_color = bg, opacity = menu_opacity, italic = true, }) end end -- Menu title if draw_title then local requires_submit = menu.search_debounce == 'submit' local rect = { ax = round(ax + spacing / 2 + self.padding), ay = ay - self.scroll_step - self.padding * 2, bx = round(bx - spacing / 2 - self.padding), by = math.min(by, ay - self.padding), } -- centers rect.cx, rect.cy = round(rect.ax + (rect.bx - rect.ax) / 2), round(rect.ay + (rect.by - rect.ay) / 2) if menu.title and not menu.ass_safe_title then menu.ass_safe_title = ass_escape(menu.title) end -- Bottom border ass:rect(ax, rect.by - self.separator_size, bx, rect.by, {color = fg, opacity = menu_opacity * 0.2}) -- Do nothing when user clicks title if is_current then cursor:zone('primary_down', rect, function() end) end -- Title if menu.search then -- Icon local icon_size, icon_opacity = self.font_size * 1.3, menu_opacity * (requires_submit and 0.5 or 1) local icon_rect = {ax = rect.ax, ay = rect.ay, bx = ax + icon_size + spacing * 1.5, by = rect.by} if is_current and requires_submit then cursor:zone('primary_down', icon_rect, function() self:search_submit() end) if get_point_to_rectangle_proximity(cursor, icon_rect) == 0 then icon_opacity = menu_opacity end end ass:icon(rect.ax + icon_size / 2, rect.cy, icon_size, 'search', { color = fg, opacity = icon_opacity, clip = '\\clip(' .. icon_rect.ax .. ',' .. icon_rect.ay .. ',' .. icon_rect.bx .. ',' .. icon_rect.by .. ')', }) -- Query/Placeholder if menu.search.query ~= '' then -- Add a ZWNBSP suffix to prevent libass from trimming trailing spaces local query = ass_escape(menu.search.query) .. '\239\187\191' ass:txt(rect.bx, rect.cy, 6, query, { size = self.font_size, color = bgt, wrap = 2, opacity = menu_opacity, clip = '\\clip(' .. icon_rect.bx .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')', }) else local placeholder = (menu.search_style == 'palette' and menu.ass_safe_title) and menu.ass_safe_title or (requires_submit and t('type & ctrl+enter to search') or t('type to search')) ass:txt(rect.bx, rect.cy, 6, placeholder, { size = self.font_size, italic = true, color = bgt, wrap = 2, opacity = menu_opacity * 0.4, clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')', }) end -- Selected input indicator for submittable searches. -- (input is selected when `selected_index` is `nil`) if menu.search_debounce == 'submit' and not menu.selected_index then local size_half = round(1 * state.scale) ass:rect(ax, rect.by - size_half, bx, rect.by + size_half, {color = fg, opacity = menu_opacity}) end local input_is_blurred = menu.search_debounce == 'submit' and menu.selected_index -- Cursor local cursor_height_half, cursor_thickness = round(self.font_size * 0.6), round(self.font_size / 12) local cursor_ax, cursor_bx = rect.bx + 1, rect.bx + 1 + cursor_thickness ass:rect(cursor_ax, rect.cy - cursor_height_half, cursor_bx, rect.cy + cursor_height_half, { color = fg, opacity = menu_opacity * (input_is_blurred and 0.5 or 1), clip = '\\clip(' .. cursor_ax .. ',' .. rect.ay .. ',' .. cursor_bx .. ',' .. rect.by .. ')', }) else ass:txt(rect.cx, rect.cy, 5, menu.ass_safe_title, { size = self.font_size, bold = true, color = bgt, wrap = 2, opacity = menu_opacity, clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')', }) end end if blur_action_index then menu.action_index = nil request_render() end return menu_rect end -- Active menu draw_menu(self.current, self.ax, 0) -- Parent menus local parent_menu = self.current.parent_menu local parent_offset_x, parent_horizontal_index = self.ax, -1 while parent_menu do parent_offset_x = parent_offset_x - parent_menu.width - self.gap draw_menu(parent_menu, parent_offset_x, parent_horizontal_index) parent_horizontal_index = parent_horizontal_index - 1 parent_menu = parent_menu.parent_menu end return ass end return Menu