aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
author0neGal <mail@0negal.com>2024-06-14 23:30:39 +0200
committer0neGal <mail@0negal.com>2024-06-14 23:30:39 +0200
commit138464759f45a463104bfc6ca92a118e7a74101c (patch)
treecdbfaefd20410e786971118bb379cca50140eb96 /src/app
parent04b0e9fcea6c60257d7bc68994103eacb340a82b (diff)
downloadViper-138464759f45a463104bfc6ca92a118e7a74101c.tar.gz
Viper-138464759f45a463104bfc6ca92a118e7a74101c.zip
initial gamepad/keyboard navigation support
Far from complete, but this does the bulk of the work, the rest is just fixing places where the selection moves in weird ways after doing some things, and overall improving the look and feel of it.
Diffstat (limited to 'src/app')
-rw-r--r--src/app/css/launcher.css9
-rw-r--r--src/app/css/selection.css19
-rw-r--r--src/app/css/theming.css2
-rw-r--r--src/app/index.html8
-rw-r--r--src/app/js/gamepad.js301
-rw-r--r--src/app/js/navigate.js688
-rw-r--r--src/app/js/tooltip.js2
-rw-r--r--src/app/main.css1
-rw-r--r--src/app/main.js3
9 files changed, 1024 insertions, 9 deletions
diff --git a/src/app/css/launcher.css b/src/app/css/launcher.css
index c43bce1..694acf1 100644
--- a/src/app/css/launcher.css
+++ b/src/app/css/launcher.css
@@ -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..04e1d5d
--- /dev/null
+++ b/src/app/css/selection.css
@@ -0,0 +1,19 @@
+#selection {
+ z-index: 99999999999999;
+
+ transition: 0.15s ease-in-out;
+ transition-property:
+ opacity, border-radius,
+ 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);
+}
+
+#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 ffc13de..40b64a0 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%%
@@ -204,7 +206,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>
@@ -217,7 +219,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>
diff --git a/src/app/js/gamepad.js b/src/app/js/gamepad.js
new file mode 100644
index 0000000..abe3db5
--- /dev/null
+++ b/src/app/js/gamepad.js
@@ -0,0 +1,301 @@
+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 = {};
+
+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) {
+ 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;
+
+ // interpret `ii` as a specific button/action, using the
+ // standard IDs: https://w3c.github.io/gamepad/#remapping
+ switch(ii) {
+ case 4: // switch tab (prev)
+ case 5: // switch tab (next)
+
+ case 12: navigate.move("up"); break;
+ case 13: navigate.move("down"); break;
+ case 14: navigate.move("left"); break;
+ case 15: navigate.move("right"); break;
+
+ case buttons.accept: navigate.select(); break;
+
+ case buttons.cancel: popups.set_all(false); 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] = 3;
+ }
+ }
+
+ // 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();
+
+ // move selection
+ case "ArrowUp": return navigate.move("up")
+ case "ArrowDown": return navigate.move("down")
+ case "ArrowLeft": return navigate.move("left")
+ 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) {
+ case "Space": return selection_el
+ .classList.remove("keyboard-selecting");
+
+ case "Enter": return selection_el
+ .classList.remove("keyboard-selecting");
+ }
+})
diff --git a/src/app/js/navigate.js b/src/app/js/navigate.js
new file mode 100644
index 0000000..d9744fa
--- /dev/null
+++ b/src/app/js/navigate.js
@@ -0,0 +1,688 @@
+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 = () => {
+ // 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";
+}
+
+// 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]"
+ ])]
+
+ // 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",
+ ".popup:not(.shown)"
+ ]
+
+ // check if `els[i].closest()` matches on any of the elements
+ // inside of `ignore_closest`
+ for (let ii = 0; ii < ignore_closest.length; ii++) {
+ if (els[i].closest(ignore_closest[ii])) {
+ // 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 = (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;
+ }
+ }
+
+ // 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);
+ }
+}
+
+// attempt to reselect the default selection when a popup is either
+// closed or opened
+events.on("popup-changed", () => {
+ // get the active selection
+ let active_el = document.querySelector(".active-selection");
+
+ // make sure there is a selection
+ if (! active_el) {
+ 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)
+})
+
+module.exports = navigate;
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")();