From 10768d212da974e950b9eddbe3696caea3bb75b2 Mon Sep 17 00:00:00 2001 From: tomasklaen Date: Wed, 17 May 2023 21:06:17 +0200 Subject: [PATCH] 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 --- .vscode/settings.json | 2 + scripts/uosc.lua | 25 +++- scripts/uosc_shared/elements/Menu.lua | 165 +++++++++++++++----------- scripts/uosc_shared/lib/utils.lua | 67 +++++++++++ 4 files changed, 187 insertions(+), 72 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f9deb4b..69aae7c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,10 +21,12 @@ "bestaudio", "bestvideo", "Bopomofo", + "curr", "demux", "doubleclick", "gfps", "hidpi", + "hitbox", "jfif", "logicaldisk", "outro", diff --git a/scripts/uosc.lua b/scripts/uosc.lua index bde897f..39f20ec 100644 --- a/scripts/uosc.lua +++ b/scripts/uosc.lua @@ -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') diff --git a/scripts/uosc_shared/elements/Menu.lua b/scripts/uosc_shared/elements/Menu.lua index dcf8e34..733b75a 100644 --- a/scripts/uosc_shared/elements/Menu.lua +++ b/scripts/uosc_shared/elements/Menu.lua @@ -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 diff --git a/scripts/uosc_shared/lib/utils.lua b/scripts/uosc_shared/lib/utils.lua index aaa3d8a..4eecddc 100644 --- a/scripts/uosc_shared/lib/utils.lua +++ b/scripts/uosc_shared/lib/utils.lua @@ -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