Menu.lua 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408
  1. local Element = require('elements/Element')
  2. -- Menu data structure accepted by `Menu:open(menu)`.
  3. ---@alias MenuData {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled'; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataItem[]; selected_index?: integer; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string}
  4. ---@alias MenuDataItem MenuDataValue|MenuData
  5. ---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'}
  6. ---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(); on_close?: fun(); on_back?: fun(); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
  7. -- Internal data structure created from `Menu`.
  8. ---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled', selected_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackItem[]; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: 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}
  9. ---@alias MenuStackItem MenuStackValue|MenuStack
  10. ---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; 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}
  11. ---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
  12. ---@alias Search {query: string; timeout: unknown; min_top: number; max_width: number; source: {width: number; top: number; scroll_y: number; selected_index?: integer; items?: MenuDataItem[]}}
  13. ---@alias Modifiers {shift?: boolean, ctrl?: boolean, alt?: boolean}
  14. ---@alias MenuCallbackMeta {modifiers: Modifiers}
  15. ---@alias MenuCallback fun(value: any, meta: MenuCallbackMeta)
  16. ---@class Menu : Element
  17. local Menu = class(Element)
  18. ---@param data MenuData
  19. ---@param callback MenuCallback
  20. ---@param opts? MenuOptions
  21. function Menu:open(data, callback, opts)
  22. local open_menu = self:is_open()
  23. if open_menu then
  24. open_menu.is_being_replaced = true
  25. open_menu:close(true)
  26. end
  27. return Menu:new(data, callback, opts)
  28. end
  29. ---@param menu_type? string
  30. ---@return Menu|nil
  31. function Menu:is_open(menu_type)
  32. return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil
  33. end
  34. ---@param immediate? boolean Close immediately without fadeout animation.
  35. ---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed.
  36. ---@overload fun(callback: fun())
  37. function Menu:close(immediate, callback)
  38. if type(immediate) ~= 'boolean' then callback = immediate end
  39. local menu = self == Menu and Elements.menu or self
  40. if menu and not menu.destroyed then
  41. if menu.is_closing then
  42. menu:tween_stop()
  43. return
  44. end
  45. local function close()
  46. Elements:remove('menu')
  47. menu.is_closing, menu.stack, menu.current, menu.all, menu.by_id = false, nil, nil, {}, {}
  48. menu:disable_key_bindings()
  49. Elements:update_proximities()
  50. cursor:queue_autohide()
  51. if callback then callback() end
  52. request_render()
  53. end
  54. menu.is_closing = true
  55. if immediate then
  56. close()
  57. else
  58. menu:fadeout(close)
  59. end
  60. end
  61. end
  62. ---@param data MenuData
  63. ---@param callback MenuCallback
  64. ---@param opts? MenuOptions
  65. ---@return Menu
  66. function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end
  67. ---@param data MenuData
  68. ---@param callback MenuCallback
  69. ---@param opts? MenuOptions
  70. function Menu:init(data, callback, opts)
  71. Element.init(self, 'menu', {render_order = 1001})
  72. -----@type fun()
  73. self.callback = callback
  74. self.opts = opts or {}
  75. self.offset_x = 0 -- Used for submenu transition animation.
  76. self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items
  77. ---@type Modifiers
  78. self.modifiers = {}
  79. self.item_height = nil
  80. self.min_width = nil
  81. self.item_spacing = 1
  82. self.item_padding = nil
  83. self.separator_size = nil
  84. self.padding = nil
  85. self.gap = nil
  86. self.font_size = nil
  87. self.font_size_hint = nil
  88. self.scroll_step = nil -- Item height + item spacing.
  89. self.scroll_height = nil -- Items + spacings - container height.
  90. self.opacity = 0 -- Used to fade in/out.
  91. self.type = data.type
  92. ---@type MenuStack Root MenuStack.
  93. self.root = nil
  94. ---@type MenuStack Current MenuStack.
  95. self.current = nil
  96. ---@type MenuStack[] All menus in a flat array.
  97. self.all = nil
  98. ---@type table<string, MenuStack> Map of submenus by their ids, such as `'Tools > Aspect ratio'`.
  99. self.by_id = {}
  100. self.key_bindings = {}
  101. self.key_bindings_search = {} -- temporary key bindings for search
  102. self.type_to_search = options.menu_type_to_search
  103. self.is_being_replaced = false
  104. self.is_closing, self.is_closed = false, false
  105. self.drag_last_y = nil
  106. self.is_dragging = false
  107. if utils.shared_script_property_set then
  108. utils.shared_script_property_set('uosc-menu-type', self.type or 'undefined')
  109. end
  110. mp.set_property_native('user-data/uosc/menu/type', self.type or 'undefined')
  111. self:update(data)
  112. for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu) end
  113. if self.mouse_nav then self.current.selected_index = nil end
  114. self:tween_property('opacity', 0, 1)
  115. self:enable_key_bindings()
  116. Elements:maybe('curtain', 'register', self.id)
  117. if self.opts.on_open then self.opts.on_open() end
  118. end
  119. function Menu:destroy()
  120. Element.destroy(self)
  121. self:disable_key_bindings()
  122. self.is_closed = true
  123. if not self.is_being_replaced then Elements:maybe('curtain', 'unregister', self.id) end
  124. if utils.shared_script_property_set then
  125. utils.shared_script_property_set('uosc-menu-type', nil)
  126. end
  127. mp.set_property_native('user-data/uosc/menu/type', nil)
  128. if self.opts.on_close then self.opts.on_close() end
  129. end
  130. ---@param data MenuData
  131. function Menu:update(data)
  132. local new_root = {is_root = true, submenu_path = {}}
  133. local new_all = {}
  134. local new_menus = {} -- menus that didn't exist before this `update()`
  135. local new_by_id = {}
  136. local menus_to_serialize = {{new_root, data}}
  137. local old_current_id = self.current and self.current.id
  138. local menu_props_to_copy = {
  139. 'title', 'hint', 'keep_open', 'search_style', 'search_submenus', 'search_suggestion', 'on_search', 'on_paste',
  140. }
  141. local item_props_to_copy = itable_join(menu_props_to_copy, {
  142. 'icon', 'active', 'bold', 'italic', 'muted', 'value', 'separator', 'selectable', 'align',
  143. })
  144. table_assign_props(new_root, data, itable_join({'type'}, menu_props_to_copy))
  145. local i = 0
  146. while i < #menus_to_serialize do
  147. i = i + 1
  148. local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2]
  149. local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id
  150. if menu_data.id then
  151. menu.id = menu_data.id
  152. elseif not menu.is_root then
  153. menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i)
  154. else
  155. menu.id = 'main'
  156. end
  157. menu.icon = 'chevron_right'
  158. -- Normalize `search_debounce`
  159. if type(menu_data.search_debounce) == 'number' then
  160. menu.search_debounce = math.max(0, menu_data.search_debounce)
  161. elseif menu_data.search_debounce == 'submit' then
  162. menu.search_debounce = 'submit'
  163. else
  164. menu.search_debounce = menu.on_search and 300 or 0
  165. end
  166. -- Update items
  167. local first_active_index = nil
  168. menu.items = {
  169. {title = t('Empty'), value = 'ignore', italic = 'true', muted = 'true', selectable = false, align = 'center'},
  170. }
  171. for i, item_data in ipairs(menu_data.items or {}) do
  172. if item_data.active and not first_active_index then first_active_index = i end
  173. local item = {}
  174. table_assign_props(item, item_data, item_props_to_copy)
  175. if item.keep_open == nil then item.keep_open = menu.keep_open end
  176. -- Submenu
  177. if item_data.items then
  178. item.parent_menu = menu
  179. item.submenu_path = itable_join(menu.submenu_path, {i})
  180. menus_to_serialize[#menus_to_serialize + 1] = {item, item_data}
  181. end
  182. menu.items[i] = item
  183. end
  184. if menu.is_root then menu.selected_index = menu_data.selected_index or first_active_index end
  185. -- Retain old state
  186. local old_menu = self.by_id[menu.id]
  187. if old_menu then
  188. table_assign_props(menu, old_menu, {'selected_index', 'scroll_y', 'fling', 'search'})
  189. else
  190. new_menus[#new_menus + 1] = menu
  191. end
  192. new_all[#new_all + 1] = menu
  193. new_by_id[menu.id] = menu
  194. end
  195. self.root, self.all, self.by_id = new_root, new_all, new_by_id
  196. self.current = self.by_id[old_current_id] or self.root
  197. self:update_content_dimensions()
  198. self:reset_navigation()
  199. -- Ensure palette menus have active searches, and clean empty searches from menus that lost the `palette` flag
  200. local update_dimensions_again = false
  201. for _, menu in ipairs(self.all) do
  202. local is_palette = menu.search_style == 'palette'
  203. if not menu.search and (is_palette or (menu.search_suggestion and itable_index_of(new_menus, menu))) then
  204. update_dimensions_again = true
  205. self:search_init(menu)
  206. elseif not is_palette and menu.search and menu.search.query == '' then
  207. update_dimensions_again = true
  208. menu.search = nil
  209. end
  210. end
  211. -- We update before _and_ after because search_inits need the initial un-searched
  212. -- menu's position and scroll state to save on the `search.source` table.
  213. if update_dimensions_again then
  214. self:update_content_dimensions()
  215. self:reset_navigation()
  216. end
  217. -- Apply search suggestions
  218. for _, menu in ipairs(new_menus) do
  219. if menu.search_suggestion then menu.search.query = menu.search_suggestion end
  220. end
  221. for _, menu in ipairs(self.all) do
  222. if menu.search then
  223. -- the menu items are new objects and the search needs to contain those
  224. menu.search.source.items = not menu.on_search and menu.items or nil
  225. -- Only internal searches are immediately submitted
  226. if not menu.on_search then self:search_internal(menu, true) end
  227. end
  228. if menu.selected_index then self:select_by_offset(0, menu) end
  229. end
  230. self:search_ensure_key_bindings()
  231. end
  232. ---@param items MenuDataItem[]
  233. function Menu:update_items(items)
  234. local data = table_assign({}, self.root)
  235. data.items = items
  236. self:update(data)
  237. end
  238. function Menu:update_content_dimensions()
  239. self.item_height = round(options.menu_item_height * state.scale)
  240. self.min_width = round(options.menu_min_width * state.scale)
  241. self.separator_size = round(1 * state.scale)
  242. self.scrollbar_size = round(2 * state.scale)
  243. self.padding = round(options.menu_padding * state.scale)
  244. self.gap = round(2 * state.scale)
  245. self.font_size = round(self.item_height * 0.48 * options.font_scale)
  246. self.font_size_hint = self.font_size - 1
  247. self.item_padding = round((self.item_height - self.font_size) * 0.6)
  248. self.scroll_step = self.item_height + self.item_spacing
  249. local title_opts = {size = self.font_size, italic = false, bold = false}
  250. local hint_opts = {size = self.font_size_hint}
  251. for _, menu in ipairs(self.all) do
  252. title_opts.bold, title_opts.italic = true, false
  253. local max_width = text_width(menu.title, title_opts) + 2 * self.padding + 2 * self.item_padding
  254. -- Estimate width of a widest item
  255. for _, item in ipairs(menu.items) do
  256. local icon_width = item.icon and self.font_size or 0
  257. item.title_width = text_width(item.title, title_opts)
  258. item.hint_width = text_width(item.hint, hint_opts)
  259. local spacings_in_item = 1 + (item.title_width > 0 and 1 or 0)
  260. + (item.hint_width > 0 and 1 or 0) + (icon_width > 0 and 1 or 0)
  261. local estimated_width = item.title_width + item.hint_width + icon_width
  262. + (self.item_padding * spacings_in_item)
  263. if estimated_width > max_width then max_width = estimated_width end
  264. end
  265. menu.max_width = max_width + 2 * self.padding
  266. end
  267. self:update_dimensions()
  268. end
  269. function Menu:update_dimensions()
  270. -- Coordinates and sizes are of the scrollable area. Title is rendered
  271. -- above it, so we need to account for that in max_height and ay position.
  272. -- This is a debt from an era where we had different cursor event handling,
  273. -- and dumb titles with no search inputs. It could use a refactor.
  274. local margin = round(self.item_height / 2)
  275. local width_available, height_available = display.width - margin * 2, display.height - margin * 2
  276. local min_width = math.min(self.min_width, width_available)
  277. for _, menu in ipairs(self.all) do
  278. local width = math.max(menu.search and menu.search.max_width or 0, menu.max_width)
  279. menu.width = round(clamp(min_width, width, width_available))
  280. local title_height = (menu.is_root and menu.title or menu.search) and self.scroll_step + self.padding or 0
  281. local max_height = height_available - title_height
  282. local content_height = self.scroll_step * #menu.items
  283. menu.height = math.min(content_height - self.item_spacing, max_height)
  284. menu.top = clamp(
  285. title_height + margin,
  286. menu.search and math.min(menu.search.min_top, menu.search.source.top) or height_available,
  287. round((height_available - menu.height + title_height) / 2)
  288. )
  289. if menu.search then
  290. menu.search.min_top = math.min(menu.search.min_top, menu.top)
  291. menu.search.max_width = math.max(menu.search.max_width, menu.width)
  292. end
  293. menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0)
  294. self:set_scroll_to(menu.scroll_y, menu) -- clamps scroll_y to scroll limits
  295. end
  296. self:update_coordinates()
  297. end
  298. -- Updates element coordinates to match currently open (sub)menu.
  299. function Menu:update_coordinates()
  300. local ax = round((display.width - self.current.width) / 2) + self.offset_x
  301. self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height)
  302. end
  303. function Menu:reset_navigation()
  304. local menu = self.current
  305. -- Reset indexes and scroll
  306. self:set_scroll_to(menu.scroll_y) -- clamps scroll_y to scroll limits
  307. if menu.items and #menu.items > 0 then
  308. -- Normalize existing selected_index always, and force it only in keyboard navigation
  309. if not self.mouse_nav then
  310. self:select_by_offset(0)
  311. end
  312. else
  313. self:select_index(nil)
  314. end
  315. -- Walk up the parent menu chain and activate items that lead to current menu
  316. local parent = menu.parent_menu
  317. while parent do
  318. parent.selected_index = itable_index_of(parent.items, menu)
  319. menu, parent = parent, parent.parent_menu
  320. end
  321. request_render()
  322. end
  323. function Menu:set_offset_x(offset)
  324. local delta = offset - self.offset_x
  325. self.offset_x = offset
  326. self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by)
  327. end
  328. function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end
  329. function Menu:get_first_active_index(menu)
  330. menu = menu or self.current
  331. for index, item in ipairs(self.current.items) do
  332. if item.active then return index end
  333. end
  334. end
  335. ---@param pos? number
  336. ---@param menu? MenuStack
  337. function Menu:set_scroll_to(pos, menu)
  338. menu = menu or self.current
  339. menu.scroll_y = clamp(0, pos or 0, menu.scroll_height)
  340. request_render()
  341. end
  342. ---@param delta? number
  343. ---@param menu? MenuStack
  344. function Menu:set_scroll_by(delta, menu)
  345. menu = menu or self.current
  346. self:set_scroll_to(menu.scroll_y + delta, menu)
  347. end
  348. ---@param pos? number
  349. ---@param menu? MenuStack
  350. ---@param fling_options? table
  351. function Menu:scroll_to(pos, menu, fling_options)
  352. menu = menu or self.current
  353. menu.fling = {
  354. y = menu.scroll_y,
  355. distance = clamp(-menu.scroll_y, pos - menu.scroll_y, menu.scroll_height - menu.scroll_y),
  356. time = mp.get_time(),
  357. duration = 0.1,
  358. easing = ease_out_sext,
  359. }
  360. if fling_options then table_assign(menu.fling, fling_options) end
  361. request_render()
  362. end
  363. ---@param delta? number
  364. ---@param menu? MenuStack
  365. ---@param fling_options? Fling
  366. function Menu:scroll_by(delta, menu, fling_options)
  367. menu = menu or self.current
  368. self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu, fling_options)
  369. end
  370. ---@param index? integer
  371. ---@param menu? MenuStack
  372. ---@param immediate? boolean
  373. function Menu:scroll_to_index(index, menu, immediate)
  374. menu = menu or self.current
  375. if (index and index >= 1 and index <= #menu.items) then
  376. local position = round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2))
  377. if immediate then
  378. self:set_scroll_to(position, menu)
  379. else
  380. self:scroll_to(position, menu)
  381. end
  382. end
  383. end
  384. ---@param index? integer
  385. ---@param menu? MenuStack
  386. function Menu:select_index(index, menu)
  387. menu = menu or self.current
  388. menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil
  389. request_render()
  390. end
  391. ---@param value? any
  392. ---@param menu? MenuStack
  393. function Menu:select_value(value, menu)
  394. menu = menu or self.current
  395. local index = itable_find(menu.items, function(item) return item.value == value end)
  396. self:select_index(index)
  397. end
  398. ---@param menu? MenuStack
  399. function Menu:deactivate_items(menu)
  400. menu = menu or self.current
  401. for _, item in ipairs(menu.items) do item.active = false end
  402. request_render()
  403. end
  404. ---@param index? integer
  405. ---@param menu? MenuStack
  406. function Menu:activate_index(index, menu)
  407. menu = menu or self.current
  408. if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end
  409. request_render()
  410. end
  411. ---@param index? integer
  412. ---@param menu? MenuStack
  413. function Menu:activate_one_index(index, menu)
  414. self:deactivate_items(menu)
  415. self:activate_index(index, menu)
  416. end
  417. ---@param value? any
  418. ---@param menu? MenuStack
  419. function Menu:activate_value(value, menu)
  420. menu = menu or self.current
  421. local index = itable_find(menu.items, function(item) return item.value == value end)
  422. self:activate_index(index, menu)
  423. end
  424. ---@param value? any
  425. ---@param menu? MenuStack
  426. function Menu:activate_one_value(value, menu)
  427. menu = menu or self.current
  428. local index = itable_find(menu.items, function(item) return item.value == value end)
  429. self:activate_one_index(index, menu)
  430. end
  431. ---@param menu MenuStack One of menus in `self.all`.
  432. function Menu:activate_menu(menu)
  433. if itable_index_of(self.all, menu) then
  434. self.current = menu
  435. self:update_coordinates()
  436. self:reset_navigation()
  437. self:search_ensure_key_bindings()
  438. request_render()
  439. else
  440. msg.error('Attempt to open a menu not in `self.all` list.')
  441. end
  442. end
  443. ---@param id string
  444. function Menu:activate_submenu(id)
  445. local submenu = self.by_id[id]
  446. if submenu then
  447. self:activate_menu(submenu)
  448. local menu = self.current
  449. local parent = menu.parent_menu
  450. while parent do
  451. parent.selected_index = itable_index_of(parent.items, menu)
  452. self:scroll_to_index(parent.selected_index, parent)
  453. menu, parent = parent, parent.parent_menu
  454. end
  455. else
  456. msg.error(string.format('Requested submenu id "%s" doesn\'t exist', id))
  457. end
  458. end
  459. ---@param index? integer
  460. ---@param menu? MenuStack
  461. function Menu:delete_index(index, menu)
  462. menu = menu or self.current
  463. if (index and index >= 1 and index <= #menu.items) then
  464. table.remove(menu.items, index)
  465. self:update_content_dimensions()
  466. self:scroll_to_index(menu.selected_index, menu)
  467. end
  468. end
  469. ---@param value? any
  470. ---@param menu? MenuStack
  471. function Menu:delete_value(value, menu)
  472. menu = menu or self.current
  473. local index = itable_find(menu.items, function(item) return item.value == value end)
  474. self:delete_index(index)
  475. end
  476. ---@param menu MenuStack One of menus in `self.all`.
  477. ---@param x number `x` coordinate to slide from.
  478. function Menu:slide_in_menu(menu, x)
  479. local current = self.current
  480. current.selected_index = nil
  481. self:activate_menu(menu)
  482. self:tween(-(display.width / 2 - menu.width / 2 - x), 0, function(offset) self:set_offset_x(offset) end)
  483. self.opacity = 1 -- in case tween above canceled fade in animation
  484. end
  485. function Menu:back()
  486. if self.opts.on_back then
  487. self.opts.on_back()
  488. if self.is_closed then return end
  489. end
  490. local current = self.current
  491. local parent = current.parent_menu
  492. if parent then
  493. self:slide_in_menu(parent, display.width / 2 - current.width / 2 - parent.width / 2 + self.offset_x)
  494. else
  495. self:close()
  496. end
  497. end
  498. ---@param opts? {keep_open?: boolean, preselect_first_item?: boolean}
  499. function Menu:open_selected_item(opts)
  500. opts = opts or {}
  501. local menu = self.current
  502. if menu.selected_index then
  503. local item = menu.items[menu.selected_index]
  504. -- Is submenu
  505. if item.items then
  506. if opts.preselect_first_item then
  507. item.selected_index = #item.items > 0 and 1 or nil
  508. end
  509. self:activate_menu(item)
  510. self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
  511. self.opacity = 1 -- in case tween above canceled fade in animation
  512. else
  513. self.callback(item.value, {modifiers = self.modifiers or {}})
  514. if not item.keep_open and not opts.keep_open then self:close() end
  515. end
  516. end
  517. end
  518. function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end
  519. function Menu:open_selected_item_preselect() self:open_selected_item({preselect_first_item = true}) end
  520. ---@param index integer
  521. function Menu:move_selected_item_to(index)
  522. local from, callback = self.current.selected_index, self.opts.on_move_item
  523. if callback and from and from ~= index and index >= 1 and index <= #self.current.items then
  524. callback(from, index, self.current.submenu_path)
  525. self.current.selected_index = index
  526. self:scroll_to_index(index, self.current, true)
  527. end
  528. end
  529. function Menu:move_selected_item_up()
  530. if self.current.selected_index then self:move_selected_item_to(self.current.selected_index - 1) end
  531. end
  532. function Menu:move_selected_item_down()
  533. if self.current.selected_index then self:move_selected_item_to(self.current.selected_index + 1) end
  534. end
  535. function Menu:delete_selected_item()
  536. local index, callback = self.current.selected_index, self.opts.on_delete_item
  537. if callback and index then callback(index, self.current.submenu_path) end
  538. end
  539. function Menu:on_display() self:update_dimensions() end
  540. function Menu:on_prop_fullormaxed() self:update_content_dimensions() end
  541. function Menu:on_options() self:update_content_dimensions() end
  542. function Menu:handle_cursor_down()
  543. if self.proximity_raw == 0 then
  544. self.drag_last_y = cursor.y
  545. self.current.fling = nil
  546. else
  547. self:close()
  548. end
  549. end
  550. function Menu:handle_cursor_up()
  551. if self.proximity_raw == 0 and self.drag_last_y and not self.is_dragging then
  552. self:open_selected_item({preselect_first_item = false, keep_open = self.modifiers and self.modifiers.shift})
  553. end
  554. if self.is_dragging then
  555. local distance = cursor:get_velocity().y / -3
  556. if math.abs(distance) > 50 then
  557. self.current.fling = {
  558. y = self.current.scroll_y,
  559. distance = distance,
  560. time = cursor.history:head().time,
  561. easing = ease_out_quart,
  562. duration = 0.5,
  563. update_cursor = true,
  564. }
  565. request_render()
  566. end
  567. end
  568. self.is_dragging = false
  569. self.drag_last_y = nil
  570. end
  571. function Menu:on_global_mouse_move()
  572. self.mouse_nav = true
  573. if self.drag_last_y then
  574. self.is_dragging = self.is_dragging or math.abs(cursor.y - self.drag_last_y) >= 10
  575. local distance = self.drag_last_y - cursor.y
  576. if distance ~= 0 then self:set_scroll_by(distance) end
  577. if self.is_dragging then self.drag_last_y = cursor.y end
  578. end
  579. request_render()
  580. end
  581. function Menu:handle_wheel_up() self:scroll_by(self.scroll_step * -3, nil, {update_cursor = true}) end
  582. function Menu:handle_wheel_down() self:scroll_by(self.scroll_step * 3, nil, {update_cursor = true}) end
  583. ---@param offset integer
  584. ---@param menu? MenuStack
  585. function Menu:select_by_offset(offset, menu)
  586. menu = menu or self.current
  587. local index = clamp(1, (menu.selected_index or offset >= 0 and 0 or #menu.items + 1) + offset, #menu.items)
  588. local prev_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index, 1)
  589. local next_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index)
  590. if prev_index and next_index then
  591. if offset == 0 then
  592. menu.selected_index = index - prev_index <= next_index - index and prev_index or next_index
  593. elseif offset > 0 then
  594. menu.selected_index = next_index
  595. else
  596. menu.selected_index = prev_index
  597. end
  598. else
  599. menu.selected_index = prev_index or next_index or nil
  600. end
  601. request_render()
  602. end
  603. ---@param offset integer
  604. ---@param immediate? boolean
  605. function Menu:navigate_by_offset(offset, immediate)
  606. self:select_by_offset(offset)
  607. if self.current.selected_index then self:scroll_to_index(self.current.selected_index, self.current, immediate) end
  608. end
  609. function Menu:prev()
  610. self:navigate_by_offset(-1, true)
  611. end
  612. function Menu:next()
  613. self:navigate_by_offset(1, true)
  614. end
  615. function Menu:on_pgup()
  616. local items_per_page = round((self.current.height / self.scroll_step) * 0.4)
  617. self:navigate_by_offset(-items_per_page)
  618. end
  619. function Menu:on_pgdwn()
  620. local items_per_page = round((self.current.height / self.scroll_step) * 0.4)
  621. self:navigate_by_offset(items_per_page)
  622. end
  623. function Menu:on_home()
  624. self:navigate_by_offset(-math.huge)
  625. end
  626. function Menu:on_end()
  627. self:navigate_by_offset(math.huge)
  628. end
  629. function Menu:paste()
  630. local menu = self.current
  631. local payload = get_clipboard()
  632. if not payload then return end
  633. if menu.search then
  634. self:search_query_update(menu.search.query .. payload)
  635. elseif menu.on_paste then
  636. local paste_type = type(menu.on_paste)
  637. if paste_type == 'string' then
  638. mp.command(menu.on_paste .. ' ' .. payload)
  639. elseif paste_type == 'table' then
  640. local command = itable_join({}, menu.on_paste)
  641. command[#command + 1] = payload
  642. mp.command_native(command)
  643. else
  644. menu.on_paste(payload)
  645. end
  646. elseif menu.search_style ~= 'disabled' then
  647. self:search_start(menu)
  648. self:search_query_update(payload, menu)
  649. end
  650. end
  651. ---@param menu MenuStack
  652. ---@param no_select_first? boolean
  653. function Menu:search_internal(menu, no_select_first)
  654. local query = menu.search.query:lower()
  655. if query == '' then
  656. -- Reset menu state to what it was before search
  657. for key, value in pairs(menu.search.source) do menu[key] = value end
  658. else
  659. -- Inherit `search_submenus` from parent menus
  660. local search_submenus, parent_menu = menu.search_submenus, menu.parent_menu
  661. while not search_submenus and parent_menu do
  662. search_submenus, parent_menu = parent_menu.search_submenus, parent_menu.parent_menu
  663. end
  664. menu.items = search_items(menu.search.source.items, query, search_submenus)
  665. -- Select 1st item in search results
  666. if not no_select_first then
  667. menu.scroll_y = 0
  668. self:select_index(1, menu)
  669. end
  670. end
  671. self:update_content_dimensions()
  672. end
  673. ---@param items MenuStackItem[]
  674. ---@param query string
  675. ---@param recursive? boolean
  676. ---@param prefix? string
  677. ---@return MenuStackItem[]
  678. function search_items(items, query, recursive, prefix)
  679. local result = {}
  680. local concat = table.concat
  681. for _, item in ipairs(items) do
  682. if item.selectable ~= false then
  683. local prefixed_title = prefix and prefix .. ' / ' .. (item.title or '') or item.title
  684. if item.items and recursive then
  685. itable_append(result, search_items(item.items, query, recursive, prefixed_title))
  686. else
  687. local title = item.title and item.title:lower()
  688. local hint = item.hint and item.hint:lower()
  689. local initials_title = title and concat(initials(title))
  690. local romanization = need_romanization()
  691. if romanization then
  692. ligature_conv_title = title and char_conv(title, true)
  693. initials_conv_title = title and concat(initials(char_conv(title, false)))
  694. end
  695. if title and title:find(query, 1, true) or
  696. title and romanization and ligature_conv_title:find(query, 1, true) or
  697. hint and hint:find(query, 1, true) or
  698. title and initials_title:find(query, 1, true) or
  699. title and romanization and initials_conv_title:find(query, 1, true) or
  700. hint and concat(initials(hint)):find(query, 1, true) then
  701. item = table_assign({}, item)
  702. item.title = prefixed_title
  703. item.ass_safe_title = nil
  704. result[#result + 1] = item
  705. end
  706. end
  707. end
  708. end
  709. return result
  710. end
  711. ---@param menu? MenuStack
  712. function Menu:search_submit(menu)
  713. menu = menu or self.current
  714. if not menu.search then return end
  715. if menu.on_search then
  716. local search_type = type(menu.on_search)
  717. if search_type == 'string' then
  718. mp.command(menu.on_search .. ' ' .. menu.search.query)
  719. elseif search_type == 'table' then
  720. local command = itable_join({}, menu.on_search)
  721. command[#command + 1] = menu.search.query
  722. mp.command_native(command)
  723. else
  724. menu.on_search(menu.search.query)
  725. end
  726. else
  727. self:search_internal(menu)
  728. end
  729. end
  730. ---@param query string
  731. ---@param menu? MenuStack
  732. function Menu:search_query_update(query, menu)
  733. menu = menu or self.current
  734. menu.search.query = query
  735. if menu.search_debounce ~= 'submit' then
  736. if menu.search.timeout then
  737. menu.search.timeout:kill()
  738. menu.search.timeout:resume()
  739. else
  740. self:search_submit(menu)
  741. end
  742. end
  743. request_render()
  744. end
  745. ---@param event? string
  746. ---@param word_mode? boolean Delete by words.
  747. function Menu:search_backspace(event, word_mode)
  748. local pos, old_query = #self.current.search.query, self.current.search.query
  749. local is_palette = self.current.search_style == 'palette'
  750. if word_mode then
  751. local word_pat, other_pat = '[^%c%s%p]+$', '[%c%s%p]+$'
  752. local init_pat = old_query:sub(#old_query):match(word_pat) and word_pat or other_pat
  753. -- First we match all same type consecutive chars at the end
  754. local tail = old_query:match(init_pat) or ''
  755. -- If there's only one, we extend the tail with opposite type chars
  756. if tail and #tail == 1 then
  757. tail = tail .. old_query:sub(1, #old_query - #tail):match(init_pat == word_pat and other_pat or word_pat)
  758. end
  759. pos = pos - #tail
  760. else
  761. -- The while loop is for skipping utf8 continuation bytes
  762. while pos > 1 and old_query:byte(pos) >= 0x80 and old_query:byte(pos) <= 0xbf do
  763. pos = pos - 1
  764. end
  765. pos = pos - 1
  766. end
  767. local new_query = old_query:sub(1, pos)
  768. if new_query ~= old_query and (is_palette or not self.type_to_search or pos > 0) then
  769. self:search_query_update(new_query)
  770. elseif not is_palette and self.type_to_search then
  771. self:search_stop()
  772. elseif is_palette and event ~= 'repeat' then
  773. self:back()
  774. end
  775. end
  776. function Menu:search_text_input(info)
  777. local menu = self.current
  778. if not menu.search and menu.search_style == 'disabled' then return end
  779. if info.event ~= 'up' then
  780. local key_text = info.key_text
  781. if not key_text then
  782. -- might be KP0 to KP9 or KP_DEC
  783. key_text = info.key_name:match('KP_?(.+)')
  784. if not key_text then return end
  785. if key_text == 'DEC' then key_text = '.' end
  786. end
  787. if not menu.search then self:search_start() end
  788. self:search_query_update(menu.search.query .. key_text)
  789. end
  790. end
  791. ---@param menu? MenuStack
  792. function Menu:search_stop(menu)
  793. menu = menu or self.current
  794. self:search_query_update('', menu)
  795. menu.search = nil
  796. self:search_ensure_key_bindings()
  797. self:update_dimensions()
  798. self:reset_navigation()
  799. end
  800. ---@param menu? MenuStack
  801. function Menu:search_init(menu)
  802. menu = menu or self.current
  803. if menu.search then return end
  804. local timeout
  805. if menu.search_debounce ~= 'submit' and menu.search_debounce > 0 then
  806. timeout = mp.add_timeout(menu.search_debounce / 1000, self:create_action(function()
  807. self:search_submit(menu)
  808. end))
  809. timeout:kill()
  810. end
  811. menu.search = {
  812. query = '',
  813. timeout = timeout,
  814. min_top = menu.top,
  815. max_width = menu.width,
  816. source = {
  817. width = menu.width,
  818. top = menu.top,
  819. scroll_y = menu.scroll_y,
  820. selected_index = menu.selected_index,
  821. items = not menu.on_search and menu.items or nil,
  822. },
  823. }
  824. end
  825. ---@param menu? MenuStack
  826. function Menu:search_start(menu)
  827. if (menu or self.current).search_style == 'disabled' then return end
  828. self:search_init(menu)
  829. self:search_ensure_key_bindings()
  830. self:update_dimensions()
  831. end
  832. ---@param menu? MenuStack
  833. function Menu:search_clear_query(menu)
  834. menu = menu or self.current
  835. if not self.current.search_style == 'palette' and self.type_to_search then
  836. self:search_stop(menu)
  837. else
  838. self:search_query_update('', menu)
  839. end
  840. end
  841. function Menu:key_bs(info)
  842. if info.event ~= 'up' then
  843. if self.current.search then
  844. if self.modifiers.shift then
  845. self:search_clear_query()
  846. else
  847. self:search_backspace(info.event, self.modifiers.ctrl)
  848. end
  849. elseif info.event ~= 'repeat' then
  850. self:back()
  851. end
  852. end
  853. end
  854. function Menu:key_ctrl_enter()
  855. if self.current.search then
  856. self:search_submit()
  857. else
  858. self:open_selected_item_preselect()
  859. end
  860. end
  861. function Menu:key_left()
  862. if self.current.search then -- control cursor when it's implemented
  863. else
  864. self:back()
  865. end
  866. end
  867. function Menu:key_right()
  868. if self.current.search then -- control cursor when it's implemented
  869. else
  870. self:open_selected_item_preselect()
  871. end
  872. end
  873. function Menu:search_enable_key_bindings()
  874. if #self.key_bindings_search ~= 0 then return end
  875. local flags = {repeatable = true, complex = true}
  876. local add_key_binding = self.type_to_search and self.add_key_binding or self.search_add_key_binding
  877. add_key_binding(self, 'any_unicode', 'menu-search', self:create_key_action('search_text_input'), flags)
  878. -- KP0 to KP9 and KP_DEC are not included in any_unicode
  879. -- despite typically producing characters, they don't have a info.key_text
  880. add_key_binding(self, 'kp_dec', 'menu-search-kp-dec', self:create_key_action('search_text_input'), flags)
  881. for i = 0, 9 do
  882. add_key_binding(self, 'kp' .. i, 'menu-search-kp' .. i, self:create_key_action('search_text_input'), flags)
  883. end
  884. end
  885. function Menu:search_ensure_key_bindings()
  886. if self.type_to_search then return end
  887. if self.current.search then
  888. self:search_enable_key_bindings()
  889. else
  890. self:search_disable_key_bindings()
  891. end
  892. end
  893. function Menu:search_disable_key_bindings()
  894. for _, name in ipairs(self.key_bindings_search) do mp.remove_key_binding(name) end
  895. self.key_bindings_search = {}
  896. end
  897. function Menu:search_add_key_binding(key, name, fn, flags)
  898. self.key_bindings_search[#self.key_bindings_search + 1] = name
  899. mp.add_forced_key_binding(key, name, fn, flags)
  900. end
  901. function Menu:add_key_binding(key, name, fn, flags)
  902. self.key_bindings[#self.key_bindings + 1] = name
  903. mp.add_forced_key_binding(key, name, fn, flags)
  904. end
  905. function Menu:enable_key_bindings()
  906. -- The `mp.set_key_bindings()` method would be easier here, but that
  907. -- doesn't support 'repeatable' flag, so we are stuck with this monster.
  908. self:add_key_binding('up', 'menu-prev1', self:create_key_action('prev'), 'repeatable')
  909. self:add_key_binding('down', 'menu-next1', self:create_key_action('next'), 'repeatable')
  910. self:add_key_binding('ctrl+up', 'menu-move-up', self:create_key_action('move_selected_item_up'), 'repeatable')
  911. self:add_key_binding('ctrl+down', 'menu-move-down', self:create_key_action('move_selected_item_down'), 'repeatable')
  912. self:add_key_binding('left', 'menu-back1', self:create_key_action('key_left'))
  913. self:add_key_binding('right', 'menu-select1', self:create_key_action('key_right'))
  914. self:add_key_binding('shift+right', 'menu-select-soft1',
  915. self:create_key_action('open_selected_item_soft', {shift = true}))
  916. self:add_key_binding('shift+mbtn_left', 'menu-select3', self:create_modified_mbtn_left_handler({shift = true}))
  917. self:add_key_binding('ctrl+mbtn_left', 'menu-select4', self:create_modified_mbtn_left_handler({ctrl = true}))
  918. self:add_key_binding('alt+mbtn_left', 'menu-select5', self:create_modified_mbtn_left_handler({alt = true}))
  919. self:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_key_action('back'))
  920. self:add_key_binding('bs', 'menu-back-alt4', self:create_key_action('key_bs'), {repeatable = true, complex = true})
  921. self:add_key_binding('shift+bs', 'menu-clear-query', self:create_key_action('key_bs', {shift = true}),
  922. {repeatable = true, complex = true})
  923. self:add_key_binding('ctrl+bs', 'menu-delete-word', self:create_key_action('key_bs', {ctrl = true}),
  924. {repeatable = true, complex = true})
  925. self:add_key_binding('enter', 'menu-select-alt3', self:create_key_action('open_selected_item_preselect'))
  926. self:add_key_binding('kp_enter', 'menu-select-alt4', self:create_key_action('open_selected_item_preselect'))
  927. self:add_key_binding('ctrl+enter', 'menu-select-ctrl1', self:create_key_action('key_ctrl_enter', {ctrl = true}))
  928. self:add_key_binding('alt+enter', 'menu-select-alt1',
  929. self:create_key_action('open_selected_item_preselect', {alt = true}))
  930. self:add_key_binding('ctrl+kp_enter', 'menu-select-ctrl2',
  931. self:create_key_action('open_selected_item_preselect', {ctrl = true}))
  932. self:add_key_binding('alt+kp_enter', 'menu-select-alt2',
  933. self:create_key_action('open_selected_item_preselect', {alt = true}))
  934. self:add_key_binding('shift+enter', 'menu-select-alt5',
  935. self:create_key_action('open_selected_item_soft', {shift = true}))
  936. self:add_key_binding('shift+kp_enter', 'menu-select-alt6',
  937. self:create_key_action('open_selected_item_soft', {shift = true}))
  938. self:add_key_binding('esc', 'menu-close', self:create_key_action('close'))
  939. self:add_key_binding('pgup', 'menu-page-up', self:create_key_action('on_pgup'), 'repeatable')
  940. self:add_key_binding('pgdwn', 'menu-page-down', self:create_key_action('on_pgdwn'), 'repeatable')
  941. self:add_key_binding('home', 'menu-home', self:create_key_action('on_home'))
  942. self:add_key_binding('end', 'menu-end', self:create_key_action('on_end'))
  943. self:add_key_binding('del', 'menu-delete-item', self:create_key_action('delete_selected_item'))
  944. self:add_key_binding('ctrl+v', 'menu-paste', self:create_key_action('paste'))
  945. if self.type_to_search then
  946. self:search_enable_key_bindings()
  947. else
  948. self:add_key_binding('/', 'menu-search1', self:create_key_action('search_start'))
  949. self:add_key_binding('ctrl+f', 'menu-search2', self:create_key_action('search_start'))
  950. end
  951. end
  952. function Menu:disable_key_bindings()
  953. self:search_disable_key_bindings()
  954. for _, name in ipairs(self.key_bindings) do mp.remove_key_binding(name) end
  955. self.key_bindings = {}
  956. end
  957. -- Check if menu is not closed or closing.
  958. function Menu:is_alive() return not self.is_closing and not self.is_closed end
  959. -- Wraps a function so that it won't run if menu is closing or closed.
  960. ---@param fn function()
  961. function Menu:create_action(fn)
  962. return function(...)
  963. if self:is_alive() then fn(...) end
  964. end
  965. end
  966. ---@param modifiers Modifiers
  967. function Menu:create_modified_mbtn_left_handler(modifiers)
  968. return self:create_action(function()
  969. self.mouse_nav = true
  970. self.modifiers = modifiers or {}
  971. self:handle_cursor_down()
  972. self:handle_cursor_up()
  973. self.modifiers = {}
  974. end)
  975. end
  976. ---@param name string
  977. ---@param modifiers? Modifiers
  978. function Menu:create_key_action(name, modifiers)
  979. return self:create_action(function(...)
  980. self.mouse_nav = false
  981. self.modifiers = modifiers or {}
  982. self:maybe(name, ...)
  983. self.modifiers = {}
  984. end)
  985. end
  986. function Menu:render()
  987. for _, menu in ipairs(self.all) do
  988. if menu.fling then
  989. local time_delta = state.render_last_time - menu.fling.time
  990. local progress = menu.fling.easing(math.min(time_delta / menu.fling.duration, 1))
  991. self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu)
  992. if progress < 1 then request_render() else menu.fling = nil end
  993. end
  994. end
  995. local display_rect = {ax = 0, ay = 0, bx = display.width, by = display.height}
  996. cursor:zone('primary_down', display_rect, self:create_action(function() self:handle_cursor_down() end))
  997. cursor:zone('primary_up', display_rect, self:create_action(function() self:handle_cursor_up() end))
  998. cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
  999. cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
  1000. local ass = assdraw.ass_new()
  1001. local spacing = self.item_padding
  1002. local icon_size = self.font_size
  1003. ---@param menu MenuStack
  1004. ---@param x number
  1005. ---@param pos number Horizontal position index. 0 = current menu, <0 parent menus, >1 submenu.
  1006. local function draw_menu(menu, x, pos)
  1007. local is_current, is_parent, is_submenu = pos == 0, pos < 0, pos > 0
  1008. local menu_opacity = (pos == 0 and 1 or config.opacity.submenu ^ math.abs(pos)) * self.opacity
  1009. local ax, ay, bx, by = x, menu.top, x + menu.width, menu.top + menu.height
  1010. local draw_title = menu.is_root and menu.title or menu.search
  1011. local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')'
  1012. local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1
  1013. local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step)
  1014. local menu_rect = {
  1015. ax = ax,
  1016. ay = ay - (draw_title and self.scroll_step + self.padding or 0) - self.padding,
  1017. bx = bx,
  1018. by = by + self.padding,
  1019. }
  1020. local blur_selected_index = is_current and self.mouse_nav
  1021. -- Background
  1022. ass:rect(menu_rect.ax, menu_rect.ay, menu_rect.bx, menu_rect.by, {
  1023. color = bg,
  1024. opacity = menu_opacity * config.opacity.menu,
  1025. radius = state.radius > 0 and state.radius + self.padding or 0,
  1026. })
  1027. if is_parent then
  1028. cursor:zone('primary_down', menu_rect, self:create_action(function() self:slide_in_menu(menu, x) end))
  1029. end
  1030. -- Draw submenu if selected
  1031. local submenu_rect, current_item = nil, is_current and menu.selected_index and menu.items[menu.selected_index]
  1032. local submenu_is_hovered = false
  1033. if current_item and current_item.items then
  1034. submenu_rect = draw_menu(current_item, menu_rect.bx + self.gap, 1)
  1035. cursor:zone('primary_down', submenu_rect, self:create_action(function()
  1036. self:open_selected_item({preselect_first_item = false})
  1037. end))
  1038. end
  1039. for index = start_index, end_index, 1 do
  1040. local item = menu.items[index]
  1041. if not item then break end
  1042. local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
  1043. local item_by = item_ay + self.item_height
  1044. local item_center_y = item_ay + (self.item_height / 2)
  1045. local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil
  1046. local content_ax, content_bx = ax + self.padding + spacing, bx - self.padding - spacing
  1047. local is_selected = menu.selected_index == index
  1048. -- Select hovered item
  1049. if is_current and self.mouse_nav and item.selectable ~= false then
  1050. if submenu_rect and cursor:direction_to_rectangle_distance(submenu_rect) then
  1051. blur_selected_index = false
  1052. else
  1053. local item_rect_hitbox = {
  1054. ax = menu_rect.ax + self.padding,
  1055. ay = item_ay,
  1056. bx = menu_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the gap with cursor
  1057. by = item_by,
  1058. }
  1059. if submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) == 0 then
  1060. blur_selected_index = false
  1061. menu.selected_index = index
  1062. if not is_selected then request_render() end
  1063. end
  1064. end
  1065. end
  1066. local has_background = is_selected or item.active
  1067. local next_item = menu.items[index + 1]
  1068. local next_is_active = next_item and next_item.active
  1069. local next_has_background = menu.selected_index == index + 1 or next_is_active
  1070. local font_color = item.active and fgt or bgt
  1071. -- Separator
  1072. if item_by < by and ((not has_background and not next_has_background) or item.separator) then
  1073. local separator_ay, separator_by = item_by, item_by + self.separator_size
  1074. if has_background then
  1075. separator_ay, separator_by = separator_ay + self.separator_size, separator_by + self.separator_size
  1076. elseif next_has_background then
  1077. separator_ay, separator_by = separator_ay - self.separator_size, separator_by - self.separator_size
  1078. end
  1079. ass:rect(ax + spacing, separator_ay, bx - spacing, separator_by, {
  1080. color = fg, opacity = menu_opacity * (item.separator and 0.13 or 0.04),
  1081. })
  1082. end
  1083. -- Background
  1084. local highlight_opacity = 0 + (item.active and 0.8 or 0) + (is_selected and 0.15 or 0)
  1085. if not is_submenu and highlight_opacity > 0 then
  1086. ass:rect(ax + self.padding, item_ay, bx - self.padding, item_by, {
  1087. radius = state.radius,
  1088. color = fg,
  1089. opacity = highlight_opacity * menu_opacity,
  1090. clip = item_clip,
  1091. })
  1092. end
  1093. -- Icon
  1094. if item.icon then
  1095. local x = (not item.title and not item.hint and item.align == 'center')
  1096. and menu_rect.ax + (menu_rect.bx - menu_rect.ax) / 2
  1097. or content_bx - (icon_size / 2)
  1098. if item.icon == 'spinner' then
  1099. ass:spinner(x, item_center_y, icon_size * 1.5, {color = font_color, opacity = menu_opacity * 0.8})
  1100. else
  1101. ass:icon(x, item_center_y, icon_size * 1.5, item.icon, {
  1102. color = font_color, opacity = menu_opacity, clip = item_clip,
  1103. })
  1104. end
  1105. content_bx = content_bx - icon_size - spacing
  1106. end
  1107. local title_cut_x = content_bx
  1108. if item.hint_width > 0 then
  1109. -- controls title & hint clipping proportional to the ratio of their widths
  1110. -- both title and hint get at least 50% of the width, unless they are smaller then that
  1111. local width = content_bx - content_ax - spacing
  1112. local title_min = math.min(item.title_width, width * 0.5)
  1113. local hint_min = math.min(item.hint_width, width * 0.5)
  1114. local title_ratio = item.title_width / (item.title_width + item.hint_width)
  1115. title_cut_x = round(content_ax + clamp(title_min, width * title_ratio, width - hint_min))
  1116. end
  1117. -- Hint
  1118. if item.hint then
  1119. item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
  1120. local clip = '\\clip(' .. title_cut_x + spacing .. ',' ..
  1121. math.max(item_ay, ay) .. ',' .. bx .. ',' .. math.min(item_by, by) .. ')'
  1122. ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
  1123. size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * menu_opacity, clip = clip,
  1124. })
  1125. end
  1126. -- Title
  1127. if item.title then
  1128. item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
  1129. local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
  1130. .. title_cut_x .. ',' .. math.min(item_by, by) .. ')'
  1131. local title_x, align = content_ax, 4
  1132. if item.align == 'right' then
  1133. title_x, align = title_cut_x, 6
  1134. elseif item.align == 'center' then
  1135. title_x, align = content_ax + (title_cut_x - content_ax) / 2, 5
  1136. end
  1137. ass:txt(title_x, item_center_y, align, item.ass_safe_title, {
  1138. size = self.font_size,
  1139. color = font_color,
  1140. italic = item.italic,
  1141. bold = item.bold,
  1142. wrap = 2,
  1143. opacity = menu_opacity * (item.muted and 0.5 or 1),
  1144. clip = clip,
  1145. })
  1146. end
  1147. end
  1148. -- Menu title
  1149. if draw_title then
  1150. local requires_submit = menu.search_debounce == 'submit'
  1151. local rect = {
  1152. ax = ax + spacing / 2 + self.padding,
  1153. ay = ay - self.scroll_step - self.padding * 2,
  1154. bx = bx - spacing / 2 - self.padding,
  1155. by = math.min(by, ay - self.padding),
  1156. }
  1157. rect.cx, rect.cy = rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2 -- centers
  1158. if menu.title and not menu.ass_safe_title then
  1159. menu.ass_safe_title = ass_escape(menu.title)
  1160. end
  1161. -- Bottom border
  1162. ass:rect(ax, rect.by - self.separator_size, bx, rect.by, {color = fg, opacity = menu_opacity * 0.2})
  1163. -- Do nothing when user clicks title
  1164. if is_current then
  1165. cursor:zone('primary_down', rect, function() end)
  1166. end
  1167. -- Title
  1168. if menu.search then
  1169. -- Icon
  1170. local icon_size, icon_opacity = self.font_size * 1.3, menu_opacity * (requires_submit and 0.5 or 1)
  1171. local icon_rect = {ax = rect.ax, ay = rect.ay, bx = ax + icon_size + spacing * 1.5, by = rect.by}
  1172. if is_current and requires_submit then
  1173. cursor:zone('primary_down', icon_rect, function() self:search_submit() end)
  1174. if get_point_to_rectangle_proximity(cursor, icon_rect) == 0 then
  1175. icon_opacity = menu_opacity
  1176. end
  1177. end
  1178. ass:icon(rect.ax + icon_size / 2, rect.cy, icon_size, 'search', {
  1179. color = fg,
  1180. opacity = icon_opacity,
  1181. clip = '\\clip(' ..
  1182. icon_rect.ax .. ',' .. icon_rect.ay .. ',' .. icon_rect.bx .. ',' .. icon_rect.by .. ')',
  1183. })
  1184. -- Query/Placeholder
  1185. if menu.search.query ~= '' then
  1186. -- Add a ZWNBSP suffix to prevent libass from trimming trailing spaces
  1187. local query = ass_escape(menu.search.query) .. '\239\187\191'
  1188. ass:txt(rect.bx, rect.cy, 6, query, {
  1189. size = self.font_size,
  1190. color = bgt,
  1191. wrap = 2,
  1192. opacity = menu_opacity,
  1193. clip = '\\clip(' .. icon_rect.bx .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
  1194. })
  1195. else
  1196. local placeholder = (menu.search_style == 'palette' and menu.ass_safe_title)
  1197. and menu.ass_safe_title
  1198. or (requires_submit and t('type & ctrl+enter to search') or t('type to search'))
  1199. ass:txt(rect.bx, rect.cy, 6, placeholder, {
  1200. size = self.font_size,
  1201. italic = true,
  1202. color = bgt,
  1203. wrap = 2,
  1204. opacity = menu_opacity * 0.4,
  1205. clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
  1206. })
  1207. end
  1208. -- Cursor
  1209. local font_size_half, cursor_thickness = round(self.font_size / 2), round(self.font_size / 14)
  1210. local cursor_ax, cursor_bx = rect.bx + 1, rect.bx + 1 + cursor_thickness
  1211. ass:rect(cursor_ax, rect.cy - font_size_half, cursor_bx, rect.cy + font_size_half, {
  1212. color = fg,
  1213. opacity = menu_opacity * 0.5,
  1214. clip = '\\clip(' .. cursor_ax .. ',' .. rect.ay .. ',' .. cursor_bx .. ',' .. rect.by .. ')',
  1215. })
  1216. else
  1217. ass:txt(rect.cx, rect.cy, 5, menu.ass_safe_title, {
  1218. size = self.font_size,
  1219. bold = true,
  1220. color = bgt,
  1221. wrap = 2,
  1222. opacity = menu_opacity,
  1223. clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
  1224. })
  1225. end
  1226. end
  1227. -- Scrollbar
  1228. if menu.scroll_height > 0 then
  1229. local groove_height = menu.height - 2
  1230. local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
  1231. local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
  1232. local sax = bx - round(self.scrollbar_size / 2)
  1233. local sbx = sax + self.scrollbar_size
  1234. ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
  1235. end
  1236. -- We are in mouse nav and cursor isn't hovering any item
  1237. if blur_selected_index then
  1238. menu.selected_index = nil
  1239. end
  1240. return menu_rect
  1241. end
  1242. -- Main menu
  1243. draw_menu(self.current, self.ax, 0)
  1244. -- Parent menus
  1245. local parent_menu = self.current.parent_menu
  1246. local parent_offset_x, parent_horizontal_index = self.ax, -1
  1247. while parent_menu do
  1248. parent_offset_x = parent_offset_x - parent_menu.width - self.gap
  1249. draw_menu(parent_menu, parent_offset_x, parent_horizontal_index)
  1250. parent_horizontal_index = parent_horizontal_index - 1
  1251. parent_menu = parent_menu.parent_menu
  1252. end
  1253. return ass
  1254. end
  1255. return Menu