feat: improved menu cursor navigation

- Left side of the empty menu screen is no longer a huge invisible back button. Instead, each parent menu can be clicked individually to navigate to it. Clicking empty space now correctly closes the menu.
- Submenus can now also be clicked to navigate to them.
- Moving cursor towards a submenu will not cause other items in current menu to be selected if cursor moves through them.

ref #217
This commit is contained in:
tomasklaen 2023-05-17 21:06:17 +02:00
parent 78b5d9e59f
commit 10768d212d
4 changed files with 187 additions and 72 deletions

View File

@ -21,10 +21,12 @@
"bestaudio",
"bestvideo",
"Bopomofo",
"curr",
"demux",
"doubleclick",
"gfps",
"hidpi",
"hitbox",
"jfif",
"logicaldisk",
"outro",

View File

@ -319,6 +319,8 @@ cursor = {
on_wheel_down = nil,
on_wheel_up = nil,
allow_dragging = false,
history = {}, -- {x, y}[] history
history_size = 10,
-- Called at the beginning of each render
reset_handlers = function()
cursor.on_primary_down, cursor.on_primary_up = nil, nil
@ -355,6 +357,17 @@ cursor = {
cursor.autohide_timer:kill()
cursor.autohide_timer:resume()
end
end,
-- Calculates distance in which cursor reaches rectangle if it continues moving in the same path.
-- Returns `nil` if cursor is not moving towards the rectangle.
direction_to_rectangle_distance = function(rect)
if cursor.hidden or not cursor.history[1] then
return false
end
local prev_x, prev_y = cursor.history[1][1], cursor.history[1][2]
local end_x, end_y = cursor.x + (cursor.x - prev_x) * 1e10, cursor.y + (cursor.y - prev_y) * 1e10
return get_ray_to_rectangle_distance(cursor.x, cursor.y, end_x, end_y, rect)
end
}
state = {
@ -541,18 +554,24 @@ function update_cursor_position(x, y)
else x, y = INFINITY, INFINITY end
end
-- add 0.5 to be in the middle of the pixel
-- Add 0.5 to be in the middle of the pixel
cursor.x, cursor.y = (x + 0.5) / display.scale_x, (y + 0.5) / display.scale_y
if old_x ~= cursor.x or old_y ~= cursor.y then
Elements:update_proximities()
if cursor.x == INFINITY or cursor.y == INFINITY then
cursor.hidden = true
cursor.hidden, cursor.history = true, {}
Elements:trigger('global_mouse_leave')
elseif cursor.hidden then
cursor.hidden = false
cursor.hidden, cursor.history = false, {}
Elements:trigger('global_mouse_enter')
else
-- Update cursor history
for i = 1, cursor.history_size - 1, 1 do
cursor.history[i] = cursor.history[i + 1]
end
cursor.history[cursor.history_size] = {x, y}
end
Elements:proximity_trigger('mouse_move')

View File

@ -268,11 +268,12 @@ function Menu:reset_navigation()
-- Reset indexes and scroll
self:scroll_to(menu.scroll_y) -- clamps scroll_y to scroll limits
if self.mouse_nav then
self:select_item_below_cursor()
elseif menu.items and #menu.items > 0 then
local from = clamp(1, menu.selected_index or 1, #menu.items)
self:select_index(itable_find(menu.items, function(item) return item.selectable ~= false end, from), menu)
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 and not menu.selected_index then
local from = clamp(1, menu.selected_index or 1, #menu.items)
self:select_index(itable_find(menu.items, function(item) return item.selectable ~= false end, from), menu)
end
else
self:select_index(nil)
end
@ -295,12 +296,6 @@ end
function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end
function Menu:get_item_index_below_cursor()
local menu = self.current
if #menu.items < 1 or self.proximity_raw > 0 then return nil end
return math.max(1, math.min(math.ceil((cursor.y - self.ay + menu.scroll_y) / self.scroll_step), #menu.items))
end
function Menu:get_first_active_index(menu)
menu = menu or self.current
for index, item in ipairs(self.current.items) do
@ -468,26 +463,33 @@ function Menu:next(menu)
end
end
---@param menu MenuStack One of menus in `self.all`.
---@param x number `x` coordinate to slide from.
function Menu:slide_in_menu(menu, x)
local current = self.current
current.selected_index = nil
self:activate_menu(menu)
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 self.opts.on_back then
self.opts.on_back()
if self.is_closed then return end
end
local menu = self.current
local parent = menu.parent_menu
local current = self.current
local parent = current.parent_menu
if parent then
menu.selected_index = nil
self:activate_menu(parent)
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
self:slide_in_menu(parent, display.width / 2 - current.width / 2 - parent.width / 2 + self.offset_x)
else
self:close()
end
end
---@param opts? {keep_open?: boolean, preselect_submenu_item?: boolean}
---@param opts? {keep_open?: boolean, preselect_first_item?: boolean}
function Menu:open_selected_item(opts)
opts = opts or {}
local menu = self.current
@ -495,7 +497,7 @@ function Menu:open_selected_item(opts)
local item = menu.items[menu.selected_index]
-- Is submenu
if item.items then
if opts.preselect_submenu_item then
if opts.preselect_first_item then
item.selected_index = #item.items > 0 and 1 or nil
end
self:activate_menu(item)
@ -509,11 +511,7 @@ function Menu:open_selected_item(opts)
end
function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end
function Menu:open_selected_item_preselect() self:open_selected_item({preselect_submenu_item = true}) end
function Menu:select_item_below_cursor()
local index = self:get_item_index_below_cursor()
self.current.selected_index = index and self.current.items[index].selectable ~= false and index or nil
end
function Menu:open_selected_item_preselect() self:open_selected_item({preselect_first_item = true}) end
---@param index integer
function Menu:move_selected_item_to(index)
@ -546,8 +544,7 @@ function Menu:handle_cursor_down()
self.drag_data = {{y = cursor.y, time = mp.get_time()}}
self.current.fling = nil
else
if cursor.x < self.ax and self.current.parent_menu then self:back()
else self:close() end
self:close()
end
end
@ -563,8 +560,7 @@ end
function Menu:handle_cursor_up()
if self.proximity_raw == 0 and self.drag_data and not self.is_dragging then
self:select_item_below_cursor()
self:open_selected_item({preselect_submenu_item = false, keep_open = self.modifiers and self.modifiers.shift})
self:open_selected_item({preselect_first_item = false, keep_open = self.modifiers and self.modifiers.shift})
end
if self.is_dragging then
local distance = self:fling_distance()
@ -579,7 +575,6 @@ function Menu:handle_cursor_up()
self.drag_data = nil
end
function Menu:on_global_mouse_move()
self.mouse_nav = true
if self.drag_data then
@ -588,8 +583,6 @@ function Menu:on_global_mouse_move()
if distance ~= 0 then self:set_scroll_by(distance) end
self.drag_data[#self.drag_data + 1] = {y = cursor.y, time = mp.get_time()}
end
if self.proximity_raw == 0 or self.is_dragging then self:select_item_below_cursor()
else self.current.selected_index = nil end
request_render()
end
@ -688,17 +681,14 @@ function Menu:create_key_action(name, modifiers)
end
function Menu:render()
local update_cursor = false
for _, menu in ipairs(self.all) do
if menu.fling then
update_cursor = update_cursor or menu.fling.update_cursor or false
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)
if progress < 1 then request_render() else menu.fling = nil end
end
end
if update_cursor then self:select_item_below_cursor() end
cursor.on_primary_down = function() self:handle_cursor_down() end
cursor.on_primary_up = function() self:handle_cursor_up() end
@ -711,28 +701,44 @@ function Menu:render()
local opacity = options.menu_opacity * self.opacity
local spacing = self.item_padding
local icon_size = self.font_size
local menu_gap, menu_padding = 2, 2
function draw_menu(menu, x, y, opacity)
local ax, ay, bx, by = x, y, x + menu.width, y + menu.height
---@param menu MenuStack
---@param x number
---@param pos number Horizontal position index. 0 = current menu, <0 parent menus, >1 submenu.
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 opacity or opacity * (options.menu_parent_opacity ^ math.abs(pos))
local ax, ay, bx, by = x, menu.top, x + menu.width, menu.top + menu.height
local draw_title = menu.is_root and menu.title
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 selected_index = menu.selected_index or -1
-- remove menu_opacity to start off with full opacity, but still decay for parent menus
local text_opacity = opacity / options.menu_opacity
-- Remove menu_opacity to start off with full, but still decay for parent menus
local text_opacity = menu_opacity / options.menu_opacity
local menu_rect = {ax = ax, ay = ay - (draw_title and self.item_height or 0) - 2, bx = bx, by = by + 2}
local blur_selected_index = is_current and self.mouse_nav
-- Background
ass:rect(ax, ay - (draw_title and self.item_height or 0) - 2, bx, by + 2, {
color = bg, opacity = opacity, radius = 4,
})
ass:rect(menu_rect.ax, menu_rect.ay, menu_rect.bx, menu_rect.by, {color = bg, opacity = menu_opacity, radius = 4})
if is_parent and get_point_to_rectangle_proximity(cursor, menu_rect) == 0 then
cursor.on_primary_down = function() self:slide_in_menu(menu, x) end
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, menu_rect.bx + menu_gap, 1)
submenu_is_hovered = get_point_to_rectangle_proximity(cursor, submenu_rect) == 0
if submenu_is_hovered then
cursor.on_primary_down = function() self:open_selected_item({preselect_first_item = false}) end
end
end
for index = start_index, end_index, 1 do
local item = menu.items[index]
local next_item = menu.items[index + 1]
local is_highlighted = selected_index == index or item.active
local next_is_active = next_item and next_item.active
local next_is_highlighted = selected_index == index + 1 or next_is_active
if not item then break end
@ -741,24 +747,47 @@ function Menu:render()
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 + spacing, bx - spacing
local is_selected = menu.selected_index == index or item.active
-- Select hovered item
if is_current and self.mouse_nav then
if submenu_rect and cursor.direction_to_rectangle_distance(submenu_rect) then
blur_selected_index = false
else
local item_rect_hitbox = {
ax = menu_rect.ax + menu_padding,
ay = item_ay,
bx = menu_rect.bx + (item.items and menu_gap or -menu_padding), -- to bridge the gap with cursor
by = item_by
}
if submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) == 0 then
blur_selected_index = false
menu.selected_index = index
end
end
end
local next_item = menu.items[index + 1]
local next_is_active = next_item and next_item.active
local next_is_highlighted = menu.selected_index == index + 1 or next_is_active
local font_color = item.active and fgt or bgt
local shadow_color = item.active and fg or bg
-- Separator
local separator_ay = item.separator and item_by - 1 or item_by
local separator_by = item_by + (item.separator and 2 or 1)
if is_highlighted then separator_ay = item_by + 1 end
if is_selected then separator_ay = item_by + 1 end
if next_is_highlighted then separator_by = item_by end
if separator_by - separator_ay > 0 and item_by < by then
ass:rect(ax + spacing / 2, separator_ay, bx - spacing / 2, separator_by, {
color = fg, opacity = opacity * (item.separator and 0.08 or 0.06),
color = fg, opacity = menu_opacity * (item.separator and 0.08 or 0.06),
})
end
-- Highlight
local highlight_opacity = 0 + (item.active and 0.8 or 0) + (selected_index == index and 0.15 or 0)
if highlight_opacity > 0 then
ass:rect(ax + 2, item_ay, bx - 2, item_by, {
local highlight_opacity = 0 + (item.active and 0.8 or 0) + (menu.selected_index == index and 0.15 or 0)
if not is_submenu and highlight_opacity > 0 then
ass:rect(ax + menu_padding, item_ay, bx - menu_padding, item_by, {
radius = 2, color = fg, opacity = highlight_opacity * text_opacity,
clip = item_clip,
})
@ -792,7 +821,7 @@ function Menu:render()
local clip = '\\clip(' .. title_cut_x .. ',' ..
math.max(item_ay, ay) .. ',' .. 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 * opacity, clip = clip,
size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * menu_opacity, clip = clip,
shadow = 1, shadow_color = shadow_color,
})
end
@ -824,15 +853,15 @@ function Menu:render()
-- Background
ass:rect(ax + 2, title_ay, bx - 2, title_ay + title_height, {
color = fg, opacity = opacity * 0.8, radius = 2,
color = fg, opacity = menu_opacity * 0.8, radius = 2,
})
ass:texture(ax + 2, title_ay, bx - 2, title_ay + title_height, 'n', {
size = 80, color = bg, opacity = opacity * 0.1,
size = 80, color = bg, opacity = menu_opacity * 0.1,
})
-- Title
ass:txt(ax + menu.width / 2, title_ay + (title_height / 2), 5, menu.ass_safe_title, {
size = self.font_size, bold = true, color = bg, wrap = 2, opacity = opacity,
size = self.font_size, bold = true, color = bg, wrap = 2, opacity = menu_opacity,
clip = '\\clip(' .. ax .. ',' .. title_ay .. ',' .. bx .. ',' .. ay .. ')',
})
end
@ -842,33 +871,31 @@ function Menu:render()
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))
ass:rect(bx - 3, thumb_y, bx - 1, thumb_y + thumb_height, {color = fg, opacity = opacity * 0.8})
ass:rect(bx - 3, thumb_y, bx - 1, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
end
-- We are in mouse nav and cursor isn't hovering any item
if blur_selected_index then
menu.selected_index = nil
end
return menu_rect
end
-- Main menu
draw_menu(self.current, self.ax, self.ay, opacity)
draw_menu(self.current, self.ax, 0)
-- Parent menus
local parent_menu = self.current.parent_menu
local parent_offset_x = self.ax
local parent_opacity_factor = options.menu_parent_opacity
local menu_gap = 2
local parent_offset_x, parent_horizontal_index = self.ax, -1
while parent_menu do
parent_offset_x = parent_offset_x - parent_menu.width - menu_gap
draw_menu(parent_menu, parent_offset_x, parent_menu.top, parent_opacity_factor * opacity)
parent_opacity_factor = parent_opacity_factor * parent_opacity_factor
draw_menu(parent_menu, parent_offset_x, parent_horizontal_index)
parent_horizontal_index = parent_horizontal_index - 1
parent_menu = parent_menu.parent_menu
end
-- Selected menu
local selected_menu = self.current.items[self.current.selected_index]
if selected_menu and selected_menu.items then
draw_menu(selected_menu, self.bx + menu_gap, selected_menu.top, options.menu_parent_opacity * opacity)
end
return ass
end

View File

@ -100,6 +100,73 @@ function get_point_to_point_proximity(point_a, point_b)
return math.sqrt(dx * dx + dy * dy)
end
---@param lax number
---@param lay number
---@param lbx number
---@param lby number
---@param max number
---@param may number
---@param mbx number
---@param mby number
function get_line_to_line_intersection(lax, lay, lbx, lby, max, may, mbx, mby)
-- Calculate the direction of the lines
local uA = ((mbx-max)*(lay-may) - (mby-may)*(lax-max)) / ((mby-may)*(lbx-lax) - (mbx-max)*(lby-lay))
local uB = ((lbx-lax)*(lay-may) - (lby-lay)*(lax-max)) / ((mby-may)*(lbx-lax) - (mbx-max)*(lby-lay))
-- If uA and uB are between 0-1, lines are colliding
if uA >= 0 and uA <= 1 and uB >= 0 and uB <= 1 then
return lax + (uA * (lbx-lax)), lay + (uA * (lby-lay))
end
return nil, nil
end
-- Returns distance from the start of a finite ray assumed to be at (rax, ray)
-- coordinates to a line.
---@param rax number
---@param ray number
---@param rbx number
---@param rby number
---@param lax number
---@param lay number
---@param lbx number
---@param lby number
function get_ray_to_line_distance(rax, ray, rbx, rby, lax, lay, lbx, lby)
local x, y = get_line_to_line_intersection(rax, ray, rbx, rby, lax, lay, lbx, lby)
if x then
return math.sqrt((rax - x) ^ 2 + (ray - y) ^ 2)
end
return nil
end
-- Returns distance from the start of a finite ray assumed to be at (ax, ay)
-- coordinates to a rectangle. Returns `0` if ray originates inside rectangle.
---@param ax number
---@param ay number
---@param bx number
---@param by number
---@param rect {ax: number; ay: number; bx: number; by: number}
---@return number|nil
function get_ray_to_rectangle_distance(ax, ay, bx, by, rect)
-- Is inside
if ax >= rect.ax and ax <= rect.bx and ay >= rect.ay and ay <= rect.by then
return 0
end
local closest = nil
function updateDistance(distance)
if distance and (not closest or distance < closest) then closest = distance end
end
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.bx, rect.ay))
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.bx, rect.ay, rect.bx, rect.by))
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.by, rect.bx, rect.by))
updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.ax, rect.by))
return closest
end
-- Call function with args if it exists
function call_maybe(fn, ...)
if type(fn) == 'function' then fn(...) end