From 66f671c145693cb9e043d469b45cccc43fead3a6 Mon Sep 17 00:00:00 2001 From: NaakkaDev Date: Mon, 29 Sep 2025 20:14:14 +0300 Subject: [PATCH] Added navigation handler for gamepad/keyboard navigation. --- druid/base/navigation_handler.lua | 391 ++++++++++++++++++++++++++++++ druid/const.lua | 3 + druid/ext.properties | 6 + druid/system/druid_instance.lua | 44 +--- 4 files changed, 408 insertions(+), 36 deletions(-) create mode 100644 druid/base/navigation_handler.lua diff --git a/druid/base/navigation_handler.lua b/druid/base/navigation_handler.lua new file mode 100644 index 0000000..ec8f832 --- /dev/null +++ b/druid/base/navigation_handler.lua @@ -0,0 +1,391 @@ +local event = require("event.event") +local const = require("druid.const") +local component = require("druid.component") + + +---Navigation handler style params. +---You can override this component styles params in Druid styles table or create your own style +---@class druid.navigation_handler.style +---@field on_select fun(self, node, hover_state)|nil Currently only used for when a slider component is selected. For buttons use its own on_hover style. + + +---Component to handle GUI navigation via keyboard/gamepad. +--- +---### Setup +---Create navigation handler component with druid: `druid:new_navigation_handler(button)` +--- +---### Notes +---- Key triggers in `input.binding` should match your setup +---- Used `action_id`'s are:' key_up, key_down, key_left and key_right +---@class druid.navigation_handler: druid.component +---@field COMPONENTS table Table of component names navigation handler can handle. +---@field on_select event fun(self, button_instance, button_instance) Triggers when a new button is selected. The first button_instance is for the newly selected and the second for the previous button. +---@field private _weight number The value used to control of the next button diagonal finding logic strictness. +---@field private _tolerance number Determines how lenient the next button finding logic is. Set larger value for further diagonal navigation. +---@field private _select_trigger hash Select trigger for the current component. Defaults to `druid.const.ACTION_SPACE`. +---@field private _selected_triggers table Table of action_ids that can trigger the selected component. Valid only for the current button when set. +---@field private _selected_component druid.component|druid.button|druid.slider Currently selected button instance. +---@field private _deselect_directions table The valid "escape" direction of the current selection. +local M = component.create("navigation_handler") + + +M.COMPONENTS = { "button", "slider" } + + +---@private +---@param style druid.navigation_handler.style +function M:on_style_change(style) + self.style = { + on_select = style.on_select or function(_, node, state) end, + } +end + +---The constructor for the navigation_handler component. +---@param component druid.component Current druid component that starts as selected. +---@param tolerance number|nil How far to allow misalignment on the perpendicular axis when finding the next component. +function M:init(component, tolerance) + -- Set default tolerance if not given. + if tolerance == nil then + tolerance = 200 + end + + self._weight = 10 + self._tolerance = tolerance + self._select_trigger = const.ACTION_SPACE + self._selected_triggers = {} + self._selected_component = component + self._deselect_directions = {} + + -- Select the component if it's a button. + if component.hover then + component.hover:set_hover(true) + end + + -- Events + self.on_select = event.create() + + -- Set style for the initial component. + self.style.on_select(self, component.node, true) +end + +---@private +---@param action_id hash Action id from on_input. +---@param action table Action from on_input. +---@return boolean is_consumed True if the input was consumed. +function M:on_input(action_id, action) + -- Trigger an action with the selected component, e.g. button click. + if self:_action_id_is_trigger(action_id) and self:_selected_is_button() then + ---@type druid.button + local btn = self._selected_component + local is_consume = false + + if action.pressed then + btn.is_repeated_started = false + btn.last_pressed_time = socket.gettime() + btn.on_pressed:trigger(self:get_context(), btn, self) + btn.can_action = true + return is_consume + end + + -- While hold button, repeat rate pick from input.repeat_interval + if action.repeated then + if not btn.on_repeated_click:is_empty() and btn.can_action then + btn:_on_button_repeated_click() + return is_consume + end + end + + if action.released then + return btn:_on_button_release() and is_consume + end + + return not btn.disabled and is_consume + end + + if action.pressed then + ---@type druid.component|nil + local component = nil + + if action_id == const.ACTION_UP then + component = self:_find_next_button("up") + elseif action_id == const.ACTION_DOWN then + component = self:_find_next_button("down") + elseif action_id == const.ACTION_LEFT then + component = self:_find_next_button("left") + elseif action_id == const.ACTION_RIGHT then + component = self:_find_next_button("right") + end + + if component ~= nil and component ~= self._selected_component then + return self:_on_new_select(component) + end + end + + -- Handle chaning slider values when pressing left or right keys. + if (action.pressed or action.repeated) + and self:_selected_is_slider() + and (action_id == const.ACTION_LEFT or action_id == const.ACTION_RIGHT) + then + ---@type druid.slider + local slider = self._selected_component + local value = slider.value + local new_value = 0.01 + + -- Speedup when holding the button. + if action.repeated and not action.pressed then + new_value = 0.05 + end + + if action_id == const.ACTION_LEFT then + -- Decrease value. + value = value - new_value + elseif action_id == const.ACTION_RIGHT then + -- Increase value. + value = value + new_value + end + + slider:set(value) + end + + return false +end + +---Sets a new weight value which affects the next button diagonal finding logic. +---@param new_value number +---@return druid.navigation_handler +function M:set_weight(new_value) + self._weight = new_value + return self +end + +---Sets a new tolerance value. Can be useful when scale or window size changes. +---@param new_value number How far to allow misalignment on the perpendicular axis when finding the next button. +---@return druid.navigation_handler self The current navigation handler instance. +function M:set_tolerance(new_value) + self._tolerance = new_value + return self +end + +---Set input action_id name to trigger selected component by keyboard/gamepad. +---@param key hash|string The action_id of the input key. Example: "key_space". +---@return druid.navigation_handler self The current navigation handler instance. +function M:set_select_trigger(key) + if type(key) == "string" then + self._select_trigger = hash(key) + else + self._select_trigger = key + end + + return self +end + +---Get current the trigger key for currently selected component. +---@return hash _select_trigger The action_id of the input key. +function M:get_select_trigger() + return self._select_trigger +end + +---Set the trigger keys for the selected component. Stays valid until the selected component changes. +---@param keys table|string|hash Supports multiple action_ids if the given value is a table with the action_id hashes or strings. +---@return druid.navigation_handler self The current navigation handler instance. +function M:set_temporary_select_triggers(keys) + if type(keys) == "table" then + for index, value in ipairs(keys) do + if type(value) == "string" then + keys[index] = hash(value) + end + end + self._selected_triggers = keys + elseif type(keys) == "string" then + self._selected_triggers = { hash(keys) } + else + self._selected_triggers = { keys } + end + + return self +end + +---Get the currently selected component. +---@return druid.component _selected_component Selected component, which often is a `druid.button`. +function M:get_selected_component() + return self._selected_component +end + +---Set the de-select direction for the selected button. If this is set +---then the next button can only be in that direction. +---@param dir string|table Valid directions: "up", "down", "left", "right". Can take multiple values as a table of strings. +---@return druid.navigation_handler self The current navigation handler instance. +function M:set_deselect_directions(dir) + if type(dir) == "table" then + self._deselect_directions = dir + elseif type(dir) == "string" then + self._deselect_directions = { dir } + end + + return self +end + +---Returns true if the currently selected `druid.component` is a `druid.button`. +---@private +---@return boolean +function M:_selected_is_button() + return self._selected_component._component.name == "button" +end + +---Returns true if the currently selected `druid.component` is a `druid.slider`. +---@private +---@return boolean +function M:_selected_is_slider() + return self._selected_component._component.name == "slider" +end + +---Find the best next button based on the direction from the currently selected button. +---@private +---@param dir string Valid directions: "top", "bottom", "left", "right". +---@return druid.component|nil +function M:_find_next_button(dir) + ---Helper method for checking if the given direction is valid. + ---@param dirs table + ---@param dir string + ---@return boolean + local function valid_direction(dirs, dir) + for _index, value in ipairs(dirs) do + if value == dir then + return true + end + end + return false + end + + ---Helper method for checking iterating through components. + ---Returns true if the given component is in the table of valid components. + ---@param input_component druid.component + ---@return boolean + local function valid_component(input_component) + local component_name = input_component._component.name + for _index, component in ipairs(M.COMPONENTS) do + if component_name == component then + return true + end + end + return false + end + + -- Check if the deselect direction is set and + -- the direction is different from it. + if next(self._deselect_directions) ~= nil and not valid_direction(self._deselect_directions, dir) then + return nil + end + + local best_component, best_score = nil, math.huge + local screen_pos = gui.get_screen_position(self._selected_component.node) + + -- Use the slider parent node instead of the pin node. + if self._selected_component._component.name == "slider" then + screen_pos = gui.get_screen_position(gui.get_parent(self._selected_component.node)) + end + + ---@type druid.component + for _, input_component in ipairs(self._meta.druid.components_interest[const.ON_INPUT]) do + -- GUI node of the component being iterated. + local node = input_component.node + + -- If it is a slider component then use its parent node instead, + -- since the pin node moves around. + if input_component._component.name == "slider" then + node = gui.get_parent(node) + end + + -- Only check components that are supported. + if input_component ~= self._selected_component and valid_component(input_component) then + local pos = gui.get_screen_position(node) + local dx, dy = pos.x - screen_pos.x, pos.y - screen_pos.y + local valid = false + local score = math.huge + + if dir == "right" and dx > 0 and math.abs(dy) <= self._tolerance then + valid = true + score = dx * dx + dy * dy * self._weight + elseif dir == "left" and dx < 0 and math.abs(dy) <= self._tolerance then + valid = true + score = dx * dx + dy * dy * self._weight + elseif dir == "up" and dy > 0 and math.abs(dx) <= self._tolerance then + valid = true + score = dy * dy + dx * dx * self._weight + elseif dir == "down" and dy < 0 and math.abs(dx) <= self._tolerance then + valid = true + score = dy * dy + dx * dx * self._weight + end + + if valid and score < best_score then + best_score = score + best_component = input_component + end + end + end + + return best_component +end + +---De-select the current selected component. +---@private +function M:_deselect_current() + if self._selected_component.hover then + self._selected_component.hover:set_hover(false) + end + self._selected_component = nil + self._selected_triggers = {} + + -- The deselect direction was used so remove it. + if self._deselect_directions then + self._deselect_directions = {} + end +end + +---Check if the supplied action_id can trigger the selected component. +---@private +---@param action_id hash +---@return boolean +function M:_action_id_is_trigger(action_id) + for _, key in ipairs(self._selected_triggers) do + if action_id == key then + return true + end + end + + return action_id == self._select_trigger +end + +---Handle new selection. +---@private +---@param new druid.component Instance of the selected component. +---@return boolean +function M:_on_new_select(new) + ---@type druid.component + local current = self._selected_component + + self.style.on_select(self, current.node, false) + self.style.on_select(self, new.node, true) + + -- De-select the current component. + self:_deselect_current() + self._selected_component = new + + --- BUTTON + if new._component.name == "button" then + -- Set the active button hover state. + new.hover:set_hover(true) + end + + --- SLIDER + if new._component.name == "slider" then + self:set_deselect_directions({ "up", "down" }) + end + + --- EVENT + self.on_select:trigger(new, current) + + return false +end + +return M diff --git a/druid/const.lua b/druid/const.lua index 0363871..a59d5a0 100755 --- a/druid/const.lua +++ b/druid/const.lua @@ -7,12 +7,15 @@ M.ACTION_MARKED_TEXT = hash(sys.get_config_string("druid.input_marked_text", "ma M.ACTION_ESC = hash(sys.get_config_string("druid.input_key_esc", "key_esc")) M.ACTION_BACK = hash(sys.get_config_string("druid.input_key_back", "key_back")) M.ACTION_ENTER = hash(sys.get_config_string("druid.input_key_enter", "key_enter")) +M.ACTION_SPACE = hash(sys.get_config_string("druid.input_key_space", "key_space")) M.ACTION_MULTITOUCH = hash(sys.get_config_string("druid.input_multitouch", "touch_multi")) M.ACTION_BACKSPACE = hash(sys.get_config_string("druid.input_key_backspace", "key_backspace")) M.ACTION_SCROLL_UP = hash(sys.get_config_string("druid.input_scroll_up", "mouse_wheel_up")) M.ACTION_SCROLL_DOWN = hash(sys.get_config_string("druid.input_scroll_down", "mouse_wheel_down")) M.ACTION_LEFT = hash(sys.get_config_string("druid.input_key_left", "key_left")) M.ACTION_RIGHT = hash(sys.get_config_string("druid.input_key_right", "key_right")) +M.ACTION_UP = hash(sys.get_config_string("druid.input_key_up", "key_up")) +M.ACTION_DOWN = hash(sys.get_config_string("druid.input_key_down", "key_down")) M.ACTION_LSHIFT = hash(sys.get_config_string("druid.input_key_lshift", "key_lshift")) M.ACTION_LCTRL = hash(sys.get_config_string("druid.input_key_lctrl", "key_lctrl")) M.ACTION_LCMD = hash(sys.get_config_string("druid.input_key_lsuper", "key_lsuper")) diff --git a/druid/ext.properties b/druid/ext.properties index 09f44dd..ec51e70 100644 --- a/druid/ext.properties +++ b/druid/ext.properties @@ -14,6 +14,8 @@ input_key_back.default = key_back input_key_enter.default = key_enter +input_key_space.default = key_space + input_key_backspace.default = key_backspace input_multitouch.default = touch_multi @@ -26,6 +28,10 @@ input_key_left.default = key_left input_key_right.default = key_right +input_key_up.default = key_up + +input_key_down.default = key_down + input_key_lshift.default = key_lshift input_key_lctrl.default = key_lctrl diff --git a/druid/system/druid_instance.lua b/druid/system/druid_instance.lua index 08c8f33..6c33ccd 100755 --- a/druid/system/druid_instance.lua +++ b/druid/system/druid_instance.lua @@ -163,7 +163,6 @@ function M:_can_use_input_component(component) return can_by_blacklist and can_by_whitelist end - local function schedule_late_init(self) if self._late_init_timer_id then return @@ -203,7 +202,6 @@ function M.create_druid_instance(context, style) return self end - ---Create new Druid component instance ---@generic T: druid.component ---@param component T The component class to create @@ -223,7 +221,6 @@ function M:new(component, ...) return instance end - ---Call this in gui_script final function. function M:final() local components = self.components_all @@ -240,7 +237,6 @@ function M:final() events.unsubscribe("druid.language_change", self.on_language_change, self) end - ---Remove created component from Druid instance. --- ---Component `on_remove` function will be invoked, if exist. @@ -292,7 +288,6 @@ function M:remove(component) return is_removed end - ---Get a context of Druid instance (usually a self of gui script) ---@package ---@return any context The Druid context @@ -300,7 +295,6 @@ function M:get_context() return self._context end - ---Get a style of Druid instance ---@package ---@return table style The Druid style table @@ -308,7 +302,6 @@ function M:get_style() return self._style end - ---Druid late update function called after initialization and before the regular update step. ---This function is used to check the GUI state and perform actions after all components and nodes have been created. ---An example use case is performing an auto stencil check in the GUI hierarchy for input components. @@ -326,7 +319,6 @@ function M:late_init() end end - ---Call this in gui_script update function. ---@param dt number Delta time function M:update(dt) @@ -341,7 +333,6 @@ function M:update(dt) self:_clear_late_remove() end - ---Call this in gui_script on_input function. ---@param action_id hash Action_id from on_input ---@param action table Action from on_input @@ -375,7 +366,6 @@ function M:on_input(action_id, action) return is_input_consumed end - ---Call this in gui_script on_message function. ---@param message_id hash Message_id from on_message ---@param message table Message from on_message @@ -396,7 +386,6 @@ function M:on_message(message_id, message, sender) end end - ---Called when the window event occurs ---@param window_event number The window event function M:on_window_event(window_event) @@ -418,7 +407,6 @@ function M:on_window_event(window_event) end end - ---Calls the on_language_change function in all related components ---This one called by global druid.on_language_change, but can be called manually to update all translations ---@private @@ -429,7 +417,6 @@ function M:on_language_change() end end - ---Set whitelist components for input processing. ---If whitelist is not empty and component not contains in this list, ---component will be not processed on the input step @@ -449,7 +436,6 @@ function M:set_whitelist(whitelist_components) return self end - ---Set blacklist components for input processing. ---If blacklist is not empty and component is contained in this list, ---component will be not processed on the input step DruidInstance @@ -469,7 +455,6 @@ function M:set_blacklist(blacklist_components) return self end - ---Remove all components on late remove step DruidInstance ---@private function M:_clear_late_remove() @@ -483,7 +468,6 @@ function M:_clear_late_remove() self._late_remove = {} end - ---Create new Druid widget instance ---@generic T: druid.component ---@param widget T The widget class to create @@ -506,7 +490,6 @@ function M:new_widget(widget, template, nodes, ...) return instance end - local button = require("druid.base.button") ---Create Button component ---@param node string|node The node_id or gui.get_node(node_id) @@ -518,7 +501,6 @@ function M:new_button(node, callback, params, anim_node) return self:new(button, node, callback, params, anim_node) end - local blocker = require("druid.base.blocker") ---Create Blocker component ---@param node string|node The node_id or gui.get_node(node_id) @@ -527,7 +509,6 @@ function M:new_blocker(node) return self:new(blocker, node) end - local back_handler = require("druid.base.back_handler") ---Create BackHandler component ---@param callback function|event|nil The callback(self, custom_args) to call on back event @@ -537,7 +518,6 @@ function M:new_back_handler(callback, params) return self:new(back_handler, callback, params) end - local hover = require("druid.base.hover") ---Create Hover component ---@param node string|node The node_id or gui.get_node(node_id) @@ -548,6 +528,14 @@ function M:new_hover(node, on_hover_callback, on_mouse_hover_callback) return self:new(hover, node, on_hover_callback, on_mouse_hover_callback) end +local navigation_handler = require("druid.base.navigation_handler") +---Create NavigationHandler component +---@param button druid.button The button that should be selected on start. +---@param tolerance number|nil How far to allow misalignment on the perpendicular axis when finding the next button. +---@return druid.navigation_handler navigation_handler The new navigation handler component. +function M:new_navigation_handler(button, tolerance) + return self:new(navigation_handler, button, tolerance) +end local text = require("druid.base.text") ---Create Text component @@ -559,7 +547,6 @@ function M:new_text(node, value, adjust_type) return self:new(text, node, value, adjust_type) end - local static_grid = require("druid.base.static_grid") ---Create Grid component ---@param parent_node string|node The node_id or gui.get_node(node_id). Parent of all Grid items. @@ -570,7 +557,6 @@ function M:new_grid(parent_node, item, in_row) return self:new(static_grid, parent_node, item, in_row) end - local scroll = require("druid.base.scroll") ---Create Scroll component ---@param view_node string|node The node_id or gui.get_node(node_id). Will be used as user input node. @@ -580,7 +566,6 @@ function M:new_scroll(view_node, content_node) return self:new(scroll, view_node, content_node) end - local drag = require("druid.base.drag") ---Create Drag component ---@param node string|node The node_id or gui.get_node(node_id). Will be used as user input node. @@ -590,7 +575,6 @@ function M:new_drag(node, on_drag_callback) return self:new(drag, node, on_drag_callback) end - local swipe = require("druid.extended.swipe") ---Create Swipe component ---@param node string|node The node_id or gui.get_node(node_id). Will be used as user input node. @@ -600,7 +584,6 @@ function M:new_swipe(node, on_swipe_callback) return self:new(swipe, node, on_swipe_callback) end - local lang_text = require("druid.extended.lang_text") ---Create LangText component ---@param node string|node The node_id or gui.get_node(node_id) @@ -611,7 +594,6 @@ function M:new_lang_text(node, locale_id, adjust_type) return self:new(lang_text, node, locale_id, adjust_type) end - local slider = require("druid.extended.slider") ---Create Slider component ---@param pin_node string|node The node_id or gui.get_node(node_id). @@ -622,7 +604,6 @@ function M:new_slider(pin_node, end_pos, callback) return self:new(slider, pin_node, end_pos, callback) end - local input = require("druid.extended.input") ---Create Input component ---@param click_node string|node Button node to enable input component @@ -633,7 +614,6 @@ function M:new_input(click_node, text_node, keyboard_type) return self:new(input, click_node, text_node, keyboard_type) end - local data_list = require("druid.extended.data_list") ---Create DataList component ---@param druid_scroll druid.scroll The Scroll instance for Data List component @@ -644,7 +624,6 @@ function M:new_data_list(druid_scroll, druid_grid, create_function) return self:new(data_list, druid_scroll, druid_grid, create_function) end - local timer_component = require("druid.extended.timer") ---Create Timer component ---@param node string|node Gui text node @@ -656,7 +635,6 @@ function M:new_timer(node, seconds_from, seconds_to, callback) return self:new(timer_component, node, seconds_from, seconds_to, callback) end - local progress = require("druid.extended.progress") ---Create Progress component ---@param node string|node Progress bar fill node or node name @@ -667,7 +645,6 @@ function M:new_progress(node, key, init_value) return self:new(progress, node, key, init_value) end - local layout = require("druid.extended.layout") ---Create Layout component ---@param node string|node The node_id or gui.get_node(node_id). @@ -677,7 +654,6 @@ function M:new_layout(node, mode) return self:new(layout, node, mode) end - local container = require("druid.extended.container") ---Create Container component ---@param node string|node The node_id or gui.get_node(node_id). @@ -688,7 +664,6 @@ function M:new_container(node, mode, callback) return self:new(container, node, mode, callback) end - local hotkey = require("druid.extended.hotkey") ---Create Hotkey component ---@param keys_array string|string[] Keys for trigger action. Should contains one action key and any amount of modificator keys @@ -699,7 +674,6 @@ function M:new_hotkey(keys_array, callback, callback_argument) return self:new(hotkey, keys_array, callback, callback_argument) end - local rich_text = require("druid.custom.rich_text.rich_text") ---Create RichText component. ---@param text_node string|node The text node to make Rich Text @@ -709,7 +683,6 @@ function M:new_rich_text(text_node, value) return self:new(rich_text, text_node, value) end - local rich_input = require("druid.custom.rich_input.rich_input") ---Create RichInput component. ---As a template please check rich_input.gui layout. @@ -720,5 +693,4 @@ function M:new_rich_input(template, nodes) return self:new(rich_input, template, nodes) end - return M