diff --git a/Libraries/LibWeb/CMakeLists.txt b/Libraries/LibWeb/CMakeLists.txt index 9f38e923d4f..b2ee7b6cfd0 100644 --- a/Libraries/LibWeb/CMakeLists.txt +++ b/Libraries/LibWeb/CMakeLists.txt @@ -382,7 +382,11 @@ set(SOURCES FileAPI/FileList.cpp FileAPI/FileReader.cpp FileAPI/FileReaderSync.cpp + Gamepad/EventNames.cpp Gamepad/Gamepad.cpp + Gamepad/GamepadButton.cpp + Gamepad/GamepadEvent.cpp + Gamepad/GamepadHapticActuator.cpp Gamepad/NavigatorGamepad.cpp Geolocation/Geolocation.cpp Serial/Serial.cpp diff --git a/Libraries/LibWeb/DOM/Document.cpp b/Libraries/LibWeb/DOM/Document.cpp index 6d7f68d0ca8..4a3412d66c7 100644 --- a/Libraries/LibWeb/DOM/Document.cpp +++ b/Libraries/LibWeb/DOM/Document.cpp @@ -4453,6 +4453,9 @@ bool Document::is_allowed_to_use_feature(PolicyControlledFeature feature) const case PolicyControlledFeature::EncryptedMedia: // FIXME: Implement allowlist for this. return true; + case PolicyControlledFeature::Gamepad: + // FIXME: Implement allowlist for this. + return true; } // 4. Return false. diff --git a/Libraries/LibWeb/DOM/Document.h b/Libraries/LibWeb/DOM/Document.h index 50c9bf4fb92..016bb2dd6c6 100644 --- a/Libraries/LibWeb/DOM/Document.h +++ b/Libraries/LibWeb/DOM/Document.h @@ -160,6 +160,7 @@ enum class PolicyControlledFeature : u8 { Autoplay, EncryptedMedia, FocusWithoutUserActivation, + Gamepad, }; class WEB_API Document diff --git a/Libraries/LibWeb/DOM/EventHandler.idl b/Libraries/LibWeb/DOM/EventHandler.idl index c83dbda0f87..161f1112bc8 100644 --- a/Libraries/LibWeb/DOM/EventHandler.idl +++ b/Libraries/LibWeb/DOM/EventHandler.idl @@ -128,4 +128,8 @@ interface mixin WindowEventHandlers { attribute EventHandler onstorage; attribute EventHandler onunhandledrejection; attribute EventHandler onunload; + + // https://w3c.github.io/gamepad/#extensions-to-the-windoweventhandlers-interface-mixin + attribute EventHandler ongamepadconnected; + attribute EventHandler ongamepaddisconnected; }; diff --git a/Libraries/LibWeb/Forward.h b/Libraries/LibWeb/Forward.h index 85a1d2ae5c4..29d8b2841a9 100644 --- a/Libraries/LibWeb/Forward.h +++ b/Libraries/LibWeb/Forward.h @@ -537,7 +537,11 @@ class FileList; namespace Web::Gamepad { +class NavigatorGamepadPartial; class Gamepad; +class GamepadButton; +class GamepadEvent; +class GamepadHapticActuator; } diff --git a/Libraries/LibWeb/Gamepad/EventNames.cpp b/Libraries/LibWeb/Gamepad/EventNames.cpp new file mode 100644 index 00000000000..9b97b2c9470 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/EventNames.cpp @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include + +namespace Web::Gamepad::EventNames { + +#define __ENUMERATE_GAMEPAD_EVENT(name) \ + FlyString name = #name##_fly_string; +ENUMERATE_GAMEPAD_EVENTS +#undef __ENUMERATE_GAMEPAD_EVENT + +} diff --git a/Libraries/LibWeb/Gamepad/EventNames.h b/Libraries/LibWeb/Gamepad/EventNames.h new file mode 100644 index 00000000000..9379d8e8530 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/EventNames.h @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace Web::Gamepad::EventNames { + +#define ENUMERATE_GAMEPAD_EVENTS \ + __ENUMERATE_GAMEPAD_EVENT(gamepadconnected) \ + __ENUMERATE_GAMEPAD_EVENT(gamepaddisconnected) + +#define __ENUMERATE_GAMEPAD_EVENT(name) extern FlyString name; +ENUMERATE_GAMEPAD_EVENTS +#undef __ENUMERATE_GAMEPAD_EVENT + +} diff --git a/Libraries/LibWeb/Gamepad/Gamepad.cpp b/Libraries/LibWeb/Gamepad/Gamepad.cpp index b619b315d65..fa1f450fca6 100644 --- a/Libraries/LibWeb/Gamepad/Gamepad.cpp +++ b/Libraries/LibWeb/Gamepad/Gamepad.cpp @@ -1,18 +1,146 @@ /* * Copyright (c) 2025, Jelle Raaijmakers + * Copyright (c) 2025, Luke Wilde * * SPDX-License-Identifier: BSD-2-Clause */ -#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include namespace Web::Gamepad { -Gamepad::Gamepad(JS::Realm& realm) - : PlatformObject(realm) +GC_DEFINE_ALLOCATOR(Gamepad); + +// https://w3c.github.io/gamepad/#dfn-standard-gamepad +// Type Index Location +// Button 0 Bottom button in right cluster +// 1 Right button in right cluster +// 2 Left button in right cluster +// 3 Top button in right cluster +// 4 Top left front button +// 5 Top right front button +// 6 Bottom left front button +// 7 Bottom right front button +// 8 Left button in center cluster +// 9 Right button in center cluster +// 10 Left stick pressed button +// 11 Right stick pressed button +// 12 Top button in left cluster +// 13 Bottom button in left cluster +// 14 Left button in left cluster +// 15 Right button in left cluster +// 16 Center button in center cluster +static Array, 17> standard_gamepad_button_layout { + SDL_GAMEPAD_BUTTON_SOUTH, + SDL_GAMEPAD_BUTTON_EAST, + SDL_GAMEPAD_BUTTON_WEST, + SDL_GAMEPAD_BUTTON_NORTH, + SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, + SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, + SDL_GAMEPAD_AXIS_LEFT_TRIGGER, + SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, + SDL_GAMEPAD_BUTTON_BACK, + SDL_GAMEPAD_BUTTON_START, + SDL_GAMEPAD_BUTTON_LEFT_STICK, + SDL_GAMEPAD_BUTTON_RIGHT_STICK, + SDL_GAMEPAD_BUTTON_DPAD_UP, + SDL_GAMEPAD_BUTTON_DPAD_DOWN, + SDL_GAMEPAD_BUTTON_DPAD_LEFT, + SDL_GAMEPAD_BUTTON_DPAD_RIGHT, + SDL_GAMEPAD_BUTTON_GUIDE, +}; + +static Array non_standard_gamepad_button_layout { + SDL_GAMEPAD_BUTTON_MISC1, + SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1, + SDL_GAMEPAD_BUTTON_LEFT_PADDLE1, + SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2, + SDL_GAMEPAD_BUTTON_LEFT_PADDLE2, + SDL_GAMEPAD_BUTTON_TOUCHPAD, + SDL_GAMEPAD_BUTTON_MISC2, + SDL_GAMEPAD_BUTTON_MISC3, + SDL_GAMEPAD_BUTTON_MISC4, + SDL_GAMEPAD_BUTTON_MISC5, + SDL_GAMEPAD_BUTTON_MISC6, +}; + +// axes 0 Horizontal axis for left stick (negative left/positive right) +// 1 Vertical axis for left stick (negative up/positive down) +// 2 Horizontal axis for right stick (negative left/positive right) +// 3 Vertical axis for right stick (negative up/positive down) +static Array standard_gamepad_axes_layout { + SDL_GAMEPAD_AXIS_LEFTX, + SDL_GAMEPAD_AXIS_LEFTY, + SDL_GAMEPAD_AXIS_RIGHTX, + SDL_GAMEPAD_AXIS_RIGHTY, +}; + +// https://w3c.github.io/gamepad/#dfn-button-press-threshold +// For buttons which do not have a digital switch to indicate a pure pressed or released state, the user +// agent MUST choose a button press threshold to indicate the button as pressed when its value is above a +// certain amount. If the platform API gives a recommended value, the user agent SHOULD use that. In other +// cases, the user agent SHOULD choose some other reasonable value. +static constexpr double ANALOG_BUTTON_PRESS_THRESHOLD = 0.1; + +static constexpr double GAMEPAD_EXPOSURE_AXIS_THRESHOLD = 0.5; + +// https://w3c.github.io/gamepad/#dfn-a-new-gamepad +GC::Ref Gamepad::create(JS::Realm& realm, SDL_JoystickID sdl_joystick_id) { + // 1. Let gamepad be a newly created Gamepad instance: + auto gamepad = realm.create(realm, sdl_joystick_id); + + // 1. Initialize gamepad's id attribute to an identification string for the gamepad. + // FIXME: What is the encoding used by SDL? + auto const* name = SDL_GetGamepadNameForID(sdl_joystick_id); + if (name) { + gamepad->m_id = Utf16String::from_utf8(StringView { name, strlen(name) }); + } + + // 2. Initialize gamepad's index attribute to the result of selecting an unused gamepad index for gamepad. + // https://w3c.github.io/gamepad/#dfn-selecting-an-unused-gamepad-index + // 1. Let navigator be gamepad's relevant global object's Navigator object. + // The rest of the steps are implemented in NavigatorGamepad. + // NOTE: Gamepad is only exposed on Window. + auto& window = as(HTML::relevant_global_object(gamepad)); + gamepad->m_index = window.navigator()->select_an_unused_gamepad_index({}); + + // 3. Initialize gamepad's mapping attribute to the result of selecting a mapping for the gamepad device. + gamepad->select_a_mapping(); + + // 4. Set gamepad.[[connected]] to true. + gamepad->m_connected = true; + + // 5. Set gamepad.[[timestamp]] to the current high resolution time given gamepad's relevant global object. + gamepad->m_timestamp = HighResolutionTime::current_high_resolution_time(HTML::relevant_global_object(gamepad)); + + // 6. Set gamepad.[[axes]] to the result of initializing axes for gamepad. + gamepad->initialize_axes(); + + // 7. Set gamepad.[[buttons]] to the result of initializing buttons for gamepad. + gamepad->initialize_buttons(); + + // 8. Set gamepad.[[vibrationActuator]] to the result of constructing a GamepadHapticActuator for gamepad. + gamepad->m_vibration_actuator = GamepadHapticActuator::create(realm, gamepad); + + // 2. Return gamepad. + return gamepad; +} + +Gamepad::Gamepad(JS::Realm& realm, SDL_JoystickID sdl_joystick_id) + : PlatformObject(realm) + , m_sdl_joystick_id(sdl_joystick_id) +{ + m_sdl_gamepad = SDL_OpenGamepad(m_sdl_joystick_id); } void Gamepad::initialize(JS::Realm& realm) @@ -21,4 +149,478 @@ void Gamepad::initialize(JS::Realm& realm) Base::initialize(realm); } +void Gamepad::visit_edges(Cell::Visitor& visitor) +{ + Base::visit_edges(visitor); + visitor.visit(m_buttons); + visitor.visit(m_vibration_actuator); +} + +void Gamepad::finalize() +{ + SDL_CloseGamepad(m_sdl_gamepad); +} + +// https://w3c.github.io/gamepad/#dfn-initializing-axes +void Gamepad::initialize_axes() +{ + // 1. Let inputCount be the number of axis inputs exposed by the device represented by gamepad. + Vector inputs; + + // 2. Set gamepad.[[axisMinimums]] to a list of unsigned long values with size equal to inputCount containing minimum logical values for each of the axis inputs. + // 3. Set gamepad.[[axisMaximums]] to a list of unsigned long values with size equal to inputCount containing maximum logical values for each of the axis inputs. + for (auto const standard_gamepad_axis : standard_gamepad_axes_layout) { + if (SDL_GamepadHasAxis(m_sdl_gamepad, standard_gamepad_axis)) { + inputs.append(standard_gamepad_axis); + m_axis_minimums.append(SDL_JOYSTICK_AXIS_MIN); + m_axis_maximums.append(SDL_JOYSTICK_AXIS_MAX); + } + } + + // 4. Let unmappedInputList be an empty list. + Vector unmapped_input_list; + + // 5. Let mappedIndexList be an empty list. + Vector mapped_index_list; + + // 6. Let axesSize be 0. + size_t axes_size = 0; + + // 7. For each rawInputIndex of the range from 0 to inputCount − 1: + for (size_t raw_input_index = 0; raw_input_index < inputs.size(); ++raw_input_index) { + // 1. If the gamepad axis at index rawInputIndex represents a Standard Gamepad axis: + auto const axis = inputs[raw_input_index]; + if (auto maybe_index = standard_gamepad_axes_layout.first_index_of(axis); maybe_index.has_value()) { + // 1. Let canonicalIndex be the canonical index for the axis. + auto canonical_index = maybe_index.value(); + + // 2. If mappedIndexList contains canonicalIndex, then append rawInputIndex to unmappedInputList. + if (mapped_index_list.contains_slow(canonical_index)) { + unmapped_input_list.append(raw_input_index); + } else { + // Otherwise: + // 1. Set gamepad.[[axisMapping]][rawInputIndex] to canonicalIndex. + m_axis_mapping.set(raw_input_index, canonical_index); + + // 2. Append canonicalIndex to mappedIndexList. + mapped_index_list.append(canonical_index); + + // 3. If canonicalIndex + 1 is greater than axesSize, then set axesSize to canonicalIndex + 1. + if (canonical_index + 1 > axes_size) + axes_size = canonical_index + 1; + } + } else { + // Otherwise, append rawInputIndex to unmappedInputList. + unmapped_input_list.append(raw_input_index); + } + } + + // 8. Let axisIndex be 0. + size_t axis_index = 0; + + // 9. For each rawInputIndex of unmappedInputList: + for (size_t raw_input_index : unmapped_input_list) { + // 1. While mappedIndexList contains axisIndex: + while (mapped_index_list.contains_slow(axis_index)) { + // 1. Increment axisIndex. + ++axis_index; + } + + // 2. Set gamepad.[[axisMapping]][rawInputIndex] to axisIndex. + m_axis_mapping.set(raw_input_index, axis_index); + + // 3. Append axisIndex to mappedIndexList. + mapped_index_list.append(axis_index); + + // 4. If axisIndex + 1 is greater than axesSize, then set axesSize to axisIndex + 1. + if (axis_index + 1 > axes_size) + axes_size = axis_index + 1; + } + + // NOTE: Instead of returning a list, we can just directly update m_buttons. + // 10. Let axes be an empty list. + // 11. For each axisIndex of the range from 0 to axesSize − 1, append 0 to axes. + // 12. Return axes. + for (size_t final_axis_index = 0; final_axis_index < axes_size; ++final_axis_index) + m_axes.append(0.0); +} + +// https://w3c.github.io/gamepad/#dfn-initializing-buttons +void Gamepad::initialize_buttons() +{ + auto& realm = this->realm(); + + // 1. Let inputCount be the number of button inputs exposed by the device represented by gamepad. + Vector> inputs; + + // 2. Set gamepad.[[buttonMinimums]] to be a list of unsigned long values with size equal to inputCount containing minimum logical values for each of the button inputs. + // 3. Set gamepad.[[buttonMaximums]] to be a list of unsigned long values with size equal to inputCount containing maximum logical values for each of the button inputs. + for (auto const& standard_gamepad_button : standard_gamepad_button_layout) { + standard_gamepad_button.visit( + [this, &inputs](SDL_GamepadButton button) { + if (SDL_GamepadHasButton(m_sdl_gamepad, button)) { + inputs.append(button); + + // Buttons are binary inputs with SDL. + m_button_minimums.append(0); + m_button_maximums.append(1); + } + }, + [this, &inputs](SDL_GamepadAxis axis) { + if (SDL_GamepadHasAxis(m_sdl_gamepad, axis)) { + inputs.append(axis); + + // "Trigger axis values range from 0 (released) to SDL_JOYSTICK_AXIS_MAX (fully + // pressed) when reported by SDL_GetGamepadAxis(). Note that this is not the + // same range that will be reported by the lower-level SDL_GetJoystickAxis()." + m_button_minimums.append(0); + m_button_maximums.append(SDL_JOYSTICK_AXIS_MAX); + } + }, + [](Empty) { + VERIFY_NOT_REACHED(); + }); + } + + for (auto const non_standard_gamepad_button : non_standard_gamepad_button_layout) { + if (SDL_GamepadHasButton(m_sdl_gamepad, non_standard_gamepad_button)) { + inputs.append(non_standard_gamepad_button); + + // Buttons are binary inputs with SDL. + m_button_minimums.append(0); + m_button_maximums.append(1); + } + } + + // 4. Let unmappedInputList be an empty list. + Vector unmapped_input_list; + + // 5. Let mappedIndexList be an empty list. + Vector mapped_index_list; + + // 6. Let buttonsSize be 0. + size_t buttons_size = 0; + + // 7. For each rawInputIndex of the range from 0 to inputCount − 1: + for (size_t raw_input_index = 0; raw_input_index < inputs.size(); ++raw_input_index) { + auto const& input = inputs[raw_input_index]; + + // 1. If the gamepad button at index rawInputIndex represents a Standard Gamepad button: + if (auto maybe_index = standard_gamepad_button_layout.first_index_of(input); maybe_index.has_value()) { + // 1. Let canonicalIndex be the canonical index for the button. + auto canonical_index = maybe_index.value(); + + // 2. If mappedIndexList contains canonicalIndex, then append rawInputIndex to unmappedInputList. + if (mapped_index_list.contains_slow(canonical_index)) { + unmapped_input_list.append(raw_input_index); + } else { + // Otherwise: + // 1. Set gamepad.[[buttonMapping]][rawInputIndex] to canonicalIndex. + m_button_mapping.set(raw_input_index, canonical_index); + + // 2. Append canonicalIndex to mappedIndexList. + mapped_index_list.append(canonical_index); + + // 3. If canonicalIndex + 1 is greater than buttonsSize, then set buttonsSize to canonicalIndex + 1. + if (canonical_index + 1 > buttons_size) + buttons_size = canonical_index + 1; + } + } else { + // Otherwise, append rawInputIndex to unmappedInputList. + unmapped_input_list.append(raw_input_index); + } + + // 2. Increment rawInputIndex. + } + + // 8. Let buttonIndex be 0. + size_t button_index = 0; + + // 9. For each rawInputIndex of unmappedInputList: + for (size_t raw_input_index : unmapped_input_list) { + // 1. While mappedIndexList contains buttonIndex: + while (mapped_index_list.contains_slow(button_index)) { + // 1. Increment buttonIndex. + ++button_index; + } + + // 2. Set gamepad.[[buttonMapping]][rawInputIndex] to buttonIndex. + m_button_mapping.set(raw_input_index, button_index); + + // 3. Append buttonIndex to mappedIndexList. + mapped_index_list.append(button_index); + + // 4. If buttonIndex + 1 is greater than buttonsSize, then set buttonsSize to buttonIndex + 1. + if (button_index + 1 > buttons_size) + buttons_size = button_index + 1; + } + + // NOTE: Instead of returning a list (and thus needing to use RootVector), we can just directly update m_buttons. + // 10. Let buttons be an empty list. + // 11. For each buttonIndex of the range from 0 to buttonsSize − 1, append a new GamepadButton to buttons. + // 12. Return buttons. + for (size_t final_button_index = 0; final_button_index < buttons_size; ++final_button_index) { + auto gamepad_button = realm.create(realm); + m_buttons.append(gamepad_button); + } +} + +GC::Ref Gamepad::vibration_actuator() const +{ + VERIFY(m_vibration_actuator); + return *m_vibration_actuator; +} + +void Gamepad::set_connected(Badge, bool value) +{ + m_connected = value; +} + +void Gamepad::set_exposed(Badge, bool value) +{ + m_exposed = value; +} + +void Gamepad::set_timestamp(Badge, HighResolutionTime::DOMHighResTimeStamp value) +{ + m_timestamp = value; +} + +// https://w3c.github.io/gamepad/#dfn-selecting-a-mapping +void Gamepad::select_a_mapping() +{ + // 1. If the button and axis layout of the gamepad device corresponds with the Standard Gamepad layout, then + // return "standard". + // 2. Return "". + for (auto const& standard_gamepad_button : standard_gamepad_button_layout) { + bool has_standard_button = standard_gamepad_button.visit( + [this](SDL_GamepadButton button) -> bool { + return SDL_GamepadHasButton(m_sdl_gamepad, button); + }, + [this](SDL_GamepadAxis axis) -> bool { + return SDL_GamepadHasAxis(m_sdl_gamepad, axis); + }, + [](Empty) -> bool { + VERIFY_NOT_REACHED(); + }); + + if (!has_standard_button) { + m_mapping = Bindings::GamepadMappingType::Empty; + return; + } + } + + for (auto const standard_gamepad_axis : standard_gamepad_axes_layout) { + if (!SDL_GamepadHasAxis(m_sdl_gamepad, standard_gamepad_axis)) { + m_mapping = Bindings::GamepadMappingType::Empty; + return; + } + } + + m_mapping = Bindings::GamepadMappingType::Standard; +} + +// https://w3c.github.io/gamepad/#dfn-map-and-normalize-axes +void Gamepad::map_and_normalize_axes() +{ + // 1. Let axisValues be a list of unsigned long values representing the most recent logical axis input values for + // each axis input of the device represented by gamepad. + // NOTE: While the Gamepad API internally uses u32 to represent raw axis values, SDL uses i16 for axes. + Vector axis_values; + for (auto const standard_gamepad_axis : standard_gamepad_axes_layout) { + if (SDL_GamepadHasAxis(m_sdl_gamepad, standard_gamepad_axis)) + axis_values.append(SDL_GetGamepadAxis(m_sdl_gamepad, standard_gamepad_axis)); + } + + // 2. Let maxRawAxisIndex be the size of axisValues − 1. + // 3. For each rawAxisIndex of the range from 0 to maxRawAxisIndex: + for (size_t raw_axis_index = 0; raw_axis_index < axis_values.size(); ++raw_axis_index) { + // 1. Let mappedIndex be gamepad.[[axisMapping]][rawAxisIndex]. + auto mapped_index = m_axis_mapping.get(raw_axis_index).value(); + + // 2. Let logicalValue be axisValues[rawAxisIndex]. + auto logical_value = axis_values[raw_axis_index]; + + // 3. Let logicalMinimum be gamepad.[[axisMinimums]][rawAxisIndex]. + auto logical_minimum = m_axis_minimums[raw_axis_index]; + + // 4. Let logicalMaximum be gamepad.[[axisMaximums]][rawAxisIndex]. + auto logical_maximum = m_axis_maximums[raw_axis_index]; + + // 5. Let normalizedValue be 2 (logicalValue − logicalMinimum) / (logicalMaximum − logicalMinimum) − 1. + double normalized_value = 2.0 * static_cast(logical_value - logical_minimum) / static_cast(logical_maximum - logical_minimum) - 1.0; + + // 6. Set gamepad.[[axes]][axisIndex] to be normalizedValue. + // FIXME: axisIndex should be mappedIndex. + m_axes[mapped_index] = normalized_value; + } +} + +// https://w3c.github.io/gamepad/#dfn-map-and-normalize-buttons +void Gamepad::map_and_normalize_buttons() +{ + // 1. Let buttonValues be a list of unsigned long values representing the most recent logical button input values + // for each button input of the device represented by gamepad. + // NOTE: While the Gamepad API internally uses u32 to represent raw button values, SDL uses bool for buttons and + // i16 for axes. The left and right triggers are buttons in the Gamepad API. + Vector button_values; + + for (auto const& standard_gamepad_button : standard_gamepad_button_layout) { + standard_gamepad_button.visit( + [this, &button_values](SDL_GamepadButton button) { + if (SDL_GamepadHasButton(m_sdl_gamepad, button)) { + bool button_pressed = SDL_GetGamepadButton(m_sdl_gamepad, button); + button_values.append(button_pressed ? 1 : 0); + } + }, + [this, &button_values](SDL_GamepadAxis axis) { + if (SDL_GamepadHasAxis(m_sdl_gamepad, axis)) + button_values.append(SDL_GetGamepadAxis(m_sdl_gamepad, axis)); + }, + [](Empty) { + VERIFY_NOT_REACHED(); + }); + } + + for (auto const non_standard_gamepad_button : non_standard_gamepad_button_layout) { + if (SDL_GamepadHasButton(m_sdl_gamepad, non_standard_gamepad_button)) { + bool button_pressed = SDL_GetGamepadButton(m_sdl_gamepad, non_standard_gamepad_button); + button_values.append(button_pressed ? 1 : 0); + } + } + + // 2. Let maxRawButtonIndex be the size of buttonValues − 1. + // 3. For each rawButtonIndex of the range from 0 to maxRawButtonIndex: + for (size_t raw_button_index = 0; raw_button_index < button_values.size(); ++raw_button_index) { + // 1. Let mappedIndex be gamepad.[[buttonMapping]][rawButtonIndex]. + auto mapped_index = m_button_mapping.get(raw_button_index).value(); + + // 2. Let logicalValue be buttonValues[rawButtonIndex]. + auto logical_value = button_values[raw_button_index]; + + // 3. Let logicalMinimum be gamepad.[[buttonMinimums]][rawButtonIndex]. + auto logical_minimum = m_button_minimums[raw_button_index]; + + // 4. Let logicalMaximum be gamepad.[[buttonMaximums]][rawButtonIndex]. + auto logical_maximum = m_button_maximums[raw_button_index]; + + // 5. Let normalizedValue be (logicalValue − logicalMinimum) / (logicalMaximum − logicalMinimum). + double value = static_cast(logical_value - logical_minimum) / static_cast(logical_maximum - logical_minimum); + + // 6. Let button be gamepad.[[buttons]][mappedIndex]. + auto button = m_buttons[mapped_index]; + + // 7. Set button.[[value]] to normalizedValue. + button->set_value({}, value); + + // 8. If the button has a digital switch to indicate a pure pressed or released state, set button.[[pressed]] + // to true if the button is pressed or false if it is not pressed. + // Otherwise, set button.[[pressed]] to true if the value is above the button press threshold or false if + // it is not above the threshold. + if (logical_maximum == 1) { + button->set_pressed({}, logical_value == 1); + } else { + button->set_pressed({}, value > ANALOG_BUTTON_PRESS_THRESHOLD); + } + + // 9. If the button is capable of detecting touch, set button.[[touched]] to true if the button is currently being touched. + // Otherwise, set button.[[touched]] to button.[[pressed]]. + // FIXME: Support the PS4/PS5 controller which has a touchpad, which is a button that can be touched and not pressed in at the same time. + button->set_touched({}, button->pressed()); + } +} + +// https://w3c.github.io/gamepad/#dfn-update-gamepad-state +void Gamepad::update_gamepad_state(Badge) +{ + auto& realm = this->realm(); + + // 1. Let now be the current high resolution time given gamepad's relevant global object. + auto& window = as(HTML::relevant_global_object(*this)); + auto now = HighResolutionTime::current_high_resolution_time(window); + + // 2. Set gamepad.[[timestamp]] to now. + m_timestamp = now; + + // 3. Run the steps to map and normalize axes for gamepad. + map_and_normalize_axes(); + + // 4. Run the steps to map and normalize buttons for gamepad. + map_and_normalize_buttons(); + + // FIXME: 5. Run the steps to record touches for gamepad. + + // 6. Let navigator be gamepad's relevant global object's Navigator object. + auto navigator = window.navigator(); + + // 7. If navigator.[[hasGamepadGesture]] is false and gamepad contains a gamepad user gesture: + if (!navigator->has_gamepad_gesture() && contains_gamepad_user_gesture()) { + // 1. Set navigator.[[hasGamepadGesture]] to true. + navigator->set_has_gamepad_gesture({}, true); + + // 2. For each connectedGamepad of navigator.[[gamepads]]: + for (auto connected_gamepad : navigator->gamepads({})) { + // 1. If connectedGamepad is not equal to null: + if (connected_gamepad) { + // 1. Set connectedGamepad.[[exposed]] to true. + connected_gamepad->m_exposed = true; + + // 2. Set connectedGamepad.[[timestamp]] to now. + connected_gamepad->m_timestamp = now; + + // 3. Let document be gamepad's relevant global object's associated Document; otherwise null. + auto& document = window.associated_document(); + + // 4. If document is not null and is fully active, then queue a global task on the gamepad task source + // to fire an event named gamepadconnected at gamepad's relevant global object using GamepadEvent + // with its gamepad attribute initialized to connectedGamepad. + if (document.is_fully_active()) { + auto gamepad_connected_event_init = GamepadEventInit { + { + .bubbles = false, + .cancelable = false, + .composed = false, + }, + connected_gamepad, + }; + auto gamepad_connected_event = MUST(GamepadEvent::construct_impl(realm, EventNames::gamepadconnected, gamepad_connected_event_init)); + window.dispatch_event(gamepad_connected_event); + } + } + } + } +} + +// https://w3c.github.io/gamepad/#dfn-gamepad-user-gesture +bool Gamepad::contains_gamepad_user_gesture() +{ + // A gamepad contains a gamepad user gesture if the current input state indicates that the user is currently + // interacting with the gamepad. The user agent MUST provide an algorithm to check if the input state contains a + // gamepad user gesture. For buttons that support a neutral default value and have reported a pressed value of + // false at least once, a pressed value of true SHOULD be considered interaction. If a button does not support a + // neutral default value (for example, a toggle switch), then a pressed value of true SHOULD NOT be considered + // interaction. If a button has never reported a pressed value of false then it SHOULD NOT be considered + // interaction. Axis movements SHOULD be considered interaction if the axis supports a neutral default value, the + // current displacement from neutral is greater than a threshold chosen by the user agent, and the axis has + // reported a value below the threshold at least once. If an axis does not support a neutral default value (for + // example, an axis for a joystick that does not self-center), or an axis has never reported a value below the axis + // gesture threshold, then the axis SHOULD NOT be considered when checking for interaction. The axis gesture + // threshold SHOULD be large enough that random jitter is not considered interaction. + + // NOTE: This roughly follows Chrome, where it exposes gamepads if a button is pressed (even if it's held across + // a refresh) or an absolute axis is above 0.5. + auto pressed_button = m_buttons.find_if([](GC::Ref gamepad_button) { + return gamepad_button->pressed(); + }); + + if (!pressed_button.is_end()) + return true; + + auto axis_above_threshold = m_axes.find_if([](double value) { + return abs(value) > GAMEPAD_EXPOSURE_AXIS_THRESHOLD; + }); + + return !axis_above_threshold.is_end(); +} + } diff --git a/Libraries/LibWeb/Gamepad/Gamepad.h b/Libraries/LibWeb/Gamepad/Gamepad.h index b5173a58df5..cb1a25a7b65 100644 --- a/Libraries/LibWeb/Gamepad/Gamepad.h +++ b/Libraries/LibWeb/Gamepad/Gamepad.h @@ -1,24 +1,156 @@ /* * Copyright (c) 2025, Jelle Raaijmakers + * Copyright (c) 2025, Luke Wilde * * SPDX-License-Identifier: BSD-2-Clause */ #pragma once +#include #include +#include +#include namespace Web::Gamepad { // https://w3c.github.io/gamepad/#dom-gamepad -class Gamepad : public Bindings::PlatformObject { +class Gamepad final : public Bindings::PlatformObject { WEB_PLATFORM_OBJECT(Gamepad, Bindings::PlatformObject); GC_DECLARE_ALLOCATOR(Gamepad); +public: + static GC::Ref create(JS::Realm&, SDL_JoystickID); + + SDL_JoystickID sdl_joystick_id() const { return m_sdl_joystick_id; } + SDL_Gamepad* sdl_gamepad() const { return m_sdl_gamepad; } + + Utf16String const& id() const { return m_id; } + + size_t index() const { return m_index; } + + bool connected() const { return m_connected; } + void set_connected(Badge, bool); + + HighResolutionTime::DOMHighResTimeStamp timestamp() const { return m_timestamp; } + void set_timestamp(Badge, HighResolutionTime::DOMHighResTimeStamp); + + bool exposed() const { return m_exposed; } + void set_exposed(Badge, bool); + + Bindings::GamepadMappingType mapping() const { return m_mapping; } + + Vector const& axes() const { return m_axes; } + Vector> const& buttons() const { return m_buttons; } + + GC::Ref vibration_actuator() const; + + void update_gamepad_state(Badge); + private: - explicit Gamepad(JS::Realm&); + explicit Gamepad(JS::Realm&, SDL_JoystickID); virtual void initialize(JS::Realm&) override; + virtual void visit_edges(Cell::Visitor&) override; + virtual void finalize() override; + + void select_a_mapping(); + void initialize_axes(); + void initialize_buttons(); + + void map_and_normalize_axes(); + void map_and_normalize_buttons(); + + bool contains_gamepad_user_gesture(); + + // https://w3c.github.io/gamepad/#dom-gamepad-id + // An identification string for the gamepad. This string identifies the brand or style of connected gamepad device. + // The exact format of the id string is left unspecified. It is RECOMMENDED that the user agent select a string + // that identifies the product without uniquely identifying the device. For example, a USB gamepad may be + // identified by its idVendor and idProduct values. Unique identifiers like serial numbers or Bluetooth device + // addresses MUST NOT be included in the id string. + Utf16String m_id; + + // https://w3c.github.io/gamepad/#dom-gamepad-index + // The index of the gamepad in the Navigator. When multiple gamepads are connected to a user agent, indices MUST be + // assigned on a first-come, first-serve basis, starting at zero. If a gamepad is disconnected, previously assigned + // indices MUST NOT be reassigned to gamepads that continue to be connected. However, if a gamepad is disconnected, + // and subsequently the same or a different gamepad is then connected, the lowest previously used index MUST be + // reused. + size_t m_index { 0 }; + + // https://w3c.github.io/gamepad/#dfn-connected + // A flag indicating that the device is connected to the system + bool m_connected { false }; + + // https://w3c.github.io/gamepad/#dfn-timestamp + // The last time data for this Gamepad was updated + HighResolutionTime::DOMHighResTimeStamp m_timestamp { 0.0 }; + + // https://w3c.github.io/gamepad/#dfn-axes + // A sequence of double values representing the current state of axes exposed by this device. + // https://w3c.github.io/gamepad/#dom-gamepad-axes + // Array of values for all axes of the gamepad. All axis values MUST be linearly normalized to the range [-1 .. 1]. + // If the controller is perpendicular to the ground with the directional stick pointing up, -1 SHOULD correspond to + // "forward" or "left", and 1 SHOULD correspond to "backward" or "right". Axes that are drawn from a 2D input + // device SHOULD appear next to each other in the axes array, X then Y. It is RECOMMENDED that axes appear in + // decreasing order of importance, such that element 0 and 1 typically represent the X and Y axis of a directional + // stick. The same object MUST be returned until the user agent needs to return different values (or values in a + // different order). + // FIXME: Our current FrozenArray implementation only supports returning new objects everytime. + Vector m_axes; + + // https://w3c.github.io/gamepad/#dfn-axismapping + // Mapping from unmapped axis index to an index in the axes array + HashMap m_axis_mapping; + + // https://w3c.github.io/gamepad/#dfn-axisminimums + // A list containing the minimum logical value for each axis + // NOTE: While the Gamepad API internally uses u32 to represent raw axis values, SDL uses i16 for axes. + Vector m_axis_minimums; + + // https://w3c.github.io/gamepad/#dfn-axismaximums + // A list containing the maximum logical value for each axis + // NOTE: While the Gamepad API internally uses u32 to represent raw axis values, SDL uses i16 for axes. + Vector m_axis_maximums; + + // https://w3c.github.io/gamepad/#dfn-buttons + // A sequence of GamepadButton objects representing the current state of buttons exposed by this device + // Array of button states for all buttons of the gamepad. It is RECOMMENDED that buttons appear in decreasing + // importance such that the primary button, secondary button, tertiary button, and so on appear as elements 0, 1, + // 2, ... in the buttons array. The same object MUST be returned until the user agent needs to return different + // values (or values in a different order). + // FIXME: Our current FrozenArray implementation only supports returning new objects everytime. + Vector> m_buttons; + + // https://w3c.github.io/gamepad/#dfn-buttonmapping + // Mapping from unmapped button index to an index in the buttons array + HashMap m_button_mapping; + + // https://w3c.github.io/gamepad/#dfn-buttonminimums + // A list containing the minimum logical value for each button. + // NOTE: While the Gamepad API internally uses u32 to represent raw button values, SDL uses bool for buttons and + // i16 for axes. The left and right triggers are buttons in the Gamepad API. + Vector m_button_minimums; + + // https://w3c.github.io/gamepad/#dfn-buttonmaximums + // A list containing the maximum logical value for each button + Vector m_button_maximums; + + // https://w3c.github.io/gamepad/#dfn-exposed + // A flag indicating that the Gamepad object has been exposed to script + bool m_exposed { false }; + + // https://w3c.github.io/gamepad/#dfn-vibrationactuator + GC::Ptr m_vibration_actuator; + + // https://w3c.github.io/gamepad/#dom-gamepad-mapping + // The mapping in use for this device. If the user agent has knowledge of the layout of the device, then it SHOULD + // indicate that a mapping is in use by setting mapping to the corresponding GamepadMappingType value. + Bindings::GamepadMappingType m_mapping { Bindings::GamepadMappingType::Standard }; + + SDL_JoystickID m_sdl_joystick_id { 0 }; + SDL_Gamepad* m_sdl_gamepad { nullptr }; }; } diff --git a/Libraries/LibWeb/Gamepad/Gamepad.idl b/Libraries/LibWeb/Gamepad/Gamepad.idl index d5fa34e6bd4..5073de0184a 100644 --- a/Libraries/LibWeb/Gamepad/Gamepad.idl +++ b/Libraries/LibWeb/Gamepad/Gamepad.idl @@ -1,15 +1,26 @@ +#import +#import +#import + +// https://w3c.github.io/gamepad/#dom-gamepadmappingtype +enum GamepadMappingType { + "", + "standard", + "xr-standard", +}; + // https://w3c.github.io/gamepad/#dom-gamepad [Exposed=Window] interface Gamepad { - [FIXME] readonly attribute DOMString id; - [FIXME] readonly attribute long index; - [FIXME] readonly attribute boolean connected; - [FIXME] readonly attribute DOMHighResTimeStamp timestamp; - [FIXME] readonly attribute GamepadMappingType mapping; - [FIXME] readonly attribute FrozenArray axes; - [FIXME] readonly attribute FrozenArray buttons; + readonly attribute Utf16DOMString id; + readonly attribute long index; + readonly attribute boolean connected; + readonly attribute DOMHighResTimeStamp timestamp; + readonly attribute GamepadMappingType mapping; + readonly attribute FrozenArray axes; + readonly attribute FrozenArray buttons; [FIXME] readonly attribute FrozenArray touches; - [FIXME, SameObject] readonly attribute GamepadHapticActuator vibrationActuator; + [SameObject] readonly attribute GamepadHapticActuator vibrationActuator; }; // https://w3c.github.io/gamepad/#idl-def-navigator-partial-1 diff --git a/Libraries/LibWeb/Gamepad/GamepadButton.cpp b/Libraries/LibWeb/Gamepad/GamepadButton.cpp new file mode 100644 index 00000000000..3254d8e52d5 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadButton.cpp @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include + +namespace Web::Gamepad { + +GC_DEFINE_ALLOCATOR(GamepadButton); + +GamepadButton::GamepadButton(JS::Realm& realm) + : Bindings::PlatformObject(realm) +{ +} + +GamepadButton::~GamepadButton() = default; + +void GamepadButton::initialize(JS::Realm& realm) +{ + WEB_SET_PROTOTYPE_FOR_INTERFACE(GamepadButton); + Base::initialize(realm); +} + +void GamepadButton::set_pressed(Badge, bool value) +{ + m_pressed = value; +} + +void GamepadButton::set_touched(Badge, bool value) +{ + m_touched = value; +} + +void GamepadButton::set_value(Badge, double value) +{ + m_value = value; +} + +} diff --git a/Libraries/LibWeb/Gamepad/GamepadButton.h b/Libraries/LibWeb/Gamepad/GamepadButton.h new file mode 100644 index 00000000000..273c3cebb39 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadButton.h @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +namespace Web::Gamepad { + +class GamepadButton final : public Bindings::PlatformObject { + WEB_PLATFORM_OBJECT(GamepadButton, Bindings::PlatformObject); + GC_DECLARE_ALLOCATOR(GamepadButton); + +public: + virtual ~GamepadButton() override; + + bool pressed() const { return m_pressed; } + bool touched() const { return m_touched; } + double value() const { return m_value; } + + void set_pressed(Badge, bool); + void set_touched(Badge, bool); + void set_value(Badge, double); + +private: + GamepadButton(JS::Realm&); + + virtual void initialize(JS::Realm&) override; + + // https://w3c.github.io/gamepad/#dfn-pressed + // A flag indicating that the button is pressed + bool m_pressed { false }; + + // https://w3c.github.io/gamepad/#dfn-touched + // A flag indicating that the button is touched + bool m_touched { false }; + + // https://w3c.github.io/gamepad/#dfn-value + // A double representing the button value scaled to the range [0 .. 1] + double m_value { 0.0 }; +}; + +} diff --git a/Libraries/LibWeb/Gamepad/GamepadButton.idl b/Libraries/LibWeb/Gamepad/GamepadButton.idl new file mode 100644 index 00000000000..c8ac8f2ee7c --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadButton.idl @@ -0,0 +1,7 @@ +// https://w3c.github.io/gamepad/#dom-gamepadbutton +[Exposed=Window] +interface GamepadButton { + readonly attribute boolean pressed; + readonly attribute boolean touched; + readonly attribute double value; +}; diff --git a/Libraries/LibWeb/Gamepad/GamepadEvent.cpp b/Libraries/LibWeb/Gamepad/GamepadEvent.cpp new file mode 100644 index 00000000000..e9ef15dcea5 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadEvent.cpp @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include + +namespace Web::Gamepad { + +GC_DEFINE_ALLOCATOR(GamepadEvent); + +WebIDL::ExceptionOr> GamepadEvent::construct_impl(JS::Realm& realm, FlyString const& event_name, GamepadEventInit const& gamepad_event_init) +{ + return realm.create(realm, event_name, gamepad_event_init); +} + +GamepadEvent::GamepadEvent(JS::Realm& realm, FlyString const& event_name, GamepadEventInit const& event_init) + : DOM::Event(realm, event_name, event_init) + , m_gamepad(*event_init.gamepad) +{ +} + +GamepadEvent::~GamepadEvent() = default; + +void GamepadEvent::initialize(JS::Realm& realm) +{ + WEB_SET_PROTOTYPE_FOR_INTERFACE(GamepadEvent); + Base::initialize(realm); +} + +void GamepadEvent::visit_edges(Cell::Visitor& visitor) +{ + Base::visit_edges(visitor); + visitor.visit(m_gamepad); +} + +} diff --git a/Libraries/LibWeb/Gamepad/GamepadEvent.h b/Libraries/LibWeb/Gamepad/GamepadEvent.h new file mode 100644 index 00000000000..5f6ca6abbb7 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadEvent.h @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include + +namespace Web::Gamepad { + +struct GamepadEventInit : public DOM::EventInit { + GC::Root gamepad; +}; + +class GamepadEvent final : public DOM::Event { + WEB_PLATFORM_OBJECT(GamepadEvent, DOM::Event); + GC_DECLARE_ALLOCATOR(GamepadEvent); + +public: + [[nodiscard]] static WebIDL::ExceptionOr> construct_impl(JS::Realm&, FlyString const& event_name, GamepadEventInit const&); + + virtual ~GamepadEvent() override; + + GC::Ref gamepad() const { return m_gamepad; } + +private: + GamepadEvent(JS::Realm&, FlyString const& event_name, GamepadEventInit const& event_init); + virtual void initialize(JS::Realm&) override; + virtual void visit_edges(Cell::Visitor&) override; + + GC::Ref m_gamepad; +}; + +} diff --git a/Libraries/LibWeb/Gamepad/GamepadEvent.idl b/Libraries/LibWeb/Gamepad/GamepadEvent.idl new file mode 100644 index 00000000000..1ef43b4069e --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadEvent.idl @@ -0,0 +1,14 @@ +#import +#import + +// https://w3c.github.io/gamepad/#dom-gamepadevent +[Exposed=Window] +interface GamepadEvent : Event { + constructor(DOMString type, GamepadEventInit eventInitDict); + [SameObject] readonly attribute Gamepad gamepad; +}; + +// https://w3c.github.io/gamepad/#dom-gamepadeventinit +dictionary GamepadEventInit : EventInit { + required Gamepad gamepad; +}; diff --git a/Libraries/LibWeb/Gamepad/GamepadHapticActuator.cpp b/Libraries/LibWeb/Gamepad/GamepadHapticActuator.cpp new file mode 100644 index 00000000000..53986eb10ac --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadHapticActuator.cpp @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Web::Gamepad { + +GC_DEFINE_ALLOCATOR(GamepadHapticActuator); + +// FIXME: What is a valid duration and startDelay? The spec doesn't define that. +// Safari: clamps any duration above 5000ms to 5000ms and doesn't seem to clamp or reject any startDelay. +// Chrome: rejects if duration + startDelay > 5000ms. +// Firefox doesn't support vibration at the time of writing. +static constexpr u64 MAX_VIBRATION_DURATION = 5000; + +// https://w3c.github.io/gamepad/#dfn-constructing-a-gamepadhapticactuator +GC::Ref GamepadHapticActuator::create(JS::Realm& realm, GC::Ref gamepad) +{ + auto& window = as(realm.global_object()); + auto document_became_hidden_observer = realm.create(realm, window.associated_document()); + + // 1. Let gamepadHapticActuator be a newly created GamepadHapticActuator instance. + auto gamepad_haptic_actuator = realm.create(realm, gamepad, document_became_hidden_observer); + + // 2. Let supportedEffectsList be an empty list. + Vector supported_effects_list; + + // 3. For each enum value type of GamepadHapticEffectType, if the user agent can send a command to initiate effects + // of that type on that actuator, append type to supportedEffectsList. + SDL_PropertiesID sdl_gamepad_properties = SDL_GetGamepadProperties(gamepad->sdl_gamepad()); + + if (SDL_GetBooleanProperty(sdl_gamepad_properties, SDL_PROP_GAMEPAD_CAP_RUMBLE_BOOLEAN, /* default_value= */ false)) + supported_effects_list.append(Bindings::GamepadHapticEffectType::DualRumble); + + if (SDL_GetBooleanProperty(sdl_gamepad_properties, SDL_PROP_GAMEPAD_CAP_TRIGGER_RUMBLE_BOOLEAN, /* default_value= */ false)) + supported_effects_list.append(Bindings::GamepadHapticEffectType::TriggerRumble); + + // 4. Set gamepadHapticActuator.[[effects]] to supportedEffectsList. + gamepad_haptic_actuator->m_effects = move(supported_effects_list); + + return gamepad_haptic_actuator; +} + +GamepadHapticActuator::GamepadHapticActuator(JS::Realm& realm, GC::Ref gamepad, GC::Ref document_became_hidden_observer) + : Bindings::PlatformObject(realm) + , m_gamepad(gamepad) + , m_document_became_hidden_observer(document_became_hidden_observer) +{ + m_document_became_hidden_observer->set_document_visibility_state_observer([this](HTML::VisibilityState visibility_state) { + if (visibility_state == HTML::VisibilityState::Hidden) + document_became_hidden(); + }); +} + +GamepadHapticActuator::~GamepadHapticActuator() = default; + +void GamepadHapticActuator::initialize(JS::Realm& realm) +{ + WEB_SET_PROTOTYPE_FOR_INTERFACE(GamepadHapticActuator); + Base::initialize(realm); +} + +void GamepadHapticActuator::visit_edges(Cell::Visitor& visitor) +{ + Base::visit_edges(visitor); + visitor.visit(m_gamepad); + visitor.visit(m_document_became_hidden_observer); + visitor.visit(m_playing_effect_promise); + visitor.visit(m_playing_effect_timer); +} + +// https://w3c.github.io/gamepad/#dfn-valid-effect +static bool is_valid_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params) +{ + // 1. Given the value of GamepadHapticEffectType type, switch on: + // "dual-rumble" + // If params does not describe a valid dual-rumble effect, return false. + // "trigger-rumble" + // If params does not describe a valid trigger-rumble effect, return false. + // 2. Return true + switch (type) { + case Bindings::GamepadHapticEffectType::DualRumble: + // https://w3c.github.io/gamepad/#dfn-valid-dual-rumble-effect + // Given GamepadEffectParameters params, a valid dual-rumble effect must have a valid duration, a valid + // startDelay, and both the strongMagnitude and the weakMagnitude must be in the range [0 .. 1]. + if (Checked::addition_would_overflow(params.duration, params.start_delay)) + return false; + + if (params.duration + params.start_delay > MAX_VIBRATION_DURATION) + return false; + + if (params.strong_magnitude < 0.0 || params.strong_magnitude > 1.0) + return false; + + if (params.weak_magnitude < 0.0 || params.weak_magnitude > 1.0) + return false; + + return true; + case Bindings::GamepadHapticEffectType::TriggerRumble: + // https://w3c.github.io/gamepad/#dfn-valid-trigger-rumble-effect + // Given GamepadEffectParameters params, a valid trigger-rumble effect must have a valid duration, a valid + // startDelay, and the strongMagnitude, weakMagnitude, leftTrigger, and rightTrigger must be in the range + // [0 .. 1]. + if (Checked::addition_would_overflow(params.duration, params.start_delay)) + return false; + + if (params.duration + params.start_delay > MAX_VIBRATION_DURATION) + return false; + + if (params.strong_magnitude < 0.0 || params.strong_magnitude > 1.0) + return false; + + if (params.weak_magnitude < 0.0 || params.weak_magnitude > 1.0) + return false; + + if (params.left_trigger < 0.0 || params.left_trigger > 1.0) + return false; + + if (params.right_trigger < 0.0 || params.right_trigger > 1.0) + return false; + + return true; + } + + VERIFY_NOT_REACHED(); +} + +// https://w3c.github.io/gamepad/#dom-gamepadhapticactuator-playeffect +GC::Ref GamepadHapticActuator::play_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params) +{ + auto& realm = this->realm(); + + // 1. If params does not describe a valid effect of type type, return a promise rejected with a TypeError. + if (!is_valid_effect(type, params)) + return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::SimpleException { WebIDL::SimpleExceptionType::TypeError, "Invalid effect"_string }); + + // 2. Let document be the current settings object's relevant global object's associated Document. + auto& window = as(HTML::current_principal_settings_object().global_object()); + auto& document = window.associated_document(); + + // 3. If document is null or document is not fully active or document's visibility state is "hidden", return a + // promise rejected with an "InvalidStateError" DOMException. + if (!document.is_fully_active() || document.visibility_state_value() == HTML::VisibilityState::Hidden) + return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::InvalidStateError::create(realm, "Haptics are not allowed in a hidden document"_utf16)); + + // 4. If this.[[playingEffectPromise]] is not null: + if (m_playing_effect_promise) { + // 1. Let effectPromise be this.[[playingEffectPromise]]. + auto effect_promise = GC::Ref { *m_playing_effect_promise }; + + // 2. Set this.[[playingEffectPromise]] to null. + m_playing_effect_promise = nullptr; + clear_playing_effect_timers(); + + // 3. Queue a global task on the gamepad task source with the relevant global object of this to resolve + // effectPromise with "preempted". + HTML::queue_global_task(HTML::Task::Source::Gamepad, HTML::relevant_global_object(*this), GC::create_function(realm.heap(), [effect_promise, &realm] { + HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes }; + + auto preempted_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Preempted)); + WebIDL::resolve_promise(realm, effect_promise, preempted_string); + })); + } + + // 5. If this GamepadHapticActuator cannot play effects with type type, return a promise rejected with reason + // NotSupportedError. + // https://w3c.github.io/gamepad/#ref-for-dfn-play-effects-with-type-1 + // A GamepadHapticActuator can play effects with type type if type can be found in the [[effects]] list. + if (!m_effects.contains_slow(type)) + return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::NotSupportedError::create(realm, "Gamepad does not support this effect"_utf16)); + + // 6. Let [[playingEffectPromise]] be a new promise. + m_playing_effect_promise = WebIDL::create_promise(realm); + + // 7. Let playEffectTimestamp be the current high resolution time given the document's relevant global object. + // NOTE: Unused. + + // 8. Do the following steps in parallel: + Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [this, &realm, type, params] { + // 1. Issue a haptic effect to the actuator with type, params, and the playEffectTimestamp. + issue_haptic_effect(type, params, GC::create_function(realm.heap(), [this, &realm] { + // 2. When the effect completes, if this.[[playingEffectPromise]] is not null, queue a global task on the + // gamepad task source with the relevant global object of this to run the following steps: + if (m_playing_effect_promise) { + HTML::queue_global_task(HTML::Task::Source::Gamepad, HTML::relevant_global_object(*this), GC::create_function(realm.heap(), [this, &realm] { + // 1. If this.[[playingEffectPromise]] is null, abort these steps. + if (!m_playing_effect_promise) + return; + + HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes }; + + // 2. Resolve this.[[playingEffectPromise]] with "complete". + auto complete_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Complete)); + WebIDL::resolve_promise(realm, *m_playing_effect_promise, complete_string); + + // 3. Set this.[[playingEffectPromise]] to null. + m_playing_effect_promise = nullptr; + clear_playing_effect_timers(); + })); + } + })); + })); + + // 9. Return [[playingEffectPromise]]. + VERIFY(m_playing_effect_promise); + return *m_playing_effect_promise; +} + +// https://w3c.github.io/gamepad/#dom-gamepadhapticactuator-reset +GC::Ref GamepadHapticActuator::reset() +{ + auto& realm = this->realm(); + + // 1. Let document be the current settings object's relevant global object's associated Document. + auto& window = as(HTML::current_principal_settings_object().global_object()); + auto& document = window.associated_document(); + + // 2. If document is null or document is not fully active or document's visibility state is "hidden", return a + // promise rejected with an "InvalidStateError" DOMException. + if (!document.is_fully_active() || document.visibility_state_value() == HTML::VisibilityState::Hidden) + return WebIDL::create_rejected_promise_from_exception(realm, WebIDL::InvalidStateError::create(realm, "Haptics are not allowed in a hidden document"_utf16)); + + // 3. Let resetResultPromise be a new promise. + auto reset_result_promise = WebIDL::create_promise(realm); + + // 4. If this.[[playingEffectPromise]] is not null, do the following steps in parallel: + if (m_playing_effect_promise) { + Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [this, &realm, reset_result_promise] { + // 1. Let effectPromise be this.[[playingEffectPromise]]. + auto effect_promise = m_playing_effect_promise; + + // 2. Stop haptic effects on this's gamepad's actuator. + bool stopped_all = stop_haptic_effects(); + + // 3. If the effect has been successfully stopped, do: + if (stopped_all) { + // 1. If effectPromise and this.[[playingEffectPromise]] are still the same, + // set this.[[playingEffectPromise]] to null. + if (effect_promise == m_playing_effect_promise) + m_playing_effect_promise = nullptr; + + // 2. Queue a global task on the gamepad task source with the relevant global object of this to resolve + // effectPromise with "preempted". + // AD-HOC: With doing this in parallel, there is a chance effect_promise is null. Don't try to resolve it + // if so. + if (effect_promise) { + HTML::queue_global_task(HTML::Task::Source::Gamepad, HTML::relevant_global_object(*this), GC::create_function(realm.heap(), [&realm, effect_promise = GC::Ref { *effect_promise }] { + HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes }; + + auto preempted_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Preempted)); + WebIDL::resolve_promise(realm, effect_promise, preempted_string); + })); + } + } + + // 4. Resolve resetResultPromise with "complete" + auto complete_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Complete)); + WebIDL::resolve_promise(realm, reset_result_promise, complete_string); + })); + } + + // 5. Return resetResultPromise. + return reset_result_promise; +} + +// https://w3c.github.io/gamepad/#handling-visibility-change +void GamepadHapticActuator::document_became_hidden() +{ + auto& realm = this->realm(); + + // When the document's visibility state becomes "hidden", run these steps for each GamepadHapticActuator actuator: + // 1. If actuator.[[playingEffectPromise]] is null, abort these steps. + if (!m_playing_effect_promise) + return; + + // 2. Queue a global task on the gamepad task source with the relevant global object of actuator to run the + // following steps: + auto& global = HTML::relevant_global_object(*this); + HTML::queue_global_task(HTML::Task::Source::Gamepad, global, GC::create_function(global.heap(), [this, &realm] { + // 1. If actuator.[[playingEffectPromise]] is null, abort these steps. + if (!m_playing_effect_promise) + return; + + HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes }; + + // 2. Resolve actuator.[[playingEffectPromise]] with "preempted". + auto preempted_string = JS::PrimitiveString::create(realm.vm(), Bindings::idl_enum_to_string(Bindings::GamepadHapticsResult::Preempted)); + WebIDL::resolve_promise(realm, *m_playing_effect_promise, preempted_string); + + // 3. Set actuator.[[playingEffectPromise]] to null. + m_playing_effect_promise = nullptr; + clear_playing_effect_timers(); + })); + + // 3. Stop haptic effects on actuator. + stop_haptic_effects(); +} + +// https://w3c.github.io/gamepad/#dfn-issue-a-haptic-effect +void GamepadHapticActuator::issue_haptic_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params, GC::Ref> on_complete) +{ + auto& heap = this->heap(); + + // To issue a haptic effect on an actuator, the user agent MUST send a command to the device to render an effect + // of type and try to make it use the provided params. The user agent SHOULD use the provided playEffectTimestamp + // for more precise playback timing when params.startDelay is not 0.0. The user agent MAY modify the effect to + // increase compatibility. For example, an effect intended for a rumble motor may be transformed into a + // waveform-based effect for a device that supports waveform haptics but lacks rumble motors. + m_playing_effect_timer = Platform::Timer::create_single_shot(heap, static_cast(params.start_delay), GC::create_function(heap, [this, type, params, on_complete, &heap] { + switch (type) { + case Bindings::GamepadHapticEffectType::DualRumble: + SDL_RumbleGamepad(m_gamepad->sdl_gamepad(), params.strong_magnitude * NumericLimits::max(), params.weak_magnitude * NumericLimits::max(), params.duration); + break; + case Bindings::GamepadHapticEffectType::TriggerRumble: + SDL_RumbleGamepadTriggers(m_gamepad->sdl_gamepad(), params.left_trigger * NumericLimits::max(), params.right_trigger * NumericLimits::max(), params.duration); + break; + } + + m_playing_effect_timer = Platform::Timer::create_single_shot(heap, params.duration, GC::create_function(heap, [on_complete] { + on_complete->function()(); + })); + + m_playing_effect_timer->start(); + })); + + m_playing_effect_timer->start(); +} + +// https://w3c.github.io/gamepad/#dfn-stop-haptic-effects +bool GamepadHapticActuator::stop_haptic_effects() +{ + // To stop haptic effects on an actuator, the user agent MUST send a command to the device to abort any effects + // currently being played. If a haptic effect was interrupted, the actuator SHOULD return to a motionless state + // as quickly as possible. + bool stopped_all = true; + + // https://wiki.libsdl.org/SDL3/SDL_RumbleGamepad + // "Each call to this function cancels any previous rumble effect, and calling it with 0 intensity stops any + // rumbling." + if (m_effects.contains_slow(Bindings::GamepadHapticEffectType::DualRumble)) { + bool success = SDL_RumbleGamepad(m_gamepad->sdl_gamepad(), 0, 0, 0); + if (!success) + stopped_all = false; + } + + // https://wiki.libsdl.org/SDL3/SDL_RumbleGamepadTriggers + // "Each call to this function cancels any previous trigger rumble effect, and calling it with 0 intensity stops + // any rumbling." + if (m_effects.contains_slow(Bindings::GamepadHapticEffectType::TriggerRumble)) { + bool success = SDL_RumbleGamepadTriggers(m_gamepad->sdl_gamepad(), 0, 0, 0); + if (!success) + stopped_all = false; + } + + return stopped_all; +} + +void GamepadHapticActuator::clear_playing_effect_timers() +{ + if (m_playing_effect_timer) { + m_playing_effect_timer->stop(); + m_playing_effect_timer = nullptr; + } +} + +} diff --git a/Libraries/LibWeb/Gamepad/GamepadHapticActuator.h b/Libraries/LibWeb/Gamepad/GamepadHapticActuator.h new file mode 100644 index 00000000000..77bfc50dee7 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadHapticActuator.h @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025, Luke Wilde + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include + +namespace Web::Gamepad { + +// https://w3c.github.io/gamepad/#dom-gamepadeffectparameters +struct GamepadEffectParameters { + // https://w3c.github.io/gamepad/#dom-gamepadeffectparameters-duration + // duration sets the duration of the vibration effect in milliseconds. + u64 duration { 0 }; + + // https://w3c.github.io/gamepad/#dom-gamepadeffectparameters-startdelay + // startDelay sets the duration of the delay after playEffect() is called until vibration is started, in + // milliseconds. During the delay interval, the actuator SHOULD NOT vibrate. + u64 start_delay { 0 }; + + // https://w3c.github.io/gamepad/#dom-gamepadeffectparameters-strongmagnitude + // The vibration magnitude for the low frequency rumble in a "dual-rumble" or "trigger-rumble" effect. + double strong_magnitude { 0.0 }; + + // https://w3c.github.io/gamepad/#dom-gamepadeffectparameters-weakmagnitude + // The vibration magnitude for the high frequency rumble in a "dual-rumble" or "trigger-rumble" effect. + double weak_magnitude { 0.0 }; + + // https://w3c.github.io/gamepad/#dom-gamepadeffectparameters-lefttrigger + // The vibration magnitude for the bottom left front button (canonical index 6) rumble in a "trigger-rumble" + // effect. + double left_trigger { 0.0 }; + + // https://w3c.github.io/gamepad/#dom-gamepadeffectparameters-righttrigger + // The vibration magnitude for the bottom right front button (canonical index 7) rumble in a "trigger-rumble" + // effect. + double right_trigger { 0.0 }; +}; + +class GamepadHapticActuator final : public Bindings::PlatformObject { + WEB_PLATFORM_OBJECT(GamepadHapticActuator, Bindings::PlatformObject); + GC_DECLARE_ALLOCATOR(GamepadHapticActuator); + +public: + static GC::Ref create(JS::Realm&, GC::Ref); + + virtual ~GamepadHapticActuator() override; + + Vector const& effects() const { return m_effects; } + + GC::Ref play_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params); + GC::Ref reset(); + +private: + GamepadHapticActuator(JS::Realm&, GC::Ref, GC::Ref); + virtual void initialize(JS::Realm&) override; + virtual void visit_edges(Cell::Visitor&) override; + + void document_became_hidden(); + void issue_haptic_effect(Bindings::GamepadHapticEffectType type, GamepadEffectParameters const& params, GC::Ref> on_complete); + bool stop_haptic_effects(); + void clear_playing_effect_timers(); + + GC::Ref m_gamepad; + GC::Ref m_document_became_hidden_observer; + + // https://w3c.github.io/gamepad/#dfn-effects + // Represents the effects supported by the actuator. + Vector m_effects; + + // https://w3c.github.io/gamepad/#dfn-playingeffectpromise + // The Promise to play some effect, or null if no effect is playing. + GC::Ptr m_playing_effect_promise; + GC::Ptr m_playing_effect_timer; +}; + +} diff --git a/Libraries/LibWeb/Gamepad/GamepadHapticActuator.idl b/Libraries/LibWeb/Gamepad/GamepadHapticActuator.idl new file mode 100644 index 00000000000..2de47abd0f7 --- /dev/null +++ b/Libraries/LibWeb/Gamepad/GamepadHapticActuator.idl @@ -0,0 +1,29 @@ +// https://w3c.github.io/gamepad/#gamepadhapticsresult-enum +enum GamepadHapticsResult { + "complete", + "preempted" +}; + +// https://w3c.github.io/gamepad/#gamepadhapticeffecttype-enum +enum GamepadHapticEffectType { + "dual-rumble", + "trigger-rumble" +}; + +// https://w3c.github.io/gamepad/#gamepadeffectparameters-dictionary +dictionary GamepadEffectParameters { + unsigned long long duration = 0; + unsigned long long startDelay = 0; + double strongMagnitude = 0.0; + double weakMagnitude = 0.0; + double leftTrigger = 0.0; + double rightTrigger = 0.0; +}; + +// https://w3c.github.io/gamepad/#gamepadhapticactuator-interface +[Exposed=Window] +interface GamepadHapticActuator { + [SameObject] readonly attribute FrozenArray effects; + Promise playEffect(GamepadHapticEffectType type, optional GamepadEffectParameters params = {}); + Promise reset(); +}; diff --git a/Libraries/LibWeb/Gamepad/NavigatorGamepad.cpp b/Libraries/LibWeb/Gamepad/NavigatorGamepad.cpp index e0a43245024..1f3f383f28e 100644 --- a/Libraries/LibWeb/Gamepad/NavigatorGamepad.cpp +++ b/Libraries/LibWeb/Gamepad/NavigatorGamepad.cpp @@ -1,13 +1,18 @@ /* * Copyright (c) 2025, Jelle Raaijmakers + * Copyright (c) 2025, Luke Wilde * * SPDX-License-Identifier: BSD-2-Clause */ #include +#include #include +#include #include #include +#include +#include namespace Web::Gamepad { @@ -15,35 +20,232 @@ namespace Web::Gamepad { WebIDL::ExceptionOr>> NavigatorGamepadPartial::get_gamepads() { auto& navigator = as(*this); + auto& realm = navigator.realm(); + auto& heap = realm.heap(); - // FIXME: 1. Let doc be the current global object's associated Document. + // 1. Let doc be the current global object's associated Document. + auto& window = as(HTML::current_principal_global_object()); + auto& document = window.associated_document(); - // FIXME: 2. If doc is null or doc is not fully active, then return an empty list. + // 2. If doc is null or doc is not fully active, then return an empty list. + GC::RootVector> gamepads { heap }; + if (!document.is_fully_active()) + return gamepads; - // FIXME: 3. If doc is not allowed to use the "gamepad" permission, then throw a "SecurityError" DOMException and abort - // these steps. + // 3. If doc is not allowed to use the "gamepad" permission, then throw a "SecurityError" DOMException and abort these steps. + if (!document.is_allowed_to_use_feature(DOM::PolicyControlledFeature::Gamepad)) + return WebIDL::SecurityError::create(realm, "Not allowed to use gamepads"_utf16); - // FIXME: 4. If this.[[hasGamepadGesture]] is false, then return an empty list. + // 4. If this.[[hasGamepadGesture]] is false, then return an empty list. + if (!m_has_gamepad_gesture) + return gamepads; - // FIXME: 5. Let now be the current high resolution time given the current global object. + // 5. Let now be the current high resolution time given the current global object. + auto now = HighResolutionTime::current_high_resolution_time(window); - // FIXME: 6. Let gamepads be an empty list. + // 6. Let gamepads be an empty list. + // NOTE: Already done. - // FIXME: 7. For each gamepad of this.[[gamepads]]: - { - // FIXME: 1. If gamepad is not null and gamepad.[[exposed]] is false: - if (false) { - // FIXME: 1. Set gamepad.[[exposed]] to true. + // 7. For each gamepad of this.[[gamepads]]: + for (auto gamepad : m_gamepads) { + // 1. If gamepad is not null and gamepad.[[exposed]] is false: + if (gamepad && !gamepad->exposed()) { + // 1. Set gamepad.[[exposed]] to true. + gamepad->set_exposed({}, true); - // FIXME: 2. Set gamepad.[[timestamp]] to now. + // 2. Set gamepad.[[timestamp]] to now. + gamepad->set_timestamp({}, now); } - // FIXME: 2. Append gamepad to gamepads. + // 2. Append gamepad to gamepads. + gamepads.append(gamepad); } - // FIXME: 8. Return gamepads. - dbgln("FIXME: Unimplemented NavigatorGamepadPartial::get_gamepads()"); - return GC::RootVector> { navigator.heap() }; + // 8. Return gamepads. + return gamepads; +} + +void NavigatorGamepadPartial::visit_edges(GC::Cell::Visitor& visitor) +{ + visitor.visit(m_gamepads); +} + +// https://w3c.github.io/gamepad/#dfn-selecting-an-unused-gamepad-index +size_t NavigatorGamepadPartial::select_an_unused_gamepad_index(Badge) +{ + // 2. Let maxGamepadIndex be the size of navigator.[[gamepads]] − 1. + // 3. For each gamepadIndex of the range from 0 to maxGamepadIndex: + for (size_t gamepad_index = 0; gamepad_index < m_gamepads.size(); ++gamepad_index) { + // 1. If navigator.[[gamepads]][gamepadIndex] is null, then return gamepadIndex. + if (!m_gamepads[gamepad_index]) + return gamepad_index; + } + + // 4. Append null to navigator.[[gamepads]]. + m_gamepads.append(nullptr); + + // 5. Return the size of navigator.[[gamepads]] − 1. + return m_gamepads.size() - 1; +} + +// https://w3c.github.io/gamepad/#event-gamepadconnected +void NavigatorGamepadPartial::handle_gamepad_connected(SDL_JoystickID sdl_joystick_id) +{ + // When a gamepad becomes available on the system, run the following steps: + if (m_available_gamepads.contains_slow(sdl_joystick_id)) + return; + + m_available_gamepads.append(sdl_joystick_id); + + // 1. Let document be the current global object's associated Document; otherwise null. + // FIXME: We can't use the current global object here, since it's not executing in a scripting context. + // NOTE: NavigatorGamepad is only available on Window. + // NOTE: document is never null. + auto& navigator = as(*this); + auto& realm = navigator.realm(); + auto& window = as(HTML::relevant_global_object(navigator)); + auto& document = window.associated_document(); + + // 2. If document is not null and is not allowed to use the "gamepad" permission, then abort these steps. + if (!document.is_allowed_to_use_feature(DOM::PolicyControlledFeature::Gamepad)) + return; + + // 3. Queue a global task on the gamepad task source with the current global object to perform the following steps: + HTML::queue_global_task(HTML::Task::Source::Gamepad, window, GC::create_function(realm.heap(), [&realm, &document, sdl_joystick_id] mutable { + // 1. Let gamepad be a new Gamepad representing the gamepad. + auto gamepad = Gamepad::create(realm, sdl_joystick_id); + + // 2. Let navigator be gamepad's relevant global object's Navigator object. + auto& gamepad_window = as(HTML::relevant_global_object(gamepad)); + auto navigator = gamepad_window.navigator(); + + // 3. Set navigator.[[gamepads]][gamepad.index] to gamepad. + navigator->m_gamepads[gamepad->index()] = gamepad; + + // 4. If navigator.[[hasGamepadGesture]] is true: + if (navigator->m_has_gamepad_gesture) { + // 1. Set gamepad.[[exposed]] to true. + gamepad->set_exposed({}, true); + + // 2. If document is not null and is fully active, then fire an event named gamepadconnected at gamepad's + // relevant global object using GamepadEvent with its gamepad attribute initialized to gamepad. + if (document.is_fully_active()) { + auto gamepad_connected_event_init = GamepadEventInit { + { + .bubbles = false, + .cancelable = false, + .composed = false, + }, + gamepad, + }; + auto gamepad_connected_event = MUST(GamepadEvent::construct_impl(realm, EventNames::gamepadconnected, gamepad_connected_event_init)); + gamepad_window.dispatch_event(gamepad_connected_event); + } + } + })); +} + +// https://w3c.github.io/gamepad/#dfn-receives-new-button-or-axis-input-values +void NavigatorGamepadPartial::handle_gamepad_updated(Badge, SDL_JoystickID sdl_joystick_id) +{ + // When the system receives new button or axis input values, run the following steps: + // 1. Let gamepad be the Gamepad object representing the device that received new button or axis input values. + auto gamepad = m_gamepads.find_if([&sdl_joystick_id](GC::Ptr gamepad) { + return gamepad && gamepad->sdl_joystick_id() == sdl_joystick_id; + }); + + if (gamepad.is_end()) + return; + + // 2. Queue a global task on the gamepad task source with gamepad's relevant global object to update gamepad state + // for gamepad. + auto& global = HTML::relevant_global_object(**gamepad); + HTML::queue_global_task(HTML::Task::Source::Gamepad, global, GC::create_function(global.heap(), [gamepad = GC::Ref { **gamepad }] { + gamepad->update_gamepad_state({}); + })); +} + +void NavigatorGamepadPartial::handle_gamepad_disconnected(Badge, SDL_JoystickID sdl_joystick_id) +{ + // When a gamepad becomes unavailable on the system, run the following steps: + m_available_gamepads.remove_first_matching([&sdl_joystick_id](SDL_JoystickID available_gamepad) { + return sdl_joystick_id == available_gamepad; + }); + + // 1. Let gamepad be the Gamepad representing the unavailable device. + auto gamepad = m_gamepads.find_if([&sdl_joystick_id](GC::Ptr gamepad) { + return gamepad && gamepad->sdl_joystick_id() == sdl_joystick_id; + }); + + if (gamepad.is_end()) + return; + + // 2. Queue a global task on the gamepad task source with gamepad's relevant global object to perform the + // following steps: + auto& window = as(HTML::relevant_global_object(**gamepad)); + HTML::queue_global_task(HTML::Task::Source::Gamepad, window, GC::create_function(window.heap(), [gamepad = GC::Ref { **gamepad }, &window] { + // 1. Set gamepad.[[connected]] to false. + gamepad->set_connected({}, false); + + // 2. Let document be gamepad's relevant global object's associated Document; otherwise null. + auto& document = window.associated_document(); + + // 3. If gamepad.[[exposed]] is true and document is not null and is fully active, then fire an event named + // gamepaddisconnected at gamepad's relevant global object using GamepadEvent with its gamepad attribute + // initialized to gamepad. + if (gamepad->exposed() && document.is_fully_active()) { + auto gamepad_disconnected_event_init = GamepadEventInit { + { + .bubbles = false, + .cancelable = false, + .composed = false, + }, + gamepad, + }; + auto gamepad_disconnected_event = MUST(GamepadEvent::construct_impl(window.realm(), EventNames::gamepaddisconnected, gamepad_disconnected_event_init)); + window.dispatch_event(gamepad_disconnected_event); + } + + // 4. Let navigator be gamepad's relevant global object's Navigator object. + auto navigator = window.navigator(); + + // 5. Set navigator.[[gamepads]][gamepad.index] to null. + navigator->m_gamepads[gamepad->index()] = nullptr; + + // 6. While navigator.[[gamepads]] is not empty and the last item of navigator.[[gamepads]] is null, remove the + // last item of navigator.[[gamepads]]. + while (!navigator->m_gamepads.is_empty() && navigator->m_gamepads.last() == nullptr) { + (void)navigator->m_gamepads.take_last(); + } + })); +} + +void NavigatorGamepadPartial::check_for_connected_gamepads() +{ + // "(SDL_JoystickID *) Returns a 0 terminated array of joystick instance IDs or NULL on failure; call + // SDL_GetError() for more information. This should be freed with SDL_free() when it is no longer needed." + int gamepad_count = 0; + SDL_JoystickID* connected_gamepads = SDL_GetGamepads(&gamepad_count); + if (!connected_gamepads) + return; + + for (int gamepad_index = 0; gamepad_index < gamepad_count; ++gamepad_index) { + handle_gamepad_connected(connected_gamepads[gamepad_index]); + } + + SDL_free(connected_gamepads); +} + +void NavigatorGamepadPartial::set_has_gamepad_gesture(Badge, bool value) +{ + m_has_gamepad_gesture = value; +} + +GC::RootVector> NavigatorGamepadPartial::gamepads(Badge) const +{ + auto& navigator = as(*this); + auto& realm = navigator.realm(); + return { realm.heap(), m_gamepads }; } } diff --git a/Libraries/LibWeb/Gamepad/NavigatorGamepad.h b/Libraries/LibWeb/Gamepad/NavigatorGamepad.h index 4d2e05122fc..74c92388fa7 100644 --- a/Libraries/LibWeb/Gamepad/NavigatorGamepad.h +++ b/Libraries/LibWeb/Gamepad/NavigatorGamepad.h @@ -11,16 +11,47 @@ #include #include +#include + namespace Web::Gamepad { class NavigatorGamepadPartial { public: WebIDL::ExceptionOr>> get_gamepads(); + size_t select_an_unused_gamepad_index(Badge); + + void handle_gamepad_connected(SDL_JoystickID sdl_joystick_id); + void handle_gamepad_updated(Badge, SDL_JoystickID sdl_joystick_id); + void handle_gamepad_disconnected(Badge, SDL_JoystickID sdl_joystick_id); + + bool has_gamepad_gesture() const { return m_has_gamepad_gesture; } + void set_has_gamepad_gesture(Badge, bool); + + GC::RootVector> gamepads(Badge) const; + +protected: + void visit_edges(GC::Cell::Visitor& visitor); + + void check_for_connected_gamepads(); + private: virtual ~NavigatorGamepadPartial() = default; friend class HTML::Navigator; + + // https://w3c.github.io/gamepad/#dfn-hasgamepadgesture + // A flag indicating that a gamepad user gesture has been observed + bool m_has_gamepad_gesture { false }; + + // https://w3c.github.io/gamepad/#dfn-gamepads + // Each Gamepad present at the index specified by its index attribute, or null for unassigned indices. + Vector> m_gamepads; + + // Non-standard attribute to know which gamepads are available to the system. This is used to prevent duplicate + // connections for the same gamepad ID (e.g. if the navigator object is initialized and checks for connected gamepads + // and also receives an SDL gamepad connected event) + Vector m_available_gamepads; }; } diff --git a/Libraries/LibWeb/HTML/AttributeNames.h b/Libraries/LibWeb/HTML/AttributeNames.h index 209f68c2c9c..b92ed77213a 100644 --- a/Libraries/LibWeb/HTML/AttributeNames.h +++ b/Libraries/LibWeb/HTML/AttributeNames.h @@ -179,6 +179,8 @@ namespace AttributeNames { __ENUMERATE_HTML_ATTRIBUTE(onfocusin, "onfocusin") \ __ENUMERATE_HTML_ATTRIBUTE(onfocusout, "onfocusout") \ __ENUMERATE_HTML_ATTRIBUTE(onformdata, "onformdata") \ + __ENUMERATE_HTML_ATTRIBUTE(ongamepadconnected, "ongamepadconnected") \ + __ENUMERATE_HTML_ATTRIBUTE(ongamepaddisconnected, "ongamepaddisconnected") \ __ENUMERATE_HTML_ATTRIBUTE(ongotpointercapture, "ongotpointercapture") \ __ENUMERATE_HTML_ATTRIBUTE(onhashchange, "onhashchange") \ __ENUMERATE_HTML_ATTRIBUTE(oninput, "oninput") \ diff --git a/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp b/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp index 99f070eff8a..b78f230c91f 100644 --- a/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp +++ b/Libraries/LibWeb/HTML/EventLoop/EventLoop.cpp @@ -296,6 +296,8 @@ void EventLoop::process_input_events() const page_client.report_finished_handling_input_event(event.page_id, EventResult::Dropped); page_client.report_finished_handling_input_event(event.page_id, result); } + + page.handle_sdl_input_events(); }; auto documents_of_traversable_navigables = documents_in_this_event_loop_matching([&](auto const& document) { diff --git a/Libraries/LibWeb/HTML/EventLoop/Task.h b/Libraries/LibWeb/HTML/EventLoop/Task.h index 7e5032294bc..3019d2178ea 100644 --- a/Libraries/LibWeb/HTML/EventLoop/Task.h +++ b/Libraries/LibWeb/HTML/EventLoop/Task.h @@ -81,6 +81,9 @@ public: // https://w3c.github.io/media-capabilities/#media-capabilities-task-source MediaCapabilities, + // https://w3c.github.io/gamepad/#dfn-gamepad-task-source + Gamepad, + // !!! IMPORTANT: Keep this field last! // This serves as the base value of all unique task sources. // Some elements, such as the HTMLMediaElement, must have a unique task source per instance. diff --git a/Libraries/LibWeb/HTML/HTMLBodyElement.cpp b/Libraries/LibWeb/HTML/HTMLBodyElement.cpp index 558ed786940..cebc5e0f6a5 100644 --- a/Libraries/LibWeb/HTML/HTMLBodyElement.cpp +++ b/Libraries/LibWeb/HTML/HTMLBodyElement.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include diff --git a/Libraries/LibWeb/HTML/HTMLFrameSetElement.cpp b/Libraries/LibWeb/HTML/HTMLFrameSetElement.cpp index 48aefa6c7e0..c748d7932cc 100644 --- a/Libraries/LibWeb/HTML/HTMLFrameSetElement.cpp +++ b/Libraries/LibWeb/HTML/HTMLFrameSetElement.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include diff --git a/Libraries/LibWeb/HTML/Navigator.cpp b/Libraries/LibWeb/HTML/Navigator.cpp index 784a76d2daa..293376682e2 100644 --- a/Libraries/LibWeb/HTML/Navigator.cpp +++ b/Libraries/LibWeb/HTML/Navigator.cpp @@ -41,6 +41,7 @@ void Navigator::initialize(JS::Realm& realm) { WEB_SET_PROTOTYPE_FOR_INTERFACE(Navigator); Base::initialize(realm); + NavigatorGamepadPartial::check_for_connected_gamepads(); } // https://html.spec.whatwg.org/multipage/system-state.html#dom-navigator-pdfviewerenabled @@ -65,6 +66,7 @@ bool Navigator::webdriver() const void Navigator::visit_edges(Cell::Visitor& visitor) { Base::visit_edges(visitor); + NavigatorGamepadPartial::visit_edges(visitor); visitor.visit(m_mime_type_array); visitor.visit(m_plugin_array); visitor.visit(m_clipboard); diff --git a/Libraries/LibWeb/HTML/WindowEventHandlers.cpp b/Libraries/LibWeb/HTML/WindowEventHandlers.cpp index 4061caea78c..fbcdc6cea3a 100644 --- a/Libraries/LibWeb/HTML/WindowEventHandlers.cpp +++ b/Libraries/LibWeb/HTML/WindowEventHandlers.cpp @@ -1,10 +1,11 @@ /* - * Copyright (c) 2022, Luke Wilde + * Copyright (c) 2022-2025, Luke Wilde * * SPDX-License-Identifier: BSD-2-Clause */ #include +#include #include #include diff --git a/Libraries/LibWeb/HTML/WindowEventHandlers.h b/Libraries/LibWeb/HTML/WindowEventHandlers.h index b42f886d95b..3b4b8b17bde 100644 --- a/Libraries/LibWeb/HTML/WindowEventHandlers.h +++ b/Libraries/LibWeb/HTML/WindowEventHandlers.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, Luke Wilde + * Copyright (c) 2022-2025, Luke Wilde * * SPDX-License-Identifier: BSD-2-Clause */ @@ -9,24 +9,26 @@ #include #include -#define ENUMERATE_WINDOW_EVENT_HANDLERS(E) \ - E(onafterprint, HTML::EventNames::afterprint) \ - E(onbeforeprint, HTML::EventNames::beforeprint) \ - E(onbeforeunload, HTML::EventNames::beforeunload) \ - E(onhashchange, HTML::EventNames::hashchange) \ - E(onlanguagechange, HTML::EventNames::languagechange) \ - E(onmessage, HTML::EventNames::message) \ - E(onmessageerror, HTML::EventNames::messageerror) \ - E(onoffline, HTML::EventNames::offline) \ - E(ononline, HTML::EventNames::online) \ - E(onpagehide, HTML::EventNames::pagehide) \ - E(onpagereveal, HTML::EventNames::pagereveal) \ - E(onpageshow, HTML::EventNames::pageshow) \ - E(onpageswap, HTML::EventNames::pageswap) \ - E(onpopstate, HTML::EventNames::popstate) \ - E(onrejectionhandled, HTML::EventNames::rejectionhandled) \ - E(onstorage, HTML::EventNames::storage) \ - E(onunhandledrejection, HTML::EventNames::unhandledrejection) \ +#define ENUMERATE_WINDOW_EVENT_HANDLERS(E) \ + E(onafterprint, HTML::EventNames::afterprint) \ + E(onbeforeprint, HTML::EventNames::beforeprint) \ + E(onbeforeunload, HTML::EventNames::beforeunload) \ + E(ongamepadconnected, Gamepad::EventNames::gamepadconnected) \ + E(ongamepaddisconnected, Gamepad::EventNames::gamepaddisconnected) \ + E(onhashchange, HTML::EventNames::hashchange) \ + E(onlanguagechange, HTML::EventNames::languagechange) \ + E(onmessage, HTML::EventNames::message) \ + E(onmessageerror, HTML::EventNames::messageerror) \ + E(onoffline, HTML::EventNames::offline) \ + E(ononline, HTML::EventNames::online) \ + E(onpagehide, HTML::EventNames::pagehide) \ + E(onpagereveal, HTML::EventNames::pagereveal) \ + E(onpageshow, HTML::EventNames::pageshow) \ + E(onpageswap, HTML::EventNames::pageswap) \ + E(onpopstate, HTML::EventNames::popstate) \ + E(onrejectionhandled, HTML::EventNames::rejectionhandled) \ + E(onstorage, HTML::EventNames::storage) \ + E(onunhandledrejection, HTML::EventNames::unhandledrejection) \ E(onunload, HTML::EventNames::unload) namespace Web::HTML { diff --git a/Libraries/LibWeb/Page/EventHandler.cpp b/Libraries/LibWeb/Page/EventHandler.cpp index 1a25b938a9a..e1aa685f83d 100644 --- a/Libraries/LibWeb/Page/EventHandler.cpp +++ b/Libraries/LibWeb/Page/EventHandler.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,8 @@ #include #include +#include + namespace Web { #define FIRE(expression) \ @@ -1459,6 +1462,56 @@ EventResult EventHandler::handle_paste(String const& text) return EventResult::Handled; } +void EventHandler::handle_gamepad_connected(SDL_JoystickID sdl_joystick_id) +{ + auto active_document = m_navigable->active_document(); + if (active_document) + active_document->window()->navigator()->handle_gamepad_connected(sdl_joystick_id); + + for (auto child_navigable : m_navigable->child_navigables()) + child_navigable->event_handler().handle_gamepad_connected(sdl_joystick_id); +} + +void EventHandler::handle_gamepad_updated(SDL_JoystickID sdl_joystick_id) +{ + auto active_document = m_navigable->active_document(); + if (active_document) + active_document->window()->navigator()->handle_gamepad_updated({}, sdl_joystick_id); + + for (auto child_navigable : m_navigable->child_navigables()) + child_navigable->event_handler().handle_gamepad_updated(sdl_joystick_id); +} + +void EventHandler::handle_gamepad_disconnected(SDL_JoystickID sdl_joystick_id) +{ + auto active_document = m_navigable->active_document(); + if (active_document) + active_document->window()->navigator()->handle_gamepad_disconnected({}, sdl_joystick_id); + + for (auto child_navigable : m_navigable->child_navigables()) + child_navigable->event_handler().handle_gamepad_disconnected(sdl_joystick_id); +} + +void EventHandler::handle_sdl_input_events() +{ + SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_EVENT_GAMEPAD_ADDED: + handle_gamepad_connected(event.gdevice.which); + break; + case SDL_EVENT_GAMEPAD_UPDATE_COMPLETE: + handle_gamepad_updated(event.gdevice.which); + break; + case SDL_EVENT_GAMEPAD_REMOVED: + handle_gamepad_disconnected(event.gdevice.which); + break; + default: + break; + } + } +} + void EventHandler::set_mouse_event_tracking_paintable(GC::Ptr paintable) { m_mouse_event_tracking_paintable = paintable; diff --git a/Libraries/LibWeb/Page/EventHandler.h b/Libraries/LibWeb/Page/EventHandler.h index 7e9f94647d9..1ed89629871 100644 --- a/Libraries/LibWeb/Page/EventHandler.h +++ b/Libraries/LibWeb/Page/EventHandler.h @@ -20,6 +20,8 @@ #include #include +#include + namespace Web { class WEB_API EventHandler { @@ -43,6 +45,8 @@ public: EventResult handle_paste(String const& text); + void handle_sdl_input_events(); + void visit_edges(JS::Cell::Visitor& visitor) const; Unicode::Segmenter& word_segmenter(); @@ -68,6 +72,10 @@ private: bool should_ignore_device_input_event() const; + void handle_gamepad_connected(SDL_JoystickID); + void handle_gamepad_updated(SDL_JoystickID); + void handle_gamepad_disconnected(SDL_JoystickID); + GC::Ref m_navigable; bool m_in_mouse_selection { false }; diff --git a/Libraries/LibWeb/Page/Page.cpp b/Libraries/LibWeb/Page/Page.cpp index 3c7a4e7b1af..3e744bb003b 100644 --- a/Libraries/LibWeb/Page/Page.cpp +++ b/Libraries/LibWeb/Page/Page.cpp @@ -244,6 +244,11 @@ EventResult Page::handle_keyup(UIEvents::KeyCode key, unsigned modifiers, u32 co return focused_navigable().event_handler().handle_keyup(key, modifiers, code_point, repeat); } +void Page::handle_sdl_input_events() +{ + top_level_traversable()->event_handler().handle_sdl_input_events(); +} + void Page::set_top_level_traversable(GC::Ref navigable) { VERIFY(!m_top_level_traversable); // Replacement is not allowed! diff --git a/Libraries/LibWeb/Page/Page.h b/Libraries/LibWeb/Page/Page.h index 5c4903a27a0..5177f4872c4 100644 --- a/Libraries/LibWeb/Page/Page.h +++ b/Libraries/LibWeb/Page/Page.h @@ -102,6 +102,8 @@ public: EventResult handle_keydown(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat); EventResult handle_keyup(UIEvents::KeyCode, unsigned modifiers, u32 code_point, bool repeat); + void handle_sdl_input_events(); + Gfx::Palette palette() const; CSSPixelRect web_exposed_screen_area() const; CSS::PreferredColorScheme preferred_color_scheme() const; diff --git a/Libraries/LibWeb/idl_files.cmake b/Libraries/LibWeb/idl_files.cmake index cb70c8b6ae0..9ecbe7113ee 100644 --- a/Libraries/LibWeb/idl_files.cmake +++ b/Libraries/LibWeb/idl_files.cmake @@ -126,6 +126,9 @@ libweb_js_bindings(FileAPI/FileList) libweb_js_bindings(FileAPI/FileReader) libweb_js_bindings(FileAPI/FileReaderSync) libweb_js_bindings(Gamepad/Gamepad) +libweb_js_bindings(Gamepad/GamepadButton) +libweb_js_bindings(Gamepad/GamepadEvent) +libweb_js_bindings(Gamepad/GamepadHapticActuator) libweb_js_bindings(Geolocation/Geolocation) libweb_js_bindings(Geolocation/GeolocationCoordinates) libweb_js_bindings(Geolocation/GeolocationPosition) diff --git a/Meta/Lagom/Tools/CodeGenerators/LibWeb/BindingsGenerator/IDLGenerators.cpp b/Meta/Lagom/Tools/CodeGenerators/LibWeb/BindingsGenerator/IDLGenerators.cpp index 10a61e6d1e7..c3790faf0d0 100644 --- a/Meta/Lagom/Tools/CodeGenerators/LibWeb/BindingsGenerator/IDLGenerators.cpp +++ b/Meta/Lagom/Tools/CodeGenerators/LibWeb/BindingsGenerator/IDLGenerators.cpp @@ -74,6 +74,9 @@ static bool is_platform_object(Type const& type) "FileList"sv, "FontFace"sv, "FormData"sv, + "Gamepad"sv, + "GamepadButton"sv, + "GamepadHapticActuator"sv, "HTMLCollection"sv, "IDBCursor"sv, "IDBCursorWithValue"sv, diff --git a/Tests/LibWeb/Text/expected/all-window-properties.txt b/Tests/LibWeb/Text/expected/all-window-properties.txt index d14ecf1261e..45430ca6b8d 100644 --- a/Tests/LibWeb/Text/expected/all-window-properties.txt +++ b/Tests/LibWeb/Text/expected/all-window-properties.txt @@ -157,6 +157,9 @@ FormDataEvent Function GainNode Gamepad +GamepadButton +GamepadEvent +GamepadHapticActuator Geolocation GeolocationCoordinates GeolocationPosition