From 720bbeedd799d6adf3b2fe2417d5e2c94e2049fc Mon Sep 17 00:00:00 2001 From: Pihkaal Date: Sat, 13 Dec 2025 19:17:28 +0100 Subject: [PATCH] feat(utils): add button navigation and selector --- public/images/utils/selector-corner.webp | Bin 0 -> 80 bytes src/utils/buttonNavigation.ts | 146 +++++++++++++++++++++++ src/utils/buttonSelector.ts | 79 ++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 public/images/utils/selector-corner.webp create mode 100644 src/utils/buttonNavigation.ts create mode 100644 src/utils/buttonSelector.ts diff --git a/public/images/utils/selector-corner.webp b/public/images/utils/selector-corner.webp new file mode 100644 index 0000000000000000000000000000000000000000..a75bc7f6dc8586c0cde4e17ab0c763223353f5bf GIT binary patch literal 80 zcmWIYbaV4yU|~OwcT5N)g}?n6`xDlih#D k8@sg(L~b5)S9?DH(4T* { + up?: T | "last"; + down?: T | "last"; + left?: T; + right?: T; + horizontalMode?: "navigate" | "preview"; +} + +export class ButtonNavigation { + private selectedButton: T; + private nextButton?: T; + private buttons: Record; + private navigation: Record>; + private onButtonClick?: (buttonName: T) => void; + private keydownHandler: (event: KeyboardEvent) => void; + + constructor(config: { + buttons: Record; + initialButton: T; + navigation: Record>; + onButtonClick?: (buttonName: T) => void; + }) { + this.buttons = config.buttons; + this.selectedButton = config.initialButton; + this.navigation = config.navigation; + this.onButtonClick = config.onButtonClick; + + // TODO: this should be handled by the nds itself i think, and dispatched + this.keydownHandler = this.handleKeyPress.bind(this); + window.addEventListener("keydown", this.keydownHandler); + } + + public getSelectedButton(): T { + return this.selectedButton; + } + + public getSelectorPosition(): ButtonRect { + return this.buttons[this.selectedButton]; + } + + public handleTouch(x: number, y: number): void { + for (const [buttonName, config] of Object.entries(this.buttons) as [ + T, + ButtonRect, + ][]) { + const [sx, sy, sw, sh] = config; + if (x >= sx && x <= sx + sw && y >= sy && y <= sy + sh) { + if (this.selectedButton === buttonName) { + this.onButtonClick?.(buttonName); + } else { + if ( + (this.navigation[buttonName].down === "last" && + this.navigation[this.selectedButton]!.up === buttonName) || + (this.navigation[buttonName].up === "last" && + this.navigation[this.selectedButton]!.down === buttonName) + ) { + this.nextButton = this.selectedButton; + } + + this.selectedButton = buttonName; + } + break; + } + } + } + + private handleKeyPress(event: KeyboardEvent): void { + const currentButton = this.selectedButton; + const currentNav = this.navigation[currentButton]; + + if (!currentNav) return; + + switch (event.key) { + case "ArrowUp": + if (!currentNav.up) return; + + if (currentNav.up === "last") { + if (this.nextButton) { + this.selectedButton = this.nextButton; + } else { + this.selectedButton = (currentNav.left ?? currentNav.right) as T; + } + } else { + if (this.navigation[currentNav.up].down === "last") { + this.nextButton = this.selectedButton; + } + this.selectedButton = currentNav.up; + } + + break; + + case "ArrowDown": + if (!currentNav.down) return; + + if (currentNav.down === "last") { + if (this.nextButton) { + this.selectedButton = this.nextButton; + } else { + this.selectedButton = (currentNav.left ?? currentNav.right) as T; + } + } else { + if (this.navigation[currentNav.down].up === "last") { + this.nextButton = this.selectedButton; + } + this.selectedButton = currentNav.down; + } + break; + + case "ArrowLeft": + if (!currentNav.left) return; + + if (currentNav.horizontalMode === "preview") { + this.nextButton = currentNav.left; + } else { + this.selectedButton = currentNav.left; + } + break; + + case "ArrowRight": + if (!currentNav.right) return; + + if (currentNav.horizontalMode === "preview") { + this.nextButton = currentNav.right; + } else { + this.selectedButton = currentNav.right; + } + break; + + case "Enter": + case " ": + this.onButtonClick?.(this.selectedButton); + break; + + default: + return; + } + + event.preventDefault(); + } + + public destroy(): void { + window.removeEventListener("keydown", this.keydownHandler); + } +} diff --git a/src/utils/buttonSelector.ts b/src/utils/buttonSelector.ts new file mode 100644 index 0000000..d741c43 --- /dev/null +++ b/src/utils/buttonSelector.ts @@ -0,0 +1,79 @@ +import { ImageLoader } from "./loadImages"; +import type { ButtonRect } from "./buttonNavigation"; + +const ANIMATION_SPEED = 0.25; + +export class ButtonSelector { + private images = new ImageLoader({ + corner: "/images/utils/selector-corner.webp", + }); + + private currentX = 0; + private currentY = 0; + private currentWidth = 0; + private currentHeight = 0; + + constructor(initialRect: ButtonRect) { + [this.currentX, this.currentY, this.currentWidth, this.currentHeight] = + initialRect; + } + + public render( + ctx: CanvasRenderingContext2D, + targetRect: ButtonRect, + opacity: number = 1, + ): void { + if (!this.images.isReady) return; + + const [targetX, targetY, targetWidth, targetHeight] = targetRect; + const dx = targetX - this.currentX; + const dy = targetY - this.currentY; + const dw = targetWidth - this.currentWidth; + const dh = targetHeight - this.currentHeight; + + if ( + Math.abs(dx) < 0.5 && + Math.abs(dy) < 0.5 && + Math.abs(dw) < 0.5 && + Math.abs(dh) < 0.5 + ) { + [this.currentX, this.currentY, this.currentWidth, this.currentHeight] = + targetRect; + } else { + this.currentX += dx * ANIMATION_SPEED; + this.currentY += dy * ANIMATION_SPEED; + this.currentWidth += dw * ANIMATION_SPEED; + this.currentHeight += dh * ANIMATION_SPEED; + } + + const x = Math.floor(this.currentX); + const y = Math.floor(this.currentY); + const w = Math.floor(this.currentWidth); + const h = Math.floor(this.currentHeight); + + const corner = this.images.require("corner"); + + ctx.globalAlpha = opacity; + + // top-left + ctx.drawImage(corner, x, y); + + // top-right + ctx.save(); + ctx.scale(-1, 1); + ctx.drawImage(corner, -(x + w), y); + ctx.restore(); + + // bottom-left + ctx.save(); + ctx.scale(1, -1); + ctx.drawImage(corner, x, -(y + h)); + ctx.restore(); + + // bottom-right + ctx.save(); + ctx.scale(-1, -1); + ctx.drawImage(corner, -(x + w), -(y + h)); + ctx.restore(); + } +}