fix: harden controller navigation and text entry

This commit is contained in:
Alexander Sellite 2026-08-16 16:40:10 -04:00
parent c4e49cdc52
commit 8ea877ecf4
28 changed files with 1320 additions and 96 deletions

View file

@ -15,6 +15,7 @@ var _depth_scale: float = 1.0
var _batch_selected: bool = false
var _focused_catch: bool = false
var _hovered: bool = false
var _controller_focus_presentation_active: bool = false
var _quality_color := Color.WHITE
@ -96,6 +97,11 @@ func set_item_state(
_refresh_style()
func set_controller_focus_presentation_active(active: bool) -> void:
_controller_focus_presentation_active = active
_refresh_style()
func advance_presentation(delta: float, elapsed: float) -> void:
var response: float = 1.0 - exp(-9.0 * delta)
_neutral_position = _neutral_position.lerp(_target_position, response)
@ -107,7 +113,7 @@ func advance_presentation(delta: float, elapsed: float) -> void:
interaction_lift += 3.0
if _focused_catch:
interaction_lift += 1.5
elif _hovered:
elif _hovered and not _controller_focus_presentation_active:
interaction_lift += 0.8
_visual_root.position = Vector2(0.0, bob - interaction_lift)
_visual_root.rotation = tilt
@ -116,7 +122,7 @@ func advance_presentation(delta: float, elapsed: float) -> void:
emphasis += 0.085
if _focused_catch:
emphasis += 0.035
elif _hovered:
elif _hovered and not _controller_focus_presentation_active:
emphasis += 0.045
_visual_root.scale = Vector2.ONE * _depth_scale * emphasis
@ -150,7 +156,7 @@ func _refresh_style() -> void:
)
var circle_visible: bool = (
_batch_selected
or _hovered
or (_hovered and not _controller_focus_presentation_active)
or _focused_catch
)
var base_style: StyleBox = idle

View file

@ -24,6 +24,20 @@ const FOCUS_COLOR_REPLACEMENTS: Dictionary[StringName, StringName] = {
&"font_selected_color": &"font_color",
&"font_hovered_selected_color": &"font_hovered_color",
}
const HOVER_STYLE_REPLACEMENTS: Dictionary[StringName, StringName] = {
&"hover": &"normal",
&"hover_pressed": &"pressed",
&"hovered": &"",
&"hovered_selected": &"",
}
const HOVER_COLOR_REPLACEMENTS: Dictionary[StringName, StringName] = {
&"font_hover_color": &"font_color",
&"icon_hover_color": &"icon_normal_color",
&"font_hover_pressed_color": &"font_pressed_color",
&"icon_hover_pressed_color": &"icon_pressed_color",
&"font_hovered_color": &"font_color",
&"font_hovered_selected_color": &"font_color",
}
var _controller_active: bool = false
var _focused_control: Control
@ -32,6 +46,9 @@ var _popup_scroll_offset: float = 0.0
var _suppressed_control: Control
var _original_style_overrides: Dictionary[StringName, Dictionary] = {}
var _original_color_overrides: Dictionary[StringName, Dictionary] = {}
var _suppressed_hover_control: Control
var _original_hover_style_overrides: Dictionary[StringName, Dictionary] = {}
var _original_hover_color_overrides: Dictionary[StringName, Dictionary] = {}
var _original_popup_hover_style: Dictionary = {}
var _original_popup_hover_color: Dictionary = {}
var _focus_layer: CanvasLayer
@ -47,6 +64,7 @@ func _ready() -> void:
func _exit_tree() -> void:
_clear_focus_presentation()
_restore_native_hover_highlight()
func _input(event: InputEvent) -> void:
@ -69,6 +87,7 @@ func _input(event: InputEvent) -> void:
func _process(_delta: float) -> void:
_update_hover_suppression()
if _controller_active:
var focused_popup: PopupMenu = _active_focused_popup(get_viewport())
if focused_popup != null:
@ -111,6 +130,7 @@ func _set_controller_active(active: bool) -> void:
if _controller_active == active:
return
_controller_active = active
_update_hover_suppression()
if _controller_active:
var focused_popup: PopupMenu = _active_focused_popup(get_viewport())
if focused_popup != null:
@ -440,6 +460,113 @@ func _suppress_native_focus_highlight(control: Control) -> void:
)
func _update_hover_suppression() -> void:
if not _controller_active:
_restore_native_hover_highlight()
return
var hovered: Control = _hover_presentation_control(
_hovered_control_in_viewport(get_viewport())
)
if hovered == _suppressed_hover_control:
return
_restore_native_hover_highlight()
if hovered != null:
_suppress_native_hover_highlight(hovered)
func _hovered_control_in_viewport(viewport: Viewport) -> Control:
var embedded_windows: Array[Window] = viewport.get_embedded_subwindows()
for index: int in range(embedded_windows.size() - 1, -1, -1):
var window: Window = embedded_windows[index]
if not window.visible:
continue
var nested_hovered: Control = _hovered_control_in_viewport(window)
if nested_hovered != null:
return nested_hovered
return viewport.gui_get_hovered_control()
func _hover_presentation_control(control: Control) -> Control:
var candidate: Control = control
while candidate != null:
if (
candidate is BaseButton
or candidate is ItemList
or candidate is Tree
or candidate.focus_mode != Control.FOCUS_NONE
):
return candidate
candidate = candidate.get_parent() as Control
return null
func _suppress_native_hover_highlight(control: Control) -> void:
_suppressed_hover_control = control
_original_hover_style_overrides.clear()
_original_hover_color_overrides.clear()
if control.has_method(&"set_controller_focus_presentation_active"):
control.call(&"set_controller_focus_presentation_active", true)
for hover_name: StringName in HOVER_STYLE_REPLACEMENTS:
var replacement_name: StringName = HOVER_STYLE_REPLACEMENTS[hover_name]
_original_hover_style_overrides[hover_name] = {
"had_override": control.has_theme_stylebox_override(hover_name),
"value": control.get_theme_stylebox(hover_name),
}
var replacement: StyleBox = (
StyleBoxEmpty.new()
if replacement_name.is_empty()
else control.get_theme_stylebox(replacement_name)
)
control.add_theme_stylebox_override(hover_name, replacement)
for hover_name: StringName in HOVER_COLOR_REPLACEMENTS:
var replacement_name: StringName = HOVER_COLOR_REPLACEMENTS[hover_name]
if not control.has_theme_color(replacement_name):
continue
_original_hover_color_overrides[hover_name] = {
"had_override": control.has_theme_color_override(hover_name),
"value": control.get_theme_color(hover_name),
}
control.add_theme_color_override(
hover_name,
control.get_theme_color(replacement_name),
)
func _restore_native_hover_highlight() -> void:
if not is_instance_valid(_suppressed_hover_control):
_suppressed_hover_control = null
_original_hover_style_overrides.clear()
_original_hover_color_overrides.clear()
return
for hover_name: StringName in _original_hover_style_overrides:
var state: Dictionary = _original_hover_style_overrides[hover_name]
if bool(state.get("had_override", false)):
_suppressed_hover_control.add_theme_stylebox_override(
hover_name,
state.get("value") as StyleBox,
)
else:
_suppressed_hover_control.remove_theme_stylebox_override(hover_name)
for hover_name: StringName in _original_hover_color_overrides:
var state: Dictionary = _original_hover_color_overrides[hover_name]
if bool(state.get("had_override", false)):
_suppressed_hover_control.add_theme_color_override(
hover_name,
state.get("value") as Color,
)
else:
_suppressed_hover_control.remove_theme_color_override(hover_name)
if _suppressed_hover_control.has_method(
&"set_controller_focus_presentation_active"
):
_suppressed_hover_control.call(
&"set_controller_focus_presentation_active", false
)
_suppressed_hover_control = null
_original_hover_style_overrides.clear()
_original_hover_color_overrides.clear()
func _suppress_popup_focus_highlight(popup: PopupMenu) -> void:
_original_popup_hover_style = {
"had_override": popup.has_theme_stylebox_override(&"hover"),

View file

@ -67,6 +67,12 @@ func select_only(catch_id: StringName) -> void:
_anchor_id = catch_id
func focus_only(catch_id: StringName) -> void:
if catch_id.is_empty() or not _visible_ids.has(catch_id):
return
_focused_id = catch_id
func clear() -> void:
_selected_ids.clear()
_focused_id = StringName()

View file

@ -93,6 +93,11 @@ enum ShopSection {
SELL_FISH,
}
enum ControllerZone {
TABS,
CONTENT,
}
const SHOP_SECTION_LABELS: Array[String] = [
"Upgrades",
"Bait and Lures",
@ -175,6 +180,7 @@ var _cooler_modal_open: bool = false
var _shop_section: ShopSection = ShopSection.UPGRADES
var _shop_tabs: Array[OrganizerTab] = []
var _controller_mapping_manager: ControllerMappingManagerType
var _controller_zone: ControllerZone = ControllerZone.TABS
func _ready() -> void:
@ -204,6 +210,40 @@ func _input(event: InputEvent) -> void:
var button_event := event as InputEventJoypadButton
if button_event == null:
return
var uses_accept: bool = (
_controller_mapping_manager.event_matches_role(
event,
ControllerMappingManagerType.ROLE_A,
)
if _controller_mapping_manager != null
else button_event.button_index == JOY_BUTTON_A
)
var uses_cancel: bool = (
_controller_mapping_manager.event_matches_role(
event,
ControllerMappingManagerType.ROLE_B,
)
if _controller_mapping_manager != null
else button_event.button_index == JOY_BUTTON_B
)
if uses_cancel:
get_viewport().set_input_as_handled()
if not button_event.pressed:
return
if _controller_zone == ControllerZone.CONTENT:
_enter_shop_tabs_zone()
else:
close_shop()
return
if uses_accept:
get_viewport().set_input_as_handled()
if not button_event.pressed or _transaction_in_progress:
return
if _controller_zone == ControllerZone.TABS:
_enter_shop_content_zone()
else:
_activate_focused_shop_control()
return
var uses_left_bumper: bool = (
_controller_mapping_manager.event_uses_role(
event,
@ -226,12 +266,69 @@ func _input(event: InputEvent) -> void:
if not button_event.pressed or _transaction_in_progress:
return
var direction: int = -1 if uses_left_bumper else 1
var current_section: int = _focused_shop_tab_index()
if current_section < 0:
current_section = int(_shop_section)
var next_section: int = wrapi(
int(_shop_section) + direction,
current_section + direction,
0,
ShopSection.size(),
)
_select_shop_section(next_section, true)
if _controller_zone == ControllerZone.TABS:
_focus_shop_tab(next_section)
else:
_select_shop_section(next_section, true)
func _enter_shop_tabs_zone() -> void:
_controller_zone = ControllerZone.TABS
_apply_shop_controller_zone_focus_modes()
_configure_controller_focus()
_focus_shop_tab(int(_shop_section))
func _enter_shop_content_zone() -> void:
var section_index: int = _focused_shop_tab_index()
if section_index < 0:
section_index = int(_shop_section)
_controller_zone = ControllerZone.CONTENT
_select_shop_section(section_index, true)
func _activate_focused_shop_control() -> bool:
var focused := get_viewport().gui_get_focus_owner() as BaseButton
if (
focused == null
or focused.disabled
or _is_shop_tab(focused)
or not focused.is_visible_in_tree()
):
return false
focused.pressed.emit()
return true
func _focused_shop_tab_index() -> int:
var focused: Control = get_viewport().gui_get_focus_owner()
for tab_index: int in _shop_tabs.size():
if _shop_tabs[tab_index] == focused:
return tab_index
return -1
func _is_shop_tab(control: Control) -> bool:
for tab: OrganizerTab in _shop_tabs:
if tab == control:
return true
return false
func _focus_shop_tab(section_index: int) -> void:
if section_index < 0 or section_index >= _shop_tabs.size():
return
var tab: OrganizerTab = _shop_tabs[section_index]
if tab.focus_mode != Control.FOCUS_NONE and not tab.disabled:
tab.call_deferred("grab_focus")
func _request_shop_cooler() -> bool:
@ -249,10 +346,15 @@ func _focus_shop_section() -> void:
_set_feedback("")
_configure_controller_focus()
if _shop_section == ShopSection.UPGRADES:
_reel_purchase.grab_focus()
return
for upgrade_button: Button in [
_reel_purchase,
_barrier_purchase,
_cooler_purchase,
]:
if not upgrade_button.disabled:
upgrade_button.grab_focus()
return
if _shop_section == ShopSection.SELL_FISH:
_shop_tabs[int(ShopSection.SELL_FISH)].grab_focus()
return
for child: Node in _supplies_list.find_children(
"*", "Button", true, false
@ -261,9 +363,9 @@ func _focus_shop_section() -> void:
if stock_button != null and not stock_button.disabled:
stock_button.grab_focus()
return
var section_index: int = int(_shop_section)
if section_index >= 0 and section_index < _shop_tabs.size():
_shop_tabs[section_index].grab_focus()
var close_button := %CloseButton as Button
if close_button.focus_mode != Control.FOCUS_NONE:
close_button.grab_focus()
func _build_shop_tabs() -> void:
@ -293,6 +395,8 @@ func _build_shop_tabs() -> void:
func _select_shop_section(section_index: int, focus_content: bool) -> void:
if _closing:
return
if focus_content:
_controller_zone = ControllerZone.CONTENT
if section_index == int(_shop_section):
if section_index != ShopSection.SELL_FISH:
var showing_upgrades := section_index == ShopSection.UPGRADES
@ -325,11 +429,36 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void:
func _configure_controller_focus() -> void:
if not visible or _cooler_page_active:
return
_apply_shop_controller_zone_focus_modes()
var candidates: Array[Control] = []
_collect_controller_focusables(self, candidates)
ControllerFocusNavigationType.configure_spatial_neighbors(candidates)
func _apply_shop_controller_zone_focus_modes() -> void:
var tabs_active: bool = _controller_zone == ControllerZone.TABS
for tab: OrganizerTab in _shop_tabs:
tab.focus_mode = (
Control.FOCUS_ALL if tabs_active else Control.FOCUS_NONE
)
var content_focus_mode := (
Control.FOCUS_NONE if tabs_active else Control.FOCUS_ALL
)
for content_root: Control in [_upgrades_content, _supplies_content]:
_set_descendant_button_focus_mode(content_root, content_focus_mode)
(%CloseButton as Button).focus_mode = content_focus_mode
func _set_descendant_button_focus_mode(
root_control: Control,
focus_mode: Control.FocusMode,
) -> void:
for child: Node in root_control.find_children("*", "BaseButton", true, false):
var button := child as BaseButton
if button != null:
button.focus_mode = focus_mode
func _collect_controller_focusables(
root: Node,
output: Array[Control],
@ -488,10 +617,11 @@ func open_shop() -> bool:
deactivate_shop_cooler_page()
show()
_shop_tab_bar.show()
_controller_zone = ControllerZone.TABS
_select_shop_section(ShopSection.UPGRADES, false)
_refresh_all()
call_deferred("_configure_controller_focus")
_reel_purchase.grab_focus()
_focus_shop_tab(int(ShopSection.UPGRADES))
menu_visibility_changed.emit(true)
return true
@ -751,6 +881,7 @@ func _refresh_supplies() -> void:
_supplies_list.remove_child(child)
child.queue_free()
if _shop_section in [ShopSection.UPGRADES, ShopSection.SELL_FISH]:
call_deferred("_configure_controller_focus")
return
_stock_title.text = SHOP_SECTION_LABELS[int(_shop_section)]
var stock_item_ids: Array[StringName] = (
@ -934,6 +1065,7 @@ func _refresh_supplies() -> void:
var canvas_grid := _add_stock_icon_grid()
for product_id: StringName in ArtShopStockType.GRID_PRODUCTS:
_add_art_upgrade_button(product_id, canvas_grid)
call_deferred("_configure_controller_focus")
func _item_belongs_in_current_section(item: ItemDataType) -> bool:

View file

@ -456,6 +456,10 @@ func setup(
func _input(event: InputEvent) -> void:
# The on-screen keyboard owns controller input while it is open. Its
# overlay is processed before the UI beneath it and consumes the event.
if is_controller_text_entry_open():
return
if _handle_virtual_mouse_input(event):
get_viewport().set_input_as_handled()
return
@ -476,6 +480,8 @@ func _input(event: InputEvent) -> void:
):
return
if event.is_action_pressed("character_call") and _can_use_character_call():
if _character_call_yields_to_world_interaction(event):
return
_network_chat_service.send_local_character_call(
_network_profile.call_id
)
@ -554,6 +560,19 @@ func _can_use_character_call() -> bool:
)
func _character_call_yields_to_world_interaction(
event: InputEvent,
) -> bool:
return (
event.is_action_pressed("interact")
and _shop_interaction != null
and _shop_interaction.is_local_player_in_range()
and _fishing_spot != null
and _fishing_spot.can_open_fishing_shop()
and not _fishing_shop.visible
)
func _on_character_call_received(
peer_id: int,
call_id: String,

View file

@ -358,6 +358,7 @@ func _build_interface() -> void:
_catalog_scroll.horizontal_scroll_mode = (
ScrollContainer.SCROLL_MODE_DISABLED
)
_catalog_scroll.follow_focus = true
_catalog_scroll.scroll_vertical_custom_step = CATALOG_ROW_STEP
catalog_scroll_margin.add_child(_catalog_scroll)
_catalog_grid = GridContainer.new()

View file

@ -4,6 +4,9 @@ extends Control
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const CHECK_ICON: Texture2D = preload(
"res://ui/icons/pictograms/check_mark_dark.png"
)
@ -12,6 +15,8 @@ const CHARACTER_FONT_SIZE: int = 34
const CANONICAL_WINDOW_SIZE: Vector2 = Vector2(1280.0, 720.0)
const TRIGGER_PRESS_THRESHOLD: float = 0.55
const TRIGGER_RELEASE_THRESHOLD: float = 0.25
const CARET_BLINK_INTERVAL: float = 0.5
const CARET_GLYPH: String = ""
signal text_submitted(value: String)
@ -22,10 +27,14 @@ enum Page {
}
var _enabled: bool = false
var _controller_mapping_manager: ControllerMappingManagerType
var _page: Page = Page.LOWER
var _target: Control
var _target_virtual_keyboard_enabled: bool = true
var _buffer: String = ""
var _buffer_caret: int = 0
var _caret_blink_elapsed: float = 0.0
var _caret_visible: bool = true
var _preview: Label
var _page_buttons: Array[Button] = []
var _keys_host: VBoxContainer
@ -52,6 +61,21 @@ func _ready() -> void:
_build_interface()
hide()
set_process_input(true)
set_process(true)
func _process(delta: float) -> void:
if not visible:
return
_caret_blink_elapsed += delta
if _caret_blink_elapsed < CARET_BLINK_INTERVAL:
return
_caret_blink_elapsed = fmod(
_caret_blink_elapsed,
CARET_BLINK_INTERVAL,
)
_caret_visible = not _caret_visible
_refresh_preview()
func set_enabled(enabled: bool) -> void:
@ -68,6 +92,12 @@ func is_open() -> bool:
return visible
func setup_controller_mapping(
mapping_manager: ControllerMappingManagerType,
) -> void:
_controller_mapping_manager = mapping_manager
func request_for_focused_control() -> bool:
return request_for_control(get_viewport().gui_get_focus_owner())
@ -88,23 +118,51 @@ func _input(event: InputEvent) -> void:
return
if visible:
var joy_motion := event as InputEventJoypadMotion
if joy_motion != null and _handle_trigger_shortcut(joy_motion):
if joy_motion != null:
if not _handle_trigger_shortcut(joy_motion):
_move_key_focus(_controller_direction(event))
get_viewport().set_input_as_handled()
return
var joy_button := event as InputEventJoypadButton
if joy_button != null and joy_button.pressed:
if joy_button.button_index == JOY_BUTTON_X:
_backspace()
get_viewport().set_input_as_handled()
return
if joy_button.button_index == JOY_BUTTON_LEFT_SHOULDER:
_close_keyboard(false)
get_viewport().set_input_as_handled()
return
if joy_button.button_index == JOY_BUTTON_RIGHT_SHOULDER:
_set_page(wrapi(int(_page) + 1, 0, Page.size()))
get_viewport().set_input_as_handled()
return
if joy_button != null:
if joy_button.pressed:
# Resolve the automapper's logical roles before raw button numbers.
# Handheld mappings do not necessarily report their labeled A/B/X
# buttons using Godot's matching physical button constants.
if _event_matches_role(
event,
ControllerMappingManagerType.ROLE_X,
JOY_BUTTON_X,
):
_backspace()
elif _event_matches_role(
event,
ControllerMappingManagerType.ROLE_LB,
JOY_BUTTON_LEFT_SHOULDER,
):
_set_page(wrapi(int(_page) - 1, 0, Page.size()))
elif _event_matches_role(
event,
ControllerMappingManagerType.ROLE_RB,
JOY_BUTTON_RIGHT_SHOULDER,
):
_set_page(wrapi(int(_page) + 1, 0, Page.size()))
elif _event_matches_role(
event,
ControllerMappingManagerType.ROLE_A,
JOY_BUTTON_A,
):
_activate_focused_key()
elif _event_matches_role(
event,
ControllerMappingManagerType.ROLE_B,
JOY_BUTTON_B,
):
_close_keyboard(true)
else:
_move_key_focus(_controller_direction(event))
get_viewport().set_input_as_handled()
return
if event.is_action_pressed(&"ui_cancel"):
_close_keyboard(true)
get_viewport().set_input_as_handled()
@ -114,8 +172,15 @@ func _input(event: InputEvent) -> void:
joy_event == null
or not joy_event.pressed
or (
joy_event.button_index != JOY_BUTTON_A
and not event.is_action_pressed(&"ui_accept")
not _event_matches_role(
event,
ControllerMappingManagerType.ROLE_A,
JOY_BUTTON_A,
)
and (
_controller_mapping_manager != null
or not event.is_action_pressed(&"ui_accept")
)
)
):
return
@ -123,6 +188,17 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _event_matches_role(
event: InputEvent,
role: StringName,
fallback_button: JoyButton,
) -> bool:
if _controller_mapping_manager != null:
return _controller_mapping_manager.event_matches_role(event, role)
var button := event as InputEventJoypadButton
return button != null and button.button_index == fallback_button
func _is_available_for_controller() -> bool:
return should_enable_for_controller(
_enabled,
@ -164,9 +240,12 @@ func _open_for(control: Control) -> void:
)
_target.set("virtual_keyboard_enabled", false)
_buffer = str(_target.get("text"))
_buffer_caret = _buffer.length()
_set_target_caret(_buffer_caret)
_page = Page.LOWER
_attach_to_target_window(control)
show()
_reset_caret_blink()
_refresh_preview()
_rebuild_keys()
@ -179,6 +258,8 @@ func _close_keyboard(restore_focus: bool) -> void:
_target_virtual_keyboard_enabled
)
hide()
_left_trigger_pressed = false
_right_trigger_pressed = false
get_viewport().gui_release_focus()
_restore_portable_host()
_target = null
@ -283,15 +364,13 @@ func _move_caret(direction: int) -> void:
if not is_instance_valid(_target):
_close_keyboard(false)
return
var caret: int = clampi(
_buffer_caret = clampi(
_get_caret_column() + direction,
0,
_buffer.length(),
)
if _target is LineEdit:
(_target as LineEdit).caret_column = caret
elif _target is TextEdit:
(_target as TextEdit).set_caret_column(caret)
_set_target_caret(_buffer_caret)
_reset_caret_blink()
_refresh_preview()
@ -320,28 +399,44 @@ func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool:
func _get_caret_column() -> int:
if _target is LineEdit:
return clampi(
(_target as LineEdit).caret_column,
0,
_buffer.length()
)
return _buffer.length()
return clampi(_buffer_caret, 0, _buffer.length())
func _set_target_text(caret: int) -> void:
_buffer_caret = clampi(caret, 0, _buffer.length())
if _target is LineEdit:
var line_edit := _target as LineEdit
line_edit.text = _buffer
line_edit.caret_column = caret
line_edit.text_changed.emit(_buffer)
elif _target is TextEdit:
var text_edit := _target as TextEdit
text_edit.text = _buffer
text_edit.text_changed.emit()
_set_target_caret(_buffer_caret)
_reset_caret_blink()
_refresh_preview()
func _set_target_caret(caret: int) -> void:
if _target is LineEdit:
(_target as LineEdit).caret_column = caret
elif _target is TextEdit:
var text_edit := _target as TextEdit
var text_before_caret: String = _buffer.substr(0, caret)
var caret_line: int = text_before_caret.count("\n")
var last_newline: int = text_before_caret.rfind("\n")
var caret_column: int = (
caret if last_newline < 0 else caret - last_newline - 1
)
text_edit.set_caret_line(caret_line)
text_edit.set_caret_column(caret_column)
func _reset_caret_blink() -> void:
_caret_blink_elapsed = 0.0
_caret_visible = true
func _refresh_preview() -> void:
if _preview == null:
return
@ -349,7 +444,58 @@ func _refresh_preview() -> void:
if _target is LineEdit and (_target as LineEdit).secret:
displayed_text = "*".repeat(_buffer.length())
var caret: int = clampi(_get_caret_column(), 0, displayed_text.length())
_preview.text = displayed_text.insert(caret, "|")
var caret_glyph: String = CARET_GLYPH if _caret_visible else ""
_preview.text = displayed_text.insert(caret, caret_glyph)
func _controller_direction(event: InputEvent) -> Vector2:
if event.is_action_pressed(&"ui_up"):
return Vector2.UP
if event.is_action_pressed(&"ui_down"):
return Vector2.DOWN
if event.is_action_pressed(&"ui_left"):
return Vector2.LEFT
if event.is_action_pressed(&"ui_right"):
return Vector2.RIGHT
return Vector2.ZERO
func _move_key_focus(direction: Vector2) -> void:
if direction == Vector2.ZERO:
return
var focused: Control = get_viewport().gui_get_focus_owner()
if focused == null or not is_ancestor_of(focused):
_focus_first_key()
return
var neighbor_path := NodePath()
if direction == Vector2.UP:
neighbor_path = focused.focus_neighbor_top
elif direction == Vector2.DOWN:
neighbor_path = focused.focus_neighbor_bottom
elif direction == Vector2.LEFT:
neighbor_path = focused.focus_neighbor_left
elif direction == Vector2.RIGHT:
neighbor_path = focused.focus_neighbor_right
if neighbor_path.is_empty():
return
var neighbor := focused.get_node_or_null(neighbor_path) as Control
if ControllerFocusNavigationType.is_focusable(neighbor):
neighbor.grab_focus()
func _activate_focused_key() -> void:
var focused: Control = get_viewport().gui_get_focus_owner()
if focused == null or not is_ancestor_of(focused):
_focus_first_key()
focused = get_viewport().gui_get_focus_owner()
var button := focused as BaseButton
if button != null and not button.disabled:
button.pressed.emit()
func _focus_first_key() -> void:
if not _key_buttons.is_empty():
_key_buttons[0].grab_focus()
func _set_page(page_index: int) -> void:

View file

@ -219,6 +219,8 @@ func consume_escape() -> bool:
func handle_controller_input(event: InputEvent) -> bool:
if not _profile_active or not _profile_interactive:
return false
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
_ensure_controller_zone_focus()
var cancel_pressed := _event_matches_controller_press(
event,
&"ui_cancel",
@ -395,23 +397,7 @@ func _apply_controller_zone_focus() -> void:
func _focus_controller_zone() -> void:
if not _profile_interactive:
return
var controls: Array[Control] = []
match _controller_zone:
ControllerZone.ACCOUNT:
controls = _account_controller_controls()
ControllerZone.CATEGORIES:
controls = _controls_under(_category_list)
ControllerZone.OPTIONS:
var groups: Array = _controller_option_groups()
if not groups.is_empty():
for item: Variant in groups[clampi(
_controller_option_depth, 0, groups.size() - 1
)]:
var option_control := item as Control
if option_control != null:
controls.append(option_control)
ControllerZone.COLOR_PICKER:
controls = _color_picker_controller_controls()
var controls: Array[Control] = _active_controller_zone_controls()
for control: Control in controls:
var button := control as BaseButton
if (
@ -429,6 +415,55 @@ func _focus_controller_zone() -> void:
return
func _ensure_controller_zone_focus() -> void:
var controls: Array[Control] = _active_controller_zone_controls()
if controls.is_empty():
if _controller_zone == ControllerZone.OPTIONS:
_controller_zone = ControllerZone.CATEGORIES
_controller_option_depth = 0
controls = _active_controller_zone_controls()
if controls.is_empty():
return
var focused: Control = get_viewport().gui_get_focus_owner()
if (
focused != null
and focused in controls
and focused.is_visible_in_tree()
and focused.focus_mode != Control.FOCUS_NONE
):
return
_apply_controller_zone_focus()
_focus_controller_zone()
func _active_controller_zone_controls() -> Array[Control]:
if (
_discard_confirmation != null
and _discard_confirmation.visible
):
return [_confirmation_confirm, _keep_editing_button]
match _controller_zone:
ControllerZone.ACCOUNT:
return _account_controller_controls()
ControllerZone.CATEGORIES:
return _controls_under(_category_list)
ControllerZone.OPTIONS:
var groups: Array = _controller_option_groups()
if groups.is_empty():
return []
var controls: Array[Control] = []
for item: Variant in groups[clampi(
_controller_option_depth, 0, groups.size() - 1
)]:
var option_control := item as Control
if option_control != null:
controls.append(option_control)
return controls
ControllerZone.COLOR_PICKER:
return _color_picker_controller_controls()
return []
func _account_controller_controls() -> Array[Control]:
var controls: Array[Control] = []
for control: Control in [
@ -451,21 +486,23 @@ func _controller_option_groups() -> Array:
if _option_list == null:
return groups
if _category_id != "fur_pattern":
groups.append(_controls_under(_option_list))
var option_controls: Array[Control] = _controls_under(_option_list)
if not option_controls.is_empty():
groups.append(option_controls)
return groups
var section_tabs := _option_list.find_child(
"FurSectionTabs", true, false
) as Node
groups.append(_controls_under(section_tabs))
_append_controller_group(groups, _controls_under(section_tabs))
if _active_fur_section == FUR_SECTION_PATTERNS:
groups.append(_controls_under(_option_list.find_child(
_append_controller_group(groups, _controls_under(_option_list.find_child(
"FurPatternPartTabs", true, false
)))
groups.append(_controls_under(_option_list.find_child(
_append_controller_group(groups, _controls_under(_option_list.find_child(
"FurPatternGrid", true, false
)))
else:
groups.append(_controls_under(_option_list.find_child(
_append_controller_group(groups, _controls_under(_option_list.find_child(
"FurColorChannelGrid", true, false
)))
var palette_controls: Array[Control] = _controls_under(
@ -476,10 +513,15 @@ func _controller_option_groups() -> Array:
) as Control
if custom_picker != null:
palette_controls.append(custom_picker)
groups.append(palette_controls)
_append_controller_group(groups, palette_controls)
return groups
func _append_controller_group(groups: Array, controls: Array[Control]) -> void:
if not controls.is_empty():
groups.append(controls)
func _controls_under(root: Node) -> Array[Control]:
var controls: Array[Control] = []
if root == null:

View file

@ -11,6 +11,7 @@ const ControllerFocusNavigationType = preload(
@export var focus_paths: Array[NodePath] = []
@export var initial_focus_path: NodePath
@export var back_focus_path: NodePath
@export var back_entry_path: NodePath
@export var maximum_layout_size: Vector2 = Vector2(720.0, 520.0)
@export var compact_maximum_layout_size: Vector2 = Vector2.ZERO
@export var compact_width_threshold: float = 680.0
@ -146,6 +147,23 @@ func focus_back() -> void:
back.grab_focus()
func set_controller_focus_scope(controls: Array[Control]) -> void:
for focus_control: Control in _focus_controls:
focus_control.focus_mode = Control.FOCUS_NONE
for control: Control in controls:
if control != null and control.is_visible_in_tree():
control.focus_mode = Control.FOCUS_ALL
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
func restore_controller_focus_scope() -> void:
if not visible or _is_transitioning:
return
for focus_control: Control in _focus_controls:
focus_control.focus_mode = Control.FOCUS_ALL
_configure_focus_navigation()
func get_page_id() -> StringName:
return page_id
@ -173,13 +191,13 @@ func _update_layout() -> void:
_cluster.position = _resting_cluster_position
_cluster.size = field_size
_cluster.apply_layout(field_size, compact)
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
_configure_focus_navigation()
func _configure_focus_order() -> void:
for bubble: BubbleButton in _bubbles:
bubble.focus_mode = Control.FOCUS_NONE
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
_configure_focus_navigation()
func _set_interactive(interactive: bool) -> void:
@ -203,16 +221,26 @@ func _set_interactive(interactive: bool) -> void:
if interactive:
call_deferred("_refresh_focus_navigation")
else:
ControllerFocusNavigationType.configure_spatial_neighbors(
_focus_controls
)
_configure_focus_navigation()
func _refresh_focus_navigation() -> void:
if visible and not _is_transitioning:
ControllerFocusNavigationType.configure_spatial_neighbors(
_focus_controls
)
_configure_focus_navigation()
func _configure_focus_navigation() -> void:
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
if back_entry_path.is_empty() or back_focus_path.is_empty():
return
var entry := get_node_or_null(back_entry_path) as Control
var back := get_node_or_null(back_focus_path) as Control
if (
ControllerFocusNavigationType.is_focusable(entry)
and ControllerFocusNavigationType.is_focusable(back)
):
entry.focus_neighbor_bottom = entry.get_path_to(back)
back.focus_neighbor_top = back.get_path_to(entry)
func _finish_transition_out(generation: int, completed: Callable) -> void:

View file

@ -131,6 +131,7 @@ var _pending_identity_operation := ""
var _pending_identity_type := ""
var _pending_identity_path := ""
var _pending_import_data: Dictionary = {}
var _active_adjustment_group: Dictionary = {}
func _ready() -> void:
@ -293,6 +294,56 @@ func setup_data_and_identity(
_refresh_data_page()
func _input(event: InputEvent) -> void:
if not visible or is_input_mapping_capturing():
return
var mouse_button := event as InputEventMouseButton
if (
mouse_button != null
and mouse_button.pressed
and not _active_adjustment_group.is_empty()
):
_exit_adjustment_zone(false)
return
var button := event as InputEventJoypadButton
if button == null or not button.pressed:
return
if (
not _active_adjustment_group.is_empty()
and _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_B,
&"ui_cancel",
JOY_BUTTON_B,
)
):
_exit_adjustment_zone(true)
get_viewport().set_input_as_handled()
return
if not _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_A,
&"ui_accept",
JOY_BUTTON_A,
):
return
if _active_adjustment_group.is_empty():
var group: Dictionary = _adjustment_group_for_control(
get_viewport().gui_get_focus_owner()
)
if group.is_empty():
return
_enter_adjustment_zone(group)
get_viewport().set_input_as_handled()
return
var focused := get_viewport().gui_get_focus_owner() as BaseButton
var decrease := _active_adjustment_group.get("decrease") as BaseButton
var increase := _active_adjustment_group.get("increase") as BaseButton
if focused == decrease or focused == increase:
focused.pressed.emit()
get_viewport().set_input_as_handled()
func open_panel(
settings_manager: SettingsManagerType,
presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL,
@ -370,6 +421,9 @@ func _finish_panel_close(applied_result: bool) -> void:
func handle_back() -> void:
if not _active_adjustment_group.is_empty():
_exit_adjustment_zone(true)
return
if (
_keyboard_mouse_mapping_panel != null
and _keyboard_mouse_mapping_panel.is_open()
@ -440,9 +494,105 @@ func _push_page(page_id: StringName) -> void:
or get_active_page_id() == page_id
):
return
_exit_adjustment_zone(false)
_start_page_transition(page_id, true)
func _adjustment_group_for_control(control: Control) -> Dictionary:
for group: Dictionary in _adjustment_groups():
if group.get("parent") as Control == control:
return group
return {}
func _adjustment_groups() -> Array[Dictionary]:
return [
{
"page": _controls_page,
"parent": _mouse_value,
"decrease": %MouseDecrease,
"increase": %MouseIncrease,
},
{
"page": _controls_page,
"parent": _controller_value,
"decrease": %ControllerDecrease,
"increase": %ControllerIncrease,
},
{
"page": _accessibility_page,
"parent": _auto_click_interval,
"decrease": %IntervalDecrease,
"increase": %IntervalIncrease,
},
]
func _enter_adjustment_zone(group: Dictionary) -> void:
var page := group.get("page") as SettingsBubblePage
var parent := group.get("parent") as Control
var decrease := group.get("decrease") as Control
var increase := group.get("increase") as Control
if page == null or parent == null or decrease == null or increase == null:
return
_active_adjustment_group = group
var controls: Array[Control] = [decrease, parent, increase]
page.set_controller_focus_scope(controls)
_set_adjustment_neighbors(decrease, parent, increase)
parent.grab_focus()
func _exit_adjustment_zone(restore_parent_focus: bool) -> void:
if _active_adjustment_group.is_empty():
return
var page := _active_adjustment_group.get("page") as SettingsBubblePage
var parent := _active_adjustment_group.get("parent") as Control
var decrease := _active_adjustment_group.get("decrease") as Control
var increase := _active_adjustment_group.get("increase") as Control
_active_adjustment_group.clear()
for control: Control in [decrease, increase]:
if control != null:
control.focus_mode = Control.FOCUS_NONE
if page != null:
page.restore_controller_focus_scope()
if restore_parent_focus and parent != null:
parent.call_deferred("grab_focus")
func _set_adjustment_neighbors(
decrease: Control,
parent: Control,
increase: Control,
) -> void:
decrease.focus_neighbor_left = decrease.get_path_to(decrease)
decrease.focus_neighbor_right = decrease.get_path_to(parent)
parent.focus_neighbor_left = parent.get_path_to(decrease)
parent.focus_neighbor_right = parent.get_path_to(increase)
increase.focus_neighbor_left = increase.get_path_to(parent)
increase.focus_neighbor_right = increase.get_path_to(increase)
for control: Control in [decrease, parent, increase]:
control.focus_neighbor_top = control.get_path_to(control)
control.focus_neighbor_bottom = control.get_path_to(control)
func _event_matches_controller_role(
event: InputEvent,
role: StringName,
action: StringName,
fallback_button: JoyButton,
) -> bool:
if _controller_mapping_manager != null:
return _controller_mapping_manager.event_matches_role(event, role)
var button := event as InputEventJoypadButton
return (
button != null
and (
button.button_index == fallback_button
or event.is_action_pressed(action)
)
)
func _start_page_transition(page_id: StringName, push_page: bool) -> void:
var outgoing_page: SettingsBubblePage = _get_active_page()
var incoming_page: SettingsBubblePage = _pages.get(page_id)

View file

@ -425,6 +425,7 @@ bubble_paths = Array[NodePath]([NodePath("BubbleCluster/SoundBackButton")])
focus_paths = Array[NodePath]([NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider"), NodePath("Paper/Content/VolumeGrid/MusicVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EffectsVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EnvironmentVolumeSlider"), NodePath("BubbleCluster/SoundBackButton")])
initial_focus_path = NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider")
back_focus_path = NodePath("BubbleCluster/SoundBackButton")
back_entry_path = NodePath("Paper/Content/VolumeGrid/EnvironmentVolumeSlider")
compact_maximum_layout_size = Vector2(544, 400)
[node name="BubbleCluster" type="Control" parent="SoundPage"]
@ -601,7 +602,7 @@ grow_vertical = 2
script = ExtResource("3_page")
page_id = &"controls"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")])
initial_focus_path = NodePath("BubbleCluster/MouseValue")
back_focus_path = NodePath("BubbleCluster/ControlsBackButton")
@ -758,7 +759,7 @@ grow_vertical = 2
script = ExtResource("3_page")
page_id = &"accessibility"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/IntervalHelp"), NodePath("BubbleCluster/AccessibilityBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/AccessibilityBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/AccessibilityBackButton")])
initial_focus_path = NodePath("BubbleCluster/AutoClickToggle")
back_focus_path = NodePath("BubbleCluster/AccessibilityBackButton")

View file

@ -11,6 +11,9 @@ const ControllerFocusPresentationType = preload(
const ControllerFocusRecoveryType = preload(
"res://ui/controller_focus_recovery.gd"
)
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd")
signal effective_pixel_size_changed(
@ -60,6 +63,12 @@ func set_on_screen_keyboard_enabled(enabled: bool) -> void:
_on_screen_keyboard.set_enabled(enabled)
func setup_controller_mapping(
mapping_manager: ControllerMappingManagerType,
) -> void:
_on_screen_keyboard.setup_controller_mapping(mapping_manager)
func set_pixel_size(pixel_size: int) -> void:
_requested_pixel_size = clampi(
pixel_size,