aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author0neGal <mail@0negal.com>2024-12-20 01:55:12 +0100
committerGitHub <noreply@github.com>2024-12-20 01:55:12 +0100
commit14c12f9bf74f7ae7e9b4b873e67b8e9769cdad1e (patch)
tree2a8c0535a22264f4f3d37d684231d2ac147ac199
parentbd4391f737ee4405fc390ab47c4124fe28df48d7 (diff)
parenta8ba1b376ff16a69c52f223341a2ed39fd59a8ea (diff)
downloadViper-14c12f9bf74f7ae7e9b4b873e67b8e9769cdad1e.tar.gz
Viper-14c12f9bf74f7ae7e9b4b873e67b8e9769cdad1e.zip
Merge pull request #239 from 0neGal/gamepad-support
feat: Gamepad/keyboard navigation support
-rw-r--r--src/app/css/launcher.css11
-rw-r--r--src/app/css/selection.css23
-rw-r--r--src/app/css/theming.css2
-rw-r--r--src/app/index.html14
-rw-r--r--src/app/js/dom_events.js6
-rw-r--r--src/app/js/gamepad.js333
-rw-r--r--src/app/js/launcher.js24
-rw-r--r--src/app/js/navigate.js902
-rw-r--r--src/app/js/settings.js9
-rw-r--r--src/app/js/tooltip.js2
-rw-r--r--src/app/main.css1
-rw-r--r--src/app/main.js3
12 files changed, 1308 insertions, 22 deletions
diff --git a/src/app/css/launcher.css b/src/app/css/launcher.css
index c43bce1..47ecb25 100644
--- a/src/app/css/launcher.css
+++ b/src/app/css/launcher.css
@@ -34,9 +34,9 @@
transition: 0.3s ease-in-out;
background-color: transparent;
-
margin: 20px;
position: relative;
+ border-radius: 0px;
box-sizing: border-box;
flex-basis: calc(100% - 10px);
}
@@ -111,7 +111,8 @@
.contentMenu li:last-child {margin-right: 0px}
.contentMenu li:first-child {margin-left: 0px}
-.contentMenu li:hover {opacity: 0.7}
+.contentMenu li:hover,
+.contentMenu li.active-selection {opacity: 0.7}
.contentMenu li[active] {
opacity: 1.0;
@@ -305,7 +306,8 @@ button:has(img):has(span) img {
transition: opacity 0.2s ease-in-out;
}
-button:has(img):has(span):hover img {
+button:has(img):has(span):hover img,
+button:has(img):has(span).active-selection img {
opacity: 1.0;
}
@@ -315,7 +317,8 @@ button:has(img):has(span) span {
transition: right 0.2s ease-in-out;
}
-button:has(img):has(span):hover span {
+button:has(img):has(span):hover span,
+button:has(img):has(span).active-selection span {
right: 0.1em;
}
diff --git a/src/app/css/selection.css b/src/app/css/selection.css
new file mode 100644
index 0000000..5b7d647
--- /dev/null
+++ b/src/app/css/selection.css
@@ -0,0 +1,23 @@
+#selection {
+ z-index: 99999999999999;
+
+ transition: 0.15s ease-in-out;
+ transition-property:
+ opacity, border-radius, background,
+ transform, width, height, top, left;
+
+ position: fixed;
+ pointer-events: none;
+ transform: scale(1.0);
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: calc(var(--padding) / 2);
+}
+
+:has(.active-selection.scroll-selection) #selection {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+#selection.keyboard-selecting,
+#selection.controller-selecting {
+ transform: scale(1.1);
+}
diff --git a/src/app/css/theming.css b/src/app/css/theming.css
index fb8fbf7..c0b6497 100644
--- a/src/app/css/theming.css
+++ b/src/app/css/theming.css
@@ -42,7 +42,7 @@ a.disabled:not("[onclick='kill('game')']") {
pointer-events: none;
}
-a:hover {
+a:hover, a.active-selection {
filter: brightness(80%) !important;
}
diff --git a/src/app/index.html b/src/app/index.html
index ca80c86..9e97810 100644
--- a/src/app/index.html
+++ b/src/app/index.html
@@ -6,6 +6,8 @@
<meta charset="utf-8">
</head>
<body>
+ <div id="selection"></div>
+
<div id="tooltip">Test</div>
<div id="bgHolder"></div>
@@ -33,7 +35,7 @@
<div id="overlay" onclick="popups.set_all(false)"></div>
<div class="popup" id="options">
<div class="misc">
- <input class="search" placeholder="%%gui.search%%">
+ <input class="search default-selection" placeholder="%%gui.search%%">
<button id="apply" onclick="settings.popup.apply();settings.popup.toggle(false)">
<img src="icons/apply.png">
%%gui.settings.save%%
@@ -251,7 +253,7 @@
</div>
<div class="misc">
- <input class="search" placeholder="%%gui.search%%">
+ <input class="search default-selection" placeholder="%%gui.search%%">
<button id="filter" onclick="browser.filters.toggle()">
<img src="icons/filter.png">
</button>
@@ -264,7 +266,7 @@
</div>
</div>
<div class="popup small blur" id="preview">
- <div class="misc fixed vertical">
+ <div class="misc fixed vertical default-selection">
<button id="close" onclick="browser.preview.hide()">
<img src="icons/close.png">
</button>
@@ -273,7 +275,7 @@
</button>
</div>
- <webview></webview>
+ <webview class="scroll-selection"></webview>
</div>
<nav class="gamesContainer">
@@ -297,7 +299,7 @@
<a id="setpath" href="#" onclick="gamepath.set()" class="disable-when-installing">%%gui.setpath%%</a>
</div>
</div>
- <div id="vpReleaseNotes" class="hidden section"></div>
+ <div id="vpReleaseNotes" class="scroll-selection hidden section"></div>
<div id="vpInfo" class="hidden section">
<h2>%%viper.info.links%%</h2>
<ul>
@@ -357,7 +359,7 @@
</div>
</div>
</div>
- <div id="nsRelease" class="hidden section"></div>
+ <div id="nsRelease" class="scroll-selection hidden section"></div>
</div>
</div>
diff --git a/src/app/js/dom_events.js b/src/app/js/dom_events.js
index ab79c57..490d2ec 100644
--- a/src/app/js/dom_events.js
+++ b/src/app/js/dom_events.js
@@ -26,12 +26,6 @@ document.addEventListener("drop", (e) => {
mods.install_from_path(e.dataTransfer.files[0].path);
})
-document.body.addEventListener("keyup", (e) => {
- if (e.key == "Escape") {
- popups.hide_last();
- }
-})
-
document.body.addEventListener("click", (e) => {
if (e.target.tagName.toLowerCase() === "a"
&& e.target.protocol != "file:") {
diff --git a/src/app/js/gamepad.js b/src/app/js/gamepad.js
new file mode 100644
index 0000000..69ca13f
--- /dev/null
+++ b/src/app/js/gamepad.js
@@ -0,0 +1,333 @@
+const popups = require("./popups");
+const settings = require("./settings");
+const launcher = require("./launcher");
+const navigate = require("./navigate");
+
+window.addEventListener("gamepadconnected", (e) => {
+ console.log("Gamepad connected:", e.gamepad.id);
+}, false)
+
+window.addEventListener("gamepaddisconnected", (e) => {
+ console.log("Gamepad disconnected:", e.gamepad.id);
+}, false)
+
+// this contains the names/directions of axes and IDs that have
+// previously been pressed, if it is found that these were recently
+// pressed in the next iteration of the `setInterval()` below than the
+// iteration is skipped
+//
+// the value of each item is equivalent to the amount of iterations to
+// wait, so `up: 3` will cause it to wait 3 iterations, before `up` can
+// be pressed again
+let delay_press = {};
+
+let held_buttons = {};
+
+setInterval(() => {
+ let gamepads = navigator.getGamepads();
+
+ // this has a list of all the directions that the `.axes[]` are
+ // pointing in, letting us navigate in that direction
+ let directions = {}
+
+ // keeps track of which buttons `delay_press` that have already been
+ // lowered, that way we can lower the ones that haven't been lowered
+ // through a button press
+ let lowered_delay = [];
+
+ // is the select/accept button being held
+ let selecting = false;
+
+ for (let i in gamepads) {
+ if (! gamepads[i]) {continue}
+ // every other `.axes[]` element is a different coordinate, each
+ // analog stick has 2 elements in `.axes[]`, the first one is
+ // the x coordinate, second is the y coordinate
+ //
+ // so we use this to keep track of which coordinate we're
+ // currently on, and thereby the direction of the float inside
+ // `.axes[i]`
+ let coord = "x";
+ let deadzone = 0.5;
+
+ for (let ii = 0; ii < gamepads[i].axes.length; ii++) {
+ let value = gamepads[i].axes[ii];
+
+ // check if we're beyond the deadzone in both the negative
+ // and positive direction, and then using `coord` add a
+ // direction to `directions`
+ if (value < -deadzone) {
+ if (coord == "y") {
+ directions.up = true;
+ } else {
+ directions.left = true;
+ }
+ } else if (value > deadzone) {
+ if (coord == "y") {
+ directions.down = true;
+ } else {
+ directions.right = true;
+ }
+ }
+
+ // flip `coord`
+ if (coord == "x") {
+ coord = "y";
+ } else {
+ coord = "x";
+ }
+ }
+
+ // only support "standard" button layouts/mappings
+ //
+ // TODO: for anybody reading this in the future, the support
+ // for other mappings is something that's on the table,
+ // however, due to not having all the hardware in the world,
+ // this will have to be up to someone else
+ if (gamepads[i].mapping != "standard") {
+ continue;
+ }
+
+ for (let ii = 0; ii < gamepads[i].buttons.length; ii++) {
+ if (! gamepads[i].buttons[ii].pressed) {
+ held_buttons[ii] = false;
+ continue;
+ }
+
+ // a list of known combinations of buttons for the most
+ // common brands out there, more should possibly be added
+ let brands = {
+ "Xbox": {
+ accept: 0,
+ cancel: 1
+ },
+ "Nintendo": {
+ accept: 1,
+ cancel: 0
+ },
+ "PlayStation": {
+ accept: 0,
+ cancel: 1
+ }
+ }
+
+ // this is the most common setup, to my understanding, with
+ // the exception of third party Nintendo controller, may
+ // need to be adjusted in the future
+ let buttons = {
+ accept: 0,
+ cancel: 1
+ }
+
+ // set `cancel` and `accept` accordingly to the ID of the
+ // gamepad, if its a known brand
+ for (let brand in brands) {
+ // unknown brand
+ if (! gamepads[i].id.includes(brand)) {
+ continue;
+ }
+
+ // set buttons according to brand
+ buttons = brands[brand];
+ break;
+ }
+
+ // if the button that's being pressed is the "accept"
+ // button, then we set `selecting` to `true`, this is done
+ // before we check for the button delay so that holding the
+ // button keeps the selection in place, until the button is
+ // no longer pressed
+ if (ii == buttons.accept) {
+ selecting = true;
+ }
+
+ // if this button is still delayed, we lower the delay and
+ // then go to the next button
+ if (delay_press[ii]) {
+ delay_press[ii]--;
+ lowered_delay.push(ii);
+ continue;
+ }
+
+ // add delay to this button, so it doesn't get clicked
+ // immediately again after this
+ delay_press[ii] = 3;
+
+ if (held_buttons[ii]) {
+ continue;
+ }
+
+ held_buttons[ii] = true;
+
+ // interpret `ii` as a specific button/action, using the
+ // standard IDs: https://w3c.github.io/gamepad/#remapping
+ switch(ii) {
+ // settings popup (center cluster buttons)
+ case 8: settings.popup.toggle(); break;
+ case 9: settings.popup.toggle(); break;
+
+ // change active section (top bumpers)
+ case 4: launcher.relative_section("left"); break;
+ case 5: launcher.relative_section("right"); break;
+
+ // navigate selection (dpad)
+ case 12: navigate.move("up"); break;
+ case 13: navigate.move("down"); break;
+ case 14: navigate.move("left"); break;
+ case 15: navigate.move("right"); break;
+
+ // click selected element
+ case buttons.accept: navigate.select(); break;
+
+ // close last opened popup
+ case buttons.cancel: popups.hide_last(); break;
+ }
+ }
+ }
+
+ for (let i in directions) {
+ if (directions[i] === true) {
+ // if this direction is still delayed, we lower the delay,
+ // and then go to the next direction
+ if (delay_press[i]) {
+ delay_press[i]--;
+ lowered_delay.push(i);
+ continue;
+ }
+
+ // move in the direction
+ navigate.move(i);
+
+ // add delay to this direction, to prevent it from being
+ // triggered immediately again
+ delay_press[i] = 5;
+ }
+ }
+
+ // run through buttons that have or have had a delay
+ for (let i in delay_press) {
+ // if a button has a delay, and it hasn't already been lowered,
+ // then we lower it
+ if (delay_press[i] && ! lowered_delay.includes(i)) {
+ delay_press[i]--;
+ }
+ }
+
+ let selection_el = document.getElementById("selection");
+
+ // add `.selecting` to `#selection` depending on whether
+ // `selecting`, is set or not
+ if (selecting) {
+ selection_el.classList.add("controller-selecting");
+ } else {
+ selection_el.classList.remove("controller-selecting");
+ }
+}, 50)
+
+
+let can_keyboard_navigate = (e) => {
+ // quite empty right now, might add more in the future, these are
+ // just element selectors where movement with the keyboard is off
+ let ignore_on_focus = [
+ "input",
+ "select"
+ ]
+
+ // check for whether the active element is one that matches
+ // something in `ignore_on_focus`
+ for (let i = 0; i < ignore_on_focus.length; i++) {
+ if (! document.activeElement.matches(ignore_on_focus)) {
+ // active element does not match to `ignore_on_focus[i]`
+ continue;
+ }
+
+ // if the key that's being pressed is "Escape" then we unfocus
+ // to the currently focused active element, this lets you go
+ // into an input, and then exit it as well
+ if (e.key == "Escape") {
+ document.activeElement.blur();
+ }
+
+ return false;
+ }
+
+ // check if there's already an active selection
+ if (document.querySelector(".active-selection")) {
+ // this is a list of keys where this keyboard event will be
+ // cancelled on, this prevents key events from being sent to
+ // element, but still lets you type
+ let cancel_keys = [
+ "Space", "Enter",
+ "ArrowUp", "ArrowDown",
+ "ArrowLeft", "ArrowRight"
+ ]
+
+ // cancel this keyboard event if `e.key` is inside `cancel_keys`
+ if (cancel_keys.includes(e.code)) {
+ e.preventDefault();
+ }
+ }
+
+ return true;
+}
+
+window.addEventListener("keydown", (e) => {
+ // do nothing if we cant navigate
+ if (! can_keyboard_navigate(e)) {
+ return;
+ }
+
+ let select = () => {
+ // do nothing if this is a repeat key press
+ if (e.repeat) {return}
+
+ // select `.active-selection`
+ navigate.select();
+
+ // add `.keyboard-selecting` to `#selection`
+ document.getElementById("selection")
+ .classList.add("keyboard-selecting");
+ }
+
+ // perform the relevant action for the key that was pressed
+ switch(e.code) {
+ // select
+ case "Space": return select();
+ case "Enter": return select();
+
+ // close popup
+ case "Escape": return popups.hide_last();
+
+ // move selection
+ case "KeyK": case "ArrowUp": return navigate.move("up")
+ case "KeyJ": case "ArrowDown": return navigate.move("down")
+ case "KeyH": case "ArrowLeft": return navigate.move("left")
+ case "KeyL": case "ArrowRight": return navigate.move("right")
+ }
+})
+
+window.addEventListener("keyup", (e) => {
+ if (! can_keyboard_navigate(e)) {
+ return;
+ }
+
+ let selection_el = document.getElementById("selection");
+
+ // perform the relevant action for the key that was pressed
+ switch(e.code) {
+ // the second and third cases here are for SteamDeck bumper
+ // button support whilst inside the desktop layout
+ case "KeyQ":
+ case "ControlLeft": case "ControlRight":
+ launcher.relative_section("left"); break;
+ case "KeyE":
+ case "AltLeft": case "AltRight":
+ launcher.relative_section("right"); break;
+
+ case "Space": return selection_el
+ .classList.remove("keyboard-selecting");
+
+ case "Enter": return selection_el
+ .classList.remove("keyboard-selecting");
+ }
+})
diff --git a/src/app/js/launcher.js b/src/app/js/launcher.js
index dc99792..d249d34 100644
--- a/src/app/js/launcher.js
+++ b/src/app/js/launcher.js
@@ -1,3 +1,4 @@
+const popups = require("./popups");
const markdown = require("marked").parse;
let launcher = {};
@@ -168,6 +169,11 @@ launcher.show_ns = (section) => {
//
// `direction` can be: left or right
launcher.relative_section = (direction) => {
+ // prevent switching section if a popup is open
+ if (popups.open_list().length) {
+ return;
+ }
+
// the `.contentMenu` in the currently active tab
let active_menu = document.querySelector(
".contentContainer:not(.hidden) .contentMenu"
@@ -203,18 +209,30 @@ launcher.relative_section = (direction) => {
}
}
+ let new_section;
+
// if we're going left, and a previous section was found, click it
if (direction == "left" && prev_section) {
- prev_section.click();
+ new_section = prev_section;
} else if (direction == "right") {
// click the next section, if one was found, otherwise just
// assume that the first section is the next section, as the
// active section is likely just the last section, so we wrap
// around instead
if (next_section) {
- next_section.click();
+ new_section = next_section;
} else if (sections[0]) {
- sections[0].click();
+ new_section = sections[0];
+ }
+ }
+
+ if (new_section) {
+ new_section.click();
+
+ // if there's an active selection, we select the new section, as
+ // that selection may be in a section that's now hidden
+ if (document.querySelector(".active-selection")) {
+ navigate.selection(new_section);
}
}
}
diff --git a/src/app/js/navigate.js b/src/app/js/navigate.js
new file mode 100644
index 0000000..18c32b3
--- /dev/null
+++ b/src/app/js/navigate.js
@@ -0,0 +1,902 @@
+const events = require("./events");
+const popups = require("./popups");
+const settings = require("./settings");
+
+let navigate = {};
+
+// sets `#selection` to the correct position, size and border radius,
+// according to what is currently the `.active-selection`, if none is
+// found, it'll instead be hidden
+navigate.selection = (new_selection) => {
+ if (new_selection) {
+ let selected = document.querySelectorAll(".active-selection");
+
+ // make sure just `new_selection` has `.active-selection`
+ for (let i = 0; i < selected.length; i++) {
+ if (selected[i] != new_selection) {
+ selected[i].classList.remove("active-selection");
+ }
+ }
+
+ new_selection.classList.add("active-selection");
+ }
+
+ // shorthands
+ let selection_el = document.getElementById("selection");
+ let active_el = document.querySelector(".active-selection");
+
+ // make sure there's an `active_el`, and hide the `selection_el` if
+ // that isn't the case
+ if (! active_el) {
+ selection_el.style.opacity = "0.0";
+ return;
+ }
+
+ // this adds space between the `selection_el` and ``
+ let padding = 8;
+
+ // attempt to get the border radius of `active_el`
+ let radius = getComputedStyle(active_el).borderRadius;
+
+ // if there's no radius set, we default to the default of
+ // `selection_el` through using `null`
+ if (! radius || radius == "0px") {
+ radius = null;
+ }
+
+ // set visibility and radius
+ selection_el.style.opacity = "1.0";
+ selection_el.style.borderRadius = radius;
+
+ // get bounds for position and size calculations of `selection_el`
+ let active_bounds = active_el.getBoundingClientRect();
+
+ // set top and left side coordinate subtracting the padding
+ selection_el.style.top = active_bounds.top - padding + "px";
+ selection_el.style.left = active_bounds.left - padding + "px";
+
+ // set width of `selection_el` with the padding
+ selection_el.style.width =
+ active_bounds.width + (padding * 2) + "px";
+
+ // set height of `selection_el` with the padding
+ selection_el.style.height =
+ active_bounds.height + (padding * 2) + "px";
+}
+
+// data from the last iterations of the interval below
+let last_sel = {
+ el: false,
+ bounds: false
+}
+
+// auto update `#selection` if `.active-selection` changes bounds, but
+// not element by itself
+setInterval(() => {
+ // get active selection
+ let selected = document.querySelector(".active-selection");
+
+ // if there's no active selection, reset `last_sel`
+ if (! selected) {
+ last_sel.el = false;
+ last_sel.bounds = false;
+
+ return;
+ }
+
+ // get stringified bounds
+ let bounds = JSON.stringify(selected.getBoundingClientRect());
+
+ // if `last_sel.el` is not `selected` the selected element was
+ // changed, so we just set `last_el` and nothing more
+ if (last_sel.el != selected) {
+ last_sel.el = selected;
+ last_sel.bounds = bounds;
+
+ return;
+ }
+
+ // if stringified bounds changed we update `#selection`
+ if (bounds != last_sel.bounds) {
+ navigate.selection();
+ last_sel.el = selected;
+ last_sel.bounds = bounds;
+ }
+}, 50)
+
+// these events cause the `#selection` element to reposition itself
+window.addEventListener("resize", () => {navigate.selection()}, true);
+window.addEventListener("scroll", () => {navigate.selection()}, true);
+
+// listen for click events, and hide the `#selection` element, when
+// emitting a mouse event we will want to hide, as it then isn't needed
+window.addEventListener("click", (e) => {
+ // make sure its a trusted click event, and therefore actually a
+ // mouse, and not anything else
+ if (! e.isTrusted) {
+ return;
+ }
+
+ // get the `.active-selection`
+ let active_el = document.querySelector(".active-selection");
+
+ // if there's an `active_el` then we unselect it, and update the
+ // `#selection` element, hiding it
+ if (active_el) {
+ active_el.classList.remove("active-selection");
+ navigate.selection();
+ }
+})
+
+// returns a list of valid elements that should be possible to navigate
+// to/select with the `#selection` element
+//
+// setting `div` makes it limit itself to elements inside that, without
+// it, it'll use `document.body` or the active popup, if one is found
+navigate.get_els = (div) => {
+ let els = [];
+
+ // is `div` not set, and is there a popup shown
+ if (! div && document.body.querySelector(".popup.shown")) {
+ // the spread operator is to convert from a `NodeList` to an
+ // `Array`, and then we need to reverse this to get the ones
+ // that are layered on top first.
+ let popups_list = [...popups.list()].reverse();
+
+ // run through the list of popups
+ for (let i = 0; i < popups_list.length; i++) {
+ // if this popup is shown, we make it the current `div`
+ if (popups_list[i].classList.contains("shown")) {
+ div = popups_list[i];
+ break;
+ }
+ }
+
+ // get buttons inside `#winbtns`
+ els = [...document.body.querySelectorAll("#winbtns [onclick]")];
+ } if (! div) { // default
+ div = document.body;
+ }
+
+ // this gets the list of all the elements we should be able to
+ // select inside `div`, on top of anything that's already in `els`
+ els = [...els, ...div.querySelectorAll([
+ "a",
+ "input",
+ "button",
+ "select",
+ "textarea",
+ "[onclick]",
+ ".scroll-selection"
+ ])]
+
+ // this'll contain a filtered list of `els`
+ let new_els = [];
+
+ // filter out elements we don't care about
+ filter: for (let i = 0; i < els.length; i++) {
+ // elements that match on `els.closest()` with any of these will
+ // be stripped away, as we dont want them
+ let ignore_closest = [
+ "#overlay",
+ ".no-navigate",
+ "button.visual",
+ ".scroll-selection",
+ ".popup:not(.shown)"
+ ]
+
+ // ignore, even if `.closest()` matches, if its just matching on
+ // itself instead of a different element
+ let ignore_closest_self = [
+ ".scroll-selection"
+ ]
+
+ // check if `els[i].closest()` matches on any of the elements
+ // inside of `ignore_closest`
+ for (let ii = 0; ii < ignore_closest.length; ii++) {
+ let closest = els[i].closest(ignore_closest[ii]);
+
+ // check if `.closest()` matches, but not on itself
+ if (closest) {
+ // ignore if `closest` is just `els[i]` and the selector
+ // is inside `ignore_closest_self`
+ if (closest == els[i] &&
+ ignore_closest_self.includes(ignore_closest[ii])) {
+
+ continue;
+ }
+
+ // it matches
+ continue filter;
+ }
+ }
+
+ // make sure `els[i]` is visible on screen
+ let visible = els[i].checkVisibility({
+ checkOpacity: true,
+ visibilityProperty: true,
+ checkVisibilityCSS: true,
+ contentVisibilityAuto: true
+ })
+
+ // filter out if not visible
+ if (! visible) {continue}
+
+ // add to filtered list
+ new_els.push(els[i])
+ }
+
+ // return the filtered list of elements
+ return new_els;
+}
+
+// attempts to select the currently default selection, if inside a popup
+// we'll look for a `.default-selection`, if it doesn't exist we'll
+// simply use the first selectable element in it
+//
+// if not inside a popup we'll just use the currently selected tab in
+// the `.gamesContainer` sidebar
+navigate.default_selection = () => {
+ // the spread operator is to convert from a `NodeList` to an
+ // `Array`, and then we need to reverse this to get the ones
+ // that are layered on top first.
+ let popups_list = [...popups.list()].reverse();
+
+ let active_popup;
+
+ // run through the list of popups
+ for (let i = 0; i < popups_list.length; i++) {
+ // if this popup is shown, set set `active_popup` to it
+ if (popups_list[i].classList.contains("shown")) {
+ active_popup = popups_list[i];
+ break;
+ }
+ }
+
+ // is there no active popup?
+ if (! active_popup) {
+ // select the currently selected page in `.gamesContainer`
+ document.querySelector(
+ ".gamesContainer :not(.inactive)"
+ ).classList.add("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+
+ return;
+ }
+
+ // get the default element inside the active popup
+ let popup_default = active_popup.querySelector("default-selection");
+
+ // did we not find a default selection element?
+ if (! popup_default) {
+ // select the first selectable element in the popup
+ navigate.get_els(active_popup)[0].classList.add(
+ "active-selection"
+ )
+
+ // update the `#selection` element
+ navigate.selection();
+
+ return;
+ }
+
+ // select the default selection
+ popup_default.classList.add("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+}
+
+// this navigates `#selection` in the direction of `direction`
+// this can be: up, down, left and right
+navigate.move = async (direction) => {
+ // get the `.active-selection` if there is one
+ let active = document.querySelector(".active-selection");
+
+ // if there is no active selection, then attempt to select the
+ // default selection
+ if (! active) {
+ navigate.default_selection()
+
+ active = document.querySelector(".active-selection");
+
+ // if there is somehow still no active selection we stop here
+ if (! active) {
+ return;
+ }
+ }
+
+ // is the active selection one that should be scrollable?
+ if (active.classList.contains("scroll-selection")) {
+ // scroll the respective `direction` if `active` has any more
+ // scroll left in that direction
+
+ // short hand to easily scroll in `direction` by `amount` with
+ // smooth scrolling enabled
+ let scroll = (direction, amount) => {
+ // update the `#selection` element
+ navigate.selection();
+
+ // scroll inside `<webview>` if the active selection is one
+ if (active.tagName == "WEBVIEW") {
+ active.executeJavaScript(`
+ document.scrollingElement.scrollBy({
+ behavior: "smooth",
+ ${direction}: ${amount}
+ })
+ `)
+
+ return;
+ }
+
+ active.scrollBy({
+ behavior: "smooth",
+ [direction]: amount
+ })
+ }
+
+ // get values needed for determining if we should scroll the
+ // active selection, and by how much
+ let scroll_el = {
+ top: active.scrollTop,
+ left: active.scrollLeft,
+ width: active.scrollWidth,
+ height: active.scrollHeight,
+ bounds: {
+ width: active.clientWidth,
+ height: active.clientWidth
+ }
+ }
+
+ // get `scroll_el` from inside a `<webview>` if the active
+ // selection is one
+ if (active.tagName == "WEBVIEW") {
+ scroll_el = await active.executeJavaScript(`(() => {
+ return {
+ top: document.scrollingElement.scrollTop,
+ left: document.scrollingElement.scrollLeft,
+ width: document.scrollingElement.scrollWidth,
+ height: document.scrollingElement.scrollHeight,
+ bounds: {
+ width: document.scrollingElement.clientWidth,
+ height: document.scrollingElement.clientHeight
+ }
+ }
+ })()`)
+ }
+
+ // decrease to increase scroll length, and in reverse
+ let scroll_scale = 2;
+
+ if (direction == "up" && scroll_el.top > 0) {
+ return scroll("top", -scroll_el.bounds.height / scroll_scale);
+ }
+
+ if (direction == "down" &&
+ scroll_el.top <= scroll_el.height &&
+ scroll_el.height != scroll_el.bounds.height) {
+
+ return scroll("top", scroll_el.bounds.height / scroll_scale);
+ }
+
+ if (direction == "left" && scroll_el.left > 0) {
+ return scroll("left", -width / scroll_scale);
+ }
+
+ if (direction == "right" &&
+ scroll_el.left <= scroll_el.width &&
+ scroll_el.width != scroll_el.bounds.width) {
+
+ return scroll("left", scroll_el.bounds.width / scroll_scale);
+ }
+ }
+
+ // attempt to get the element in the `direction` requested
+ let move_to_el = navigate.get_relative_el(active, direction);
+
+ // if no element is found, do nothing
+ if (! move_to_el) {return}
+
+ // switch `.active-selection` from `active` to `move_to_el`
+ active.classList.remove("active-selection");
+ move_to_el.classList.add("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+
+ // make sure the selecting classes are removed, and thereby the
+ // scale/pressed effect with it
+ document.getElementById("selection").classList.remove(
+ "keyboard-selecting",
+ "controller-selecting"
+ )
+
+ // stop here if `move_to_el` is a child to `document.body`
+ if (move_to_el.parentElement == document.body) {
+ return;
+ }
+
+ // this element will be scrolled in view later
+ let scroll_el = move_to_el;
+
+ // these elements cant be scrolled
+ let no_scroll_parents = [
+ ".el .text",
+ ".gamesContainer",
+ ]
+
+ // run through unscrollable parent elements
+ for (let i = 0; i < no_scroll_parents.length; i++) {
+ // check if `move_to_el.closest()` matches on anything in
+ // `no_scroll_parents`
+ let no_scroll_parent = move_to_el.closest(
+ no_scroll_parents[i]
+ )
+
+ if (! no_scroll_parent) {
+ // it does not match
+ continue;
+ }
+
+ // it matches, so we make the new `scroll_el` the parent
+ scroll_el = no_scroll_parent;
+ }
+
+ // refuse to scroll to begin with, if any of these are a parent
+ let ignore_parents = [
+ ".contentMenu",
+ ".gamesContainer",
+ ]
+
+ // check if `ignore_parents` match on `move_to_el`, and if so, stop
+ for (let i = 0; i < ignore_parents.length; i++) {
+ if (move_to_el.closest(ignore_parents[i])) {
+ return;
+ }
+ }
+
+ // scroll `scroll_el` smoothly into view, centered
+ scroll_el.scrollIntoView({
+ block: "center",
+ inline: "center",
+ behavior: "smooth",
+ })
+}
+
+// selects the currently selected element, by clicking or focusing it
+navigate.select = () => {
+ // get the current selection
+ let active = document.querySelector(".active-selection");
+
+ // make sure there is a selection
+ if (! active) {return}
+
+ // slight delay to prevent some timing issues
+ setTimeout(() => {
+ // if `active` is a switch, use `settings.popup.switch()` on it,
+ // to be able to toggle it
+ if (active.closest(".switch")) {
+ settings.popup.switch(active.closest(".switch"));
+ return;
+ }
+
+ // click and focus `active`
+ active.click();
+ active.focus();
+ }, 150)
+}
+
+// selects the closest and hopefully most correct element to select next
+// to `relative_el` in the direction of `direction`
+//
+// the direction can be: up, down, left and right
+navigate.get_relative_el = (relative_el, direction) => {
+ // get selectable elements
+ let els = navigate.get_els();
+
+ // get bounds of `relative_el`
+ let bounds = relative_el.getBoundingClientRect();
+
+ // get the centered coordinates of `relative_el`
+ let relative = {
+ x: bounds.left + (bounds.width / 2),
+ y: bounds.top + (bounds.height / 2)
+ }
+
+ // update the coordinates on the element itself
+ relative_el.coords = relative;
+
+ // attempt to return the element in the correct direction
+ // if `x` or `y` is a number that's greater or less than 0 then
+ // we'll go in the direction of the coordinate that is as such
+ //
+ // meaning `get_el(1, 0)` will go to the right
+ let get_el = (x = 0, y = 0) => {
+ // `coord` is the coordinate that we're trying to get an element
+ // on, and `rev_coord` is just the opposite coord
+ let coord, rev_coord;
+
+ // set `coord` and `rev_coord` according to `x` and `y`
+ if (x > 0 || x < 0) {
+ coord = "x";
+ rev_coord = "y";
+ } else if (y > 0 || y < 0) {
+ coord = "y";
+ rev_coord = "x";
+ } else { // something unexpected was given
+ return false;
+ }
+
+ // this is the distance between each point which we check for
+ // selectable elements, increasing this improves performance,
+ // but lowers accuracy, and likewise in reverse
+ let jump_distance = 5;
+
+ // this is the coordinates to check in the direct coord check
+ let check = {
+ x: relative.x,
+ y: relative.y
+ }
+
+ // this will contain the element that directly next to
+ // `relative_el` from checking every point from `relative_el`
+ // into `direction`
+ let direct_el;
+
+ // this is the amount of pixels inbetween `relative_el` and
+ // `direct_el`, this means it doesn't have the distance from the
+ // center of `direct_el` or anything included, just the raw
+ // distance between them, this number could vary in accuracy
+ // depending on how big or small `jump_distance` is
+ let direct_distance = 0;
+
+ // attempt to find an element from a straight line from
+ // `relative_el`, by checking whether there's an element at each
+ // point in the `direction` specified
+ while (! direct_el) {
+ // add `jump_distance` to the coordinates we're checking
+ check.x += x * jump_distance;
+ check.y += y * jump_distance;
+
+ // get the elements at the coordinates we're checking
+ let els_at = document.elementsFromPoint(check.x, check.y);
+
+ // run through all the elements we found
+ for (let i = 0; i < els_at.length; i++) {
+ // make sure `els_at[i]` isn't `relative_el` and that
+ // its a selectable element
+ if (els_at[i] == relative_el
+ || ! els.includes(els_at[i])) {
+
+ // not selectable or is `relative_el`
+ continue;
+ }
+
+ // set `direct_el`
+ direct_el = els_at[i];
+
+ // get the bounds of `direct_el`
+ let direct_bounds = direct_el.getBoundingClientRect();
+
+ // get the centered coordinates for `direct_el`
+ let direct_coords = {
+ x: direct_bounds.left + (direct_bounds.width / 2),
+ y: direct_bounds.top + (direct_bounds.height / 2)
+ }
+
+ // update the coordinates on the element itself
+ direct_el.coords = direct_coords;
+
+ // get the difference between `relative_el` and
+ // `direct_el`'s coordinates, effectively their distance
+ let diff_x = direct_coords.x - relative.x;
+ let diff_y = direct_coords.y - relative.y;
+
+ // make sure this element is marked as the element that
+ // was found directly
+ direct_el.is_direct_el = true;
+
+ // update the distance on the element itself
+ direct_el.distance = Math.sqrt(
+ diff_x*diff_x + diff_y*diff_y
+ )
+
+ // set the distance on the coord we're checking on the
+ // element itself
+ direct_el.coord_distance = direct_distance;
+
+ break; // we found the `direct_el` we can stop now
+ }
+
+ // if `els_at` has `relative_el` then we reset
+ // `direct_distance`
+ if (els_at.includes(relative_el)) {
+ direct_distance = 0;
+ } else {
+ // add `jump_distance` to `direct_distance`, because
+ // we're no longer on the `relative_el` nor the
+ // `direct_el`
+ direct_distance += jump_distance;
+ }
+
+ // are we beyond the edges of the window
+ if (check.x < 0 || check.y < 0 ||
+ check.x > innerWidth || check.y > innerHeight) {
+
+ // did we find no elements?
+ if (! els_at.length) {
+ break; // stop searching
+ }
+ }
+ }
+
+ // this contains elements in the respective directions
+ let positions = {
+ up: [], down: [],
+ left: [], right: []
+ }
+
+ // gets the nearest elements from the selectable elements
+ for (let i = 0; i < els.length; i++) {
+ // get bounds
+ let el_bounds = els[i].getBoundingClientRect();
+
+ // get centered coordinates
+ let el_coords = {
+ x: el_bounds.left + (el_bounds.width / 2),
+ y: el_bounds.top + (el_bounds.height / 2)
+ }
+
+ // get the difference between `el_coords` and `direct_el`'s
+ // coordinates, effectively their distance
+ let diff_x = el_coords.x - relative.x;
+ let diff_y = el_coords.y - relative.y;
+
+ // is this element not an element that was previously a
+ // `direct_el`?
+ if (! els[i].is_direct_el) {
+ // update centerd coordinates on the element itself
+ els[i].coords = el_coords;
+
+ // set the distance on the element itself
+ els[i].distance = Math.sqrt(
+ diff_x*diff_x + diff_y*diff_y
+ )
+
+ // get and set the distance on the coord we're checking
+ // on the element itself
+ els[i].coord_distance = Math.abs(
+ relative[coord] - el_coords[coord]
+ )
+ } else {
+ els[i].is_direct_el = false;
+ continue;
+ }
+
+ // put `els[i]` in the correct place in `positions`
+ if (el_coords.x < relative.x) {
+ positions.left.push(els[i]);
+ } if (el_coords.x > relative.x) {
+ positions.right.push(els[i]);
+ } if (el_coords.y < relative.y) {
+ positions.up.push(els[i]);
+ } if (el_coords.y > relative.y) {
+ positions.down.push(els[i]);
+ }
+ }
+
+ // this will contain the element closest to `relative_el` in the
+ // correct direction, but not necessarily at the same
+ // coordinates
+ let closest_el;
+
+ // set `closest_el` to the elements closest element in the
+ // respective position using `direction`
+ for (let i = 0; i < positions[direction].length; i++) {
+ // get the element
+ let el = positions[direction][i];
+
+ // is this the first check, or is it closer than the
+ // previous `closest_el`, then update `closest_el`
+ if (! closest_el || closest_el.distance > el.distance) {
+ closest_el = el;
+ }
+ }
+
+ // was there found a `closest_el` and `direct_el`
+ if (closest_el && direct_el) {
+ // simply return `direct_el` if its the same as `closest_el`
+ if (closest_el == direct_el) {
+ return direct_el;
+ }
+
+ // if the parent element of `closest_el` and `direct_el` is
+ // the same, then we prefer the `direct_el`
+ //
+ // unless the parent is `document.body`
+ if (closest_el.parentElement == direct_el.parentElement &&
+ direct_el.parentElement !== document.body) {
+ return direct_el;
+ }
+
+ // get the difference between `relative_el` and `direct_el`
+ // on the coordinate of `direction`
+ let same_coord_diff = Math.abs(
+ direct_el.coords[rev_coord] -
+ relative_el.coords[rev_coord]
+ )
+
+ // if the difference is less than 3 then we just return the
+ // `direct_el` as its only a couple pixels off being on the
+ // same coordinate as `relative_el`
+ if (same_coord_diff < 3) {
+ return direct_el;
+ }
+
+ // get the difference is distance on `direct_el` and
+ // `closest_el`
+ let difference = Math.abs(
+ direct_el.distance - closest_el.distance
+ )
+
+ // is the distance les than 50?
+ if (difference < 50) {
+ // get the difference between `direct_el` and
+ // `relative_el`
+ let direct_diff = Math.abs(
+ direct_el.coords[rev_coord] -
+ relative_el.coords[rev_coord]
+ )
+
+ // get the difference between `closest_el` and
+ // `relative_el`
+ let closest_diff = Math.abs(
+ closest_el.coords[rev_coord] -
+ relative_el.coords[rev_coord]
+ )
+
+ // if the `direct_el` is closer to `relative_el`, return
+ // that, otherwise return `closet_el`
+ if (direct_diff < closest_diff) {
+ return direct_el;
+ } else if (closest_diff < direct_diff) {
+ return closest_el;
+ }
+ }
+
+ // is `direct_el` closer than `closest_el` in either
+ // `.coord_distance` or `.distance`
+ //
+ // if not just return `closest_el`
+ if (direct_el.coord_distance <= closest_el.coord_distance ||
+ direct_el.distance <= closest_el.distance) {
+
+ // if `direct_el` is closer in `.coord_distance` then
+ // return `direct_el`
+ if (direct_el.coord_distance <=
+ closest_el.coord_distance) {
+
+ return direct_el;
+ }
+
+ // if the difference in `.distance` is less than 50,
+ // then return `direct_el`
+ if (difference < 50) {
+ return direct_el;
+ }
+
+ // if the `.distance` is overall closer on `direct_el`
+ // than `closest_el` then we return `direct_el`,
+ // otherwise we return `closest_el`
+ if (direct_el.distance < closest_el.distance) {
+ return direct_el;
+ } else {
+ return closest_el;
+ }
+ } else { // `direct_el` is unarguably too far away
+ return closest_el;
+ }
+ } else if (! direct_el && ! closest_el) {
+ // do nothing if no element at all was found
+ return false;
+ }
+
+ // return whichever element we did find
+ return direct_el || closest_el;
+ }
+
+ // translate `direction` into `get_el()` args
+ switch(direction) {
+ case "up": return get_el(0, -1);
+ case "down": return get_el(0, 1);
+ case "left": return get_el(-1, 0);
+ case "right": return get_el(1, 0);
+ }
+}
+
+// contains a list of the last selections we had before a popup was
+// opened, letting us go back to those selections when they're closed
+let last_popup_selections = [];
+
+// attempt to reselect the default selection when a popup is either
+// closed or opened
+events.on("popup-changed", (e) => {
+ // get the active selection
+ let active_el = document.querySelector(".active-selection");
+
+ // make sure there is a selection
+ if (! active_el) {
+ return;
+ }
+
+ // add `active_el` to `last_popup_selections` if we opened a popup
+ if (e.new_state) {
+ last_popup_selections.push({
+ el: active_el,
+ popup: e.popup
+ })
+ } else { // we're closing a popup
+ // this may contain the element we had opened before the popup
+ // we're closing was opened
+ let last_selection;
+
+ // remove selections that are for this popup
+ last_popup_selections = last_popup_selections.filter((item) => {
+ // is this selection for this popup?
+ let is_popup = item.popup == e.popup;
+
+ // set `last_selection` to `.el` if its this popup we're
+ // closing, thereby getting the last selection made before
+ // we opened this popup
+ if (is_popup) {
+ last_selection = item.el;
+ }
+
+ return ! is_popup;
+ })
+
+ // select `last_selection` if one was found
+ if (last_selection) {
+ setTimeout(() => {
+ navigate.selection(last_selection);
+ }, 150) // needed due to popup animation
+
+ return;
+ }
+ }
+
+ // remove the currently active selection
+ active_el.classList.remove("active-selection");
+
+ // update the `#selection` element
+ navigate.selection();
+
+ // wait a moment to allow the popup to open or close completely
+ setTimeout(() => {
+ // select the default selection
+ navigate.default_selection();
+ }, 300)
+})
+
+// automatically deselect a selection if its no longer visible
+setInterval(() => {
+ // get the active selection
+ let active_el = document.querySelector(".active-selection");
+
+ if (! active_el) {return}
+
+ let visible = active_el.checkVisibility({
+ checkOpacity: true,
+ visibilityProperty: true,
+ checkVisibilityCSS: true,
+ contentVisibilityAuto: true
+ })
+
+ if (! visible) {
+ navigate.default_selection();
+ }
+}, 500)
+
+module.exports = navigate;
diff --git a/src/app/js/settings.js b/src/app/js/settings.js
index 3aa9c43..b5ef773 100644
--- a/src/app/js/settings.js
+++ b/src/app/js/settings.js
@@ -286,6 +286,15 @@ settings.popup.load = () => {
}
settings.popup.switch = (el, state) => {
+ if (! el) {return}
+
+ // prevent switches from being switched when disabled
+ if (el.getAttribute("disabled") != null
+ || el.classList.contains("disabled")) {
+
+ return;
+ }
+
if (state) {
return el.classList.add("on");
} else if (state === false) {
diff --git a/src/app/js/tooltip.js b/src/app/js/tooltip.js
index f2d97a0..13dcd67 100644
--- a/src/app/js/tooltip.js
+++ b/src/app/js/tooltip.js
@@ -128,5 +128,3 @@ document.addEventListener("mousedown", tooltip_event);
document.addEventListener("mousemove", tooltip_event);
document.addEventListener("mouseenter", tooltip_event);
document.addEventListener("mouseleave", tooltip_event);
-
-module.exports = tooltip;
diff --git a/src/app/main.css b/src/app/main.css
index 3f77ac4..9ade423 100644
--- a/src/app/main.css
+++ b/src/app/main.css
@@ -5,6 +5,7 @@
@import "css/tooltip.css";
@import "css/theming.css";
@import "css/launcher.css";
+@import "css/selection.css";
body {
margin: 0;
diff --git a/src/app/main.js b/src/app/main.js
index 3adb96d..0a84e66 100644
--- a/src/app/main.js
+++ b/src/app/main.js
@@ -44,5 +44,8 @@ const launcher = require("./js/launcher");
const is_running = require("./js/is_running");
const set_buttons = require("./js/set_buttons");
+const navigate = require("./js/navigate");
+const gamepad = require("./js/gamepad");
+
require("./js/dom_events");
require("./js/localize")();