Added navigation handler for gamepad/keyboard navigation.

This commit is contained in:
NaakkaDev
2025-09-29 20:14:14 +03:00
parent 044eec50b2
commit 66f671c145
4 changed files with 408 additions and 36 deletions

View File

@@ -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<string> 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<string> 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<string>
---@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

View File

@@ -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"))

View File

@@ -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

View File

@@ -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