From d771cda89de2281f035401ea64adea52693577d4 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 26 Mar 2025 21:06:16 +0100 Subject: [PATCH] Refactor and Split Up Some Code This refactors some code to use CSS modules, functional React components and splits up the Settings component into roughly one component per setting. --- src/css/About.module.scss | 84 + src/css/About.scss | 89 - src/css/{Dialog.scss => Dialog.module.scss} | 13 +- ...DragUpload.scss => DragUpload.module.scss} | 8 +- ...eyButton.scss => HotkeyButton.module.scss} | 29 +- src/css/Layout.module.scss | 40 + src/css/Layout.scss | 42 - src/css/RunEditor.scss | 81 - src/css/TextBox.module.scss | 79 + src/css/mobile.module.scss | 5 + src/storage/{index.tsx => index.ts} | 0 src/ui/LiveSplit.tsx | 4 +- src/ui/components/ColorPicker.tsx | 2 +- src/ui/components/Dialog.tsx | 175 +- src/ui/components/DragUpload.tsx | 75 +- src/ui/components/HotkeyButton.tsx | 203 +- src/ui/components/Layout.tsx | 171 +- src/ui/components/Settings.tsx | 1752 ----------------- src/ui/components/Settings/Accuracy.tsx | 35 + src/ui/components/Settings/Alignment.tsx | 34 + src/ui/components/Settings/Color.tsx | 79 + src/ui/components/Settings/Column.tsx | 139 ++ src/ui/components/Settings/DigitsFormat.tsx | 37 + src/ui/components/Settings/Font.tsx | 177 ++ src/ui/components/Settings/Gradient.tsx | 384 ++++ .../components/Settings/LayoutBackground.tsx | 236 +++ .../components/Settings/LayoutDirection.tsx | 33 + .../Settings/ServerConnectionButton.tsx | 43 + src/ui/components/Settings/String.tsx | 174 ++ src/ui/components/Settings/TimingMethod.tsx | 54 + src/ui/components/Settings/index.tsx | 645 ++++++ src/ui/components/Switch.tsx | 2 +- src/ui/components/TextBox.tsx | 89 +- src/ui/views/About.tsx | 168 +- src/ui/views/RunEditor.tsx | 2 - src/ui/views/SplitsSelection.tsx | 2 +- src/ui/views/TimerView.tsx | 4 +- src/util/AutoRefresh.tsx | 72 +- src/util/OptionUtil.tsx | 2 +- 39 files changed, 2773 insertions(+), 2490 deletions(-) create mode 100644 src/css/About.module.scss delete mode 100644 src/css/About.scss rename src/css/{Dialog.scss => Dialog.module.scss} (85%) rename src/css/{DragUpload.scss => DragUpload.module.scss} (88%) rename src/css/{HotkeyButton.scss => HotkeyButton.module.scss} (54%) create mode 100644 src/css/Layout.module.scss delete mode 100644 src/css/Layout.scss create mode 100644 src/css/TextBox.module.scss create mode 100644 src/css/mobile.module.scss rename src/storage/{index.tsx => index.ts} (100%) delete mode 100644 src/ui/components/Settings.tsx create mode 100644 src/ui/components/Settings/Accuracy.tsx create mode 100644 src/ui/components/Settings/Alignment.tsx create mode 100644 src/ui/components/Settings/Color.tsx create mode 100644 src/ui/components/Settings/Column.tsx create mode 100644 src/ui/components/Settings/DigitsFormat.tsx create mode 100644 src/ui/components/Settings/Font.tsx create mode 100644 src/ui/components/Settings/Gradient.tsx create mode 100644 src/ui/components/Settings/LayoutBackground.tsx create mode 100644 src/ui/components/Settings/LayoutDirection.tsx create mode 100644 src/ui/components/Settings/ServerConnectionButton.tsx create mode 100644 src/ui/components/Settings/String.tsx create mode 100644 src/ui/components/Settings/TimingMethod.tsx create mode 100644 src/ui/components/Settings/index.tsx diff --git a/src/css/About.module.scss b/src/css/About.module.scss new file mode 100644 index 0000000..343ec18 --- /dev/null +++ b/src/css/About.module.scss @@ -0,0 +1,84 @@ +@use "mobile.module.scss"; +@use "variables.icss"; + +$icon-size: 40px; +$title-font-size: 40px; +$build-version-font-size: 12px; +$link-color: #56b0ff; + +.about { + max-width: 700px; + + @include mobile.mobile { + max-width: 100%; + } +} + +.aboutInnerContainer { + background-color: variables.$light-row-color; + padding: variables.$ui-large-margin; + border: 1px solid variables.$border-color; + width: fit-content; + + h2 { + margin-bottom: variables.$ui-large-margin; + } + + a { + color: $link-color; + } + + @include mobile.mobile { + box-sizing: border-box; + } +} + +.livesplitTitle { + display: flex; + align-items: center; + gap: variables.$ui-margin; +} + +.livesplitIcon { + height: $icon-size; +} + +.titleText { + font-weight: bold; + font-size: $title-font-size; +} + +.buildVersion { + font-size: $build-version-font-size; +} + +.changelog { + margin-left: variables.$ui-large-margin; + + >div { + margin: variables.$ui-large-margin 0 variables.$ui-large-margin variables.$ui-large-margin; + } +} + +.contributors { + margin: 0 auto; + display: flex; + flex-wrap: wrap; + gap: 14px; + justify-content: space-evenly; + align-items: end; + font-size: 14px; + + a { + display: flex; + align-items: center; + flex-direction: column; + gap: 4px; + + img { + width: variables.$contributor-avatar-size; + height: variables.$contributor-avatar-size; + border-radius: 50%; + } + } +} diff --git a/src/css/About.scss b/src/css/About.scss deleted file mode 100644 index 16caaf8..0000000 --- a/src/css/About.scss +++ /dev/null @@ -1,89 +0,0 @@ -@use "mobile"; -@use "variables.icss"; - -$icon-size: 40px; -$title-font-size: 40px; -$build-version-font-size: 12px; -$link-color: #56b0ff; - -.about { - .about-inner-container { - background-color: variables.$light-row-color; - padding: variables.$ui-large-margin; - border: 1px solid variables.$border-color; - width: fit-content; - - .livesplit-title { - display: flex; - align-items: center; - - .livesplit-icon { - height: $icon-size; - margin-right: variables.$ui-margin; - - img { - height: 100%; - } - } - - .title-text { - font-weight: bold; - font-size: $title-font-size; - } - } - - .build-version { - font-size: $build-version-font-size; - } - - h2 { - margin-bottom: variables.$ui-large-margin; - } - - a { - color: $link-color; - } - - .changelog { - margin-left: variables.$ui-large-margin; - - > div { - margin: variables.$ui-large-margin 0 variables.$ui-large-margin - variables.$ui-large-margin; - } - } - - .contributors { - margin: 0 auto; - display: flex; - flex-wrap: wrap; - gap: 14px; - justify-content: space-evenly; - align-items: end; - font-size: 14px; - - a { - display: flex; - align-items: center; - flex-direction: column; - gap: 4px; - - img { - width: variables.$contributor-avatar-size; - height: variables.$contributor-avatar-size; - border-radius: 50%; - } - } - } - - @include mobile.mobile { - box-sizing: border-box; - } - } - - max-width: 700px; - - @include mobile.mobile { - max-width: 100%; - } -} diff --git a/src/css/Dialog.scss b/src/css/Dialog.module.scss similarity index 85% rename from src/css/Dialog.scss rename to src/css/Dialog.module.scss index a59053e..ff7b71b 100644 --- a/src/css/Dialog.scss +++ b/src/css/Dialog.module.scss @@ -1,6 +1,6 @@ @use "variables.icss"; -.is-mobile + dialog > .dialog { +:global(.is-mobile) + dialog > .dialog { min-width: auto; } @@ -20,13 +20,19 @@ dialog { border: 2px solid rgba(255, 255, 255, 0.25); border-radius: 10px; min-width: 225px; + display: flex; + flex-direction: column; + gap: variables.$ui-large-margin; max-width: 400px; padding: variables.$ui-large-margin; h1 { - font-size: 20px; - margin: 5px 0; + font-size: larger; + } + + >* { + margin: 0; } .buttons { @@ -49,7 +55,6 @@ dialog { } input { - width: 100%; border: none; border-bottom: 1px solid rgba(255, 255, 255, 0.25); background: transparent; diff --git a/src/css/DragUpload.scss b/src/css/DragUpload.module.scss similarity index 88% rename from src/css/DragUpload.scss rename to src/css/DragUpload.module.scss index 560c01a..2b416a4 100644 --- a/src/css/DragUpload.scss +++ b/src/css/DragUpload.module.scss @@ -1,14 +1,14 @@ -@use "mobile"; +@use "mobile.module.scss"; @use "variables.icss"; -#upload-drop-zone { +.uploadDropZone { position: absolute; top: 0; left: 0; right: 0; bottom: 0; - #upload-drop-zone-overlay { + .overlay { position: absolute; width: calc(100% + 2 * #{variables.$main-content-margin}); height: calc(100% + 2 * #{variables.$main-content-margin}); @@ -27,7 +27,7 @@ margin: 0; } - .overlay-text { + .overlayText { font-size: 50px; text-align: center; } diff --git a/src/css/HotkeyButton.scss b/src/css/HotkeyButton.module.scss similarity index 54% rename from src/css/HotkeyButton.scss rename to src/css/HotkeyButton.module.scss index ad56ee8..c03c07c 100644 --- a/src/css/HotkeyButton.scss +++ b/src/css/HotkeyButton.module.scss @@ -1,23 +1,32 @@ @use "variables.icss"; -.hotkey-box { +.hotkeyBox { button { - margin: 0; font-size: 16px; min-height: 22px; padding-top: 0; padding-bottom: 0; } - .hotkey-button.focused { - color: red; - } - - .trash { - cursor: pointer; - } - display: grid; grid-template-columns: 1fr 20px; column-gap: variables.$ui-margin; + align-items: center; +} + +.focused { + color: red; +} + +.trash { + cursor: pointer; +} + +.overlay { + position: fixed; + bottom: 0; + left: 0; + right: 0; + top: 0; + z-index: 5; } diff --git a/src/css/Layout.module.scss b/src/css/Layout.module.scss new file mode 100644 index 0000000..2f475b6 --- /dev/null +++ b/src/css/Layout.module.scss @@ -0,0 +1,40 @@ +@use "Font"; + +.resizableLayout { + position: absolute; + top: 0; + width: inherit; + height: inherit; + + :global(.react-resizable) { + width: 0 !important; + height: 0 !important; + } +} + +.handleEast { + cursor: e-resize; + right: -10px; + bottom: 10px; + top: 0; + position: absolute; + width: 20px; +} + +.handleSouth { + cursor: s-resize; + bottom: -10px; + left: 0; + right: 10px; + position: absolute; + height: 20px; +} + +.handleSouthEast { + cursor: se-resize; + bottom: -10px; + right: -10px; + position: absolute; + width: 20px; + height: 20px; +} diff --git a/src/css/Layout.scss b/src/css/Layout.scss deleted file mode 100644 index f4c4674..0000000 --- a/src/css/Layout.scss +++ /dev/null @@ -1,42 +0,0 @@ -@use "Font"; - -.layout { - .resizable-layout { - position: absolute; - top: 0; - width: inherit; - height: inherit; - - .react-resizable { - width: 0 !important; - height: 0 !important; - } - - .resizable-handle-east { - cursor: e-resize; - right: -10px; - bottom: 10px; - top: 0; - position: absolute; - width: 20px; - } - - .resizable-handle-south { - cursor: s-resize; - bottom: -10px; - left: 0; - right: 10px; - position: absolute; - height: 20px; - } - - .resizable-handle-south-east { - cursor: se-resize; - bottom: -10px; - right: -10px; - position: absolute; - width: 20px; - height: 20px; - } - } -} diff --git a/src/css/RunEditor.scss b/src/css/RunEditor.scss index bed42e6..f5135b5 100644 --- a/src/css/RunEditor.scss +++ b/src/css/RunEditor.scss @@ -9,7 +9,6 @@ $tab-bar-height: 30px; $tab-width: 625px; $button-width: 160px; -$label-size: 14px; $segment-icon-size: 19px; $mobile-game-icon-size: 100px; @@ -326,86 +325,6 @@ $small-button-padding: 1px 3px 1px 3px; } } - .group { - position: relative; - border-bottom: 1px solid variables.$border-color; - - > input { - font-size: 18px; - padding: $label-size + variables.$ui-margin 0 - math.div(variables.$ui-margin, 2) - math.div(variables.$ui-margin, 2); - display: block; - width: 100%; - border: none; - background: transparent; - color: white; - font-family: "fira", sans-serif; - } - - > input:focus { - outline: none; - } - - > label { - color: hsla(50, 0%, 75%, 1); - font-size: $label-size; - font-weight: normal; - position: absolute; - pointer-events: none; - left: math.div(variables.$ui-margin, 2); - top: 0; - transition: 0.2s ease all; - -moz-transition: 0.2s ease all; - -webkit-transition: 0.2s ease all; - } - - > input:focus ~ label { - color: hsla(50, 100%, 50%, 1); - } - - &.invalid > input:focus ~ label { - color: hsla(0, 100%, 50%, 1); - } - - > .bar { - position: relative; - display: block; - width: 100%; - } - - > .bar:before, - > .bar:after { - content: ""; - height: 2px; - width: 0; - bottom: 0; - position: absolute; - background: hsla(50, 100%, 50%, 1); - transition: 0.2s ease all; - -moz-transition: 0.2s ease all; - -webkit-transition: 0.2s ease all; - } - - > .bar:before { - left: 50%; - } - - > .bar:after { - right: 50%; - } - - &.invalid > .bar:before, - &.invalid > .bar:after { - background: hsla(0, 100%, 50%, 1); - } - - > input:focus ~ .bar:before, - > input:focus ~ .bar:after { - width: 50%; - } - } - .filter-table { width: 100%; diff --git a/src/css/TextBox.module.scss b/src/css/TextBox.module.scss new file mode 100644 index 0000000..4b8e869 --- /dev/null +++ b/src/css/TextBox.module.scss @@ -0,0 +1,79 @@ +@use "sass:math"; + +@use "variables.icss"; + +$label-size: 14px; + +.group { + position: relative; + border-bottom: 1px solid variables.$border-color; + + >input { + font-size: 18px; + padding: $label-size + variables.$ui-margin 0 math.div(variables.$ui-margin, 2) math.div(variables.$ui-margin, 2); + display: block; + width: 100%; + border: none; + background: transparent; + color: white; + font-family: inherit; + } + + >input:focus { + outline: none; + } + + >label { + color: hsla(50, 0%, 75%, 1); + font-size: $label-size; + font-weight: normal; + position: absolute; + pointer-events: none; + left: math.div(variables.$ui-margin, 2); + top: 0; + transition: 0.2s ease all; + } + + >input:focus~label { + color: hsla(50, 100%, 50%, 1); + } + + &.invalid>input:focus~label { + color: hsla(0, 100%, 50%, 1); + } + + >.bar { + position: relative; + display: block; + width: 100%; + } + + >.bar:before, + >.bar:after { + content: ""; + height: 2px; + width: 0; + bottom: 0; + position: absolute; + background: hsla(50, 100%, 50%, 1); + transition: 0.2s ease all; + } + + >.bar:before { + left: 50%; + } + + >.bar:after { + right: 50%; + } + + &.invalid>.bar:before, + &.invalid>.bar:after { + background: hsla(0, 100%, 50%, 1); + } + + >input:focus~.bar:before, + >input:focus~.bar:after { + width: 50%; + } +} diff --git a/src/css/mobile.module.scss b/src/css/mobile.module.scss new file mode 100644 index 0000000..587e0de --- /dev/null +++ b/src/css/mobile.module.scss @@ -0,0 +1,5 @@ +@mixin mobile { + :global(.is-mobile) & { + @content; + } +} diff --git a/src/storage/index.tsx b/src/storage/index.ts similarity index 100% rename from src/storage/index.tsx rename to src/storage/index.ts diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 6cd7615..05cafa7 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -44,7 +44,7 @@ import { UrlCache } from "../util/UrlCache"; import { ServerProtocol, WebRenderer } from "../livesplit-core/livesplit_core"; import { LiveSplitServer } from "../api/LiveSplitServer"; import { LSOCommandSink } from "./LSOCommandSink"; -import DialogContainer from "./components/Dialog"; +import { DialogContainer } from "./components/Dialog"; import { createHotkeys, HotkeyImplementation } from "../platform/Hotkeys"; import { Menu } from "lucide-react"; @@ -350,7 +350,7 @@ export class LiveSplit extends React.Component { } public render() { - let view; + let view: React.JSX.Element | undefined; if (this.state.menu.kind === MenuKind.RunEditor) { view = ( void; - onClose: () => void; -} +import * as classes from "../../css/Dialog.module.scss"; export interface Options { title: string | React.JSX.Element; @@ -21,7 +16,7 @@ export interface State { } let dialogElement: HTMLDialogElement | null = null; -let setState: ((options: Options) => void) | undefined; +let setStateFn: ((options: Options) => void) | undefined; let resolveFn: ((_: [number, string]) => void) | undefined; let onCloseFn: (() => void) | undefined; let alreadyClosed = false; @@ -39,99 +34,91 @@ export function showDialog(options: Options): Promise<[number, string]> { } }; } - setState?.(options); + setStateFn?.(options); return new Promise((resolve) => (resolveFn = resolve)); } -export default class DialogContainer extends React.Component { - constructor(props: Props) { - super(props); +export function DialogContainer({ + onShow, + onClose, +}: { + onShow: () => void; + onClose: () => void; +}) { + const [options, setOptions] = useState({ + title: "", + description: "", + buttons: [], + }); + const [input, setInput] = useState(""); - onCloseFn = props.onClose; + useEffect(() => { + onCloseFn = onClose; + }, [onClose]); - this.state = { - options: { - title: "", - description: "", - buttons: [], - }, - input: "", + useEffect(() => { + setStateFn = (options) => { + onShow(); + setOptions(options); + setInput(options.defaultText ?? ""); }; - } + }, [onShow]); - public componentDidMount(): void { - setState = (options) => { - this.props.onShow(); - this.setState({ - options, - input: options.defaultText ?? "", - }); - }; - } - - public render() { - return ( - { - dialogElement = element; - }} - onKeyDown={(e) => { - if (e?.key === "ArrowLeft") { - e.preventDefault(); - ( - document.activeElement - ?.previousElementSibling as any - )?.focus(); - } else if (e?.key === "ArrowRight") { - e.preventDefault(); - ( - document.activeElement?.nextElementSibling as any - )?.focus(); - } - }} - > -
-

{this.state.options.title}

-

{this.state.options.description}

- {this.state.options.textInput && ( - - this.setState({ input: e.target.value }) - } - onKeyDown={(e) => { - if (e?.key === "Enter") { - e.preventDefault(); - this.close(0); - } - }} - /> - )} -
- {this.state.options.buttons.map((button, i) => { - return ( - - ); - })} -
-
-
- ); - } - - private close(i: number) { + const handleClose = (i: number) => { alreadyClosed = true; dialogElement?.close(); - resolveFn?.([i, this.state.input]); - this.props.onClose(); - } + resolveFn?.([i, input]); + onClose(); + }; + + return ( + { + dialogElement = element; + }} + onKeyDown={(e) => { + if (e?.key === "ArrowLeft") { + e.preventDefault(); + ( + document.activeElement?.previousElementSibling as any + )?.focus(); + } else if (e?.key === "ArrowRight") { + e.preventDefault(); + ( + document.activeElement?.nextElementSibling as any + )?.focus(); + } + }} + > +
+

{options.title}

+

{options.description}

+ {options.textInput && ( + setInput(e.target.value)} + onKeyDown={(e) => { + if (e?.key === "Enter") { + e.preventDefault(); + handleClose(0); + } + }} + /> + )} +
+ {options.buttons.map((button, i) => ( + + ))} +
+
+
+ ); } diff --git a/src/ui/components/DragUpload.tsx b/src/ui/components/DragUpload.tsx index 20b9233..f22ecee 100644 --- a/src/ui/components/DragUpload.tsx +++ b/src/ui/components/DragUpload.tsx @@ -1,37 +1,38 @@ -import * as React from "react"; +import React, { useEffect, useRef } from "react"; import { toast } from "react-toastify"; -import "../../css/DragUpload.scss"; +import * as classes from "../../css/DragUpload.module.scss"; -export interface Props { +export function DragUpload({ + children, + importLayout, + importSplits, +}: { children: React.ReactNode; importLayout?: (file: File) => Promise; - importSplits(file: File): Promise; -} + importSplits: (file: File) => Promise; +}) { + const dropZoneRef = useRef(null); + const dropZoneOverlayRef = useRef(null); -export default class DragUpload extends React.Component { - public componentDidMount() { - const dropZone = document.getElementById("upload-drop-zone"); - const dropZoneOverlay = document.getElementById( - "upload-drop-zone-overlay", - ); - const importLayout = this.props.importLayout; - const importSplits = this.props.importSplits; + useEffect(() => { + const dropZone = dropZoneRef.current; + const dropZoneOverlay = dropZoneOverlayRef.current; - if (dropZone === null) { + if (!dropZone) { return; } - dropZone.addEventListener("dragenter", (event) => { + const handleDragEnter = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); if (dropZoneOverlay) { dropZoneOverlay.style.visibility = "visible"; } - }); + }; - dropZone.addEventListener("dragleave", (event) => { + const handleDragLeave = (event: DragEvent) => { if ( dropZoneOverlay && (event.pageX < 10 || @@ -41,14 +42,14 @@ export default class DragUpload extends React.Component { ) { dropZoneOverlay.style.visibility = "hidden"; } - }); + }; - dropZone.addEventListener("dragover", (event) => { + const handleDragOver = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); - }); + }; - dropZone.addEventListener("drop", (event) => { + const handleDrop = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); @@ -73,17 +74,27 @@ export default class DragUpload extends React.Component { } } return null; - }); - } + }; - public render() { - return ( -
-
-
Waiting for drop...
-
- {this.props.children} + dropZone.addEventListener("dragenter", handleDragEnter); + dropZone.addEventListener("dragleave", handleDragLeave); + dropZone.addEventListener("dragover", handleDragOver); + dropZone.addEventListener("drop", handleDrop); + + return () => { + dropZone.removeEventListener("dragenter", handleDragEnter); + dropZone.removeEventListener("dragleave", handleDragLeave); + dropZone.removeEventListener("dragover", handleDragOver); + dropZone.removeEventListener("drop", handleDrop); + }; + }, [importLayout, importSplits]); + + return ( +
+
+
Waiting for drop...
- ); - } + {children} +
+ ); } diff --git a/src/ui/components/HotkeyButton.tsx b/src/ui/components/HotkeyButton.tsx index 656d281..3a69b40 100644 --- a/src/ui/components/HotkeyButton.tsx +++ b/src/ui/components/HotkeyButton.tsx @@ -1,9 +1,9 @@ -import * as React from "react"; -import { Option, map, expect } from "../../util/OptionUtil"; +import React, { useCallback, useEffect, useState } from "react"; +import { Option, expect } from "../../util/OptionUtil"; import { hotkeySystem } from "../LiveSplit"; import { Circle, Trash } from "lucide-react"; -import "../../css/HotkeyButton.scss"; +import * as classes from "../../css/HotkeyButton.module.scss"; function resolveKey(keyCode: string): Promise | string { return expect( @@ -12,104 +12,45 @@ function resolveKey(keyCode: string): Promise | string { ).resolve(keyCode); } -export interface Props { +export function HotkeyButton({ + value, + setValue, +}: { value: Option; setValue: (value: Option) => void; -} +}) { + const [listener, setListener] = useState>(null); + const [intervalHandle, setIntervalHandle] = useState>(null); + const [resolvedKey, setResolvedKey] = useState>(null); -export interface State { - listener: Option; - intervalHandle: Option; - resolvedKey: Option; -} - -export default class HotkeyButton extends React.Component { - constructor(props: Props) { - super(props); - - this.state = { - listener: null, - intervalHandle: null, - resolvedKey: null, - }; - - this.updateResolvedKey(props.value); - } - public componentDidUpdate(previousProps: Props) { - if (previousProps.value !== this.props.value) { - this.updateResolvedKey(this.props.value); - } - } - - private async updateResolvedKey(value: Option): Promise { - let resolvedKey = ""; - if (value != null) { - const matches = value.match(/(.+)\+\s*(.+)$/); - if (matches != null) { - resolvedKey = `${matches[1]}+ ${await resolveKey(matches[2])}`; - } else { - resolvedKey = await resolveKey(value); + const updateResolvedKey = useCallback( + async (value: Option): Promise => { + let resolvedKey = ""; + if (value != null) { + const matches = value.match(/(.+)\+\s*(.+)$/); + if (matches != null) { + resolvedKey = `${matches[1]}+ ${await resolveKey( + matches[2], + )}`; + } else { + resolvedKey = await resolveKey(value); + } } - } - this.setState({ resolvedKey }); - } + setResolvedKey(resolvedKey); + }, + [], + ); - public render() { - let buttonText: Option | React.JSX.Element = null; - if (this.props.value != null) { - buttonText = this.state.resolvedKey; - } else if (this.state.listener != null) { - buttonText = ( - - ); - } + useEffect(() => { + updateResolvedKey(value); + }, [value, updateResolvedKey]); - return ( -
- - {map(this.props.value, () => ( - this.props.setValue(null)} - /> - ))} - {this.state.listener != null && ( -
this.blurButton()} - /> - )} -
- ); - } + const focusButton = () => { + let newListener = listener; + let newIntervalHandle = intervalHandle; - private focusButton() { - let listener = this.state.listener; - let intervalHandle = this.state.intervalHandle; - - if (listener === null) { - listener = { + if (newListener == null) { + newListener = { handleEvent: (ev: KeyboardEvent) => { if (ev.repeat) { return; @@ -144,17 +85,17 @@ export default class HotkeyButton extends React.Component { text += "Shift + "; } text += ev.code; - this.props.setValue(text); + setValue(text); ev.preventDefault(); }, }; - window.addEventListener("keydown", listener); + window.addEventListener("keydown", newListener); } - if (intervalHandle === null) { + if (newIntervalHandle == null) { const oldButtonState: boolean[][] = []; - intervalHandle = window.setInterval(() => { + newIntervalHandle = window.setInterval(() => { const gamepads = navigator.getGamepads(); let gamepadIdx = 0; @@ -163,14 +104,14 @@ export default class HotkeyButton extends React.Component { oldButtonState[gamepadIdx] = []; } - if (gamepad !== null) { + if (gamepad != null) { let buttonIdx = 0; for (const button of gamepad.buttons) { const oldState = oldButtonState[gamepadIdx]?.[buttonIdx] ?? false; if (button.pressed && !oldState) { - this.props.setValue(`Gamepad${buttonIdx}`); + setValue(`Gamepad${buttonIdx}`); } oldButtonState[gamepadIdx][buttonIdx] = @@ -185,22 +126,52 @@ export default class HotkeyButton extends React.Component { }, 1000 / 60.0); } - this.setState({ - listener, - intervalHandle, - }); + setListener(newListener); + setIntervalHandle(newIntervalHandle); + }; + + const blurButton = () => { + if (listener != null) { + window.removeEventListener("keydown", listener); + } + if (intervalHandle != null) { + window.clearInterval(intervalHandle); + } + setListener(null); + setIntervalHandle(null); + }; + + let buttonText: Option | React.JSX.Element = null; + if (value != null) { + buttonText = resolvedKey; + } else if (listener != null) { + buttonText = ; } - private blurButton() { - if (this.state.listener != null) { - window.removeEventListener("keydown", this.state.listener); - } - if (this.state.intervalHandle != null) { - window.clearTimeout(this.state.intervalHandle); - } - this.setState({ - listener: null, - intervalHandle: null, - }); - } + return ( +
+ + {value && ( + setValue(null)} + /> + )} + {listener != null && ( +
+ )} +
+ ); } diff --git a/src/ui/components/Layout.tsx b/src/ui/components/Layout.tsx index d065c2c..d7b87f1 100644 --- a/src/ui/components/Layout.tsx +++ b/src/ui/components/Layout.tsx @@ -6,9 +6,18 @@ import AutoRefresh from "../../util/AutoRefresh"; import { UrlCache } from "../../util/UrlCache"; import { GeneralSettings } from "../views/MainSettings"; -import "../../css/Layout.scss"; +import * as classes from "../../css/Layout.module.scss"; -export interface Props { +export default function Layout({ + getState, + layoutUrlCache, + allowResize, + width, + height, + generalSettings, + renderer, + onResize, +}: { getState: () => LayoutStateRef; layoutUrlCache: UrlCache; allowResize: boolean; @@ -16,100 +25,78 @@ export interface Props { height: number; generalSettings: GeneralSettings; renderer: WebRenderer; - onResize(width: number, height: number): void; -} - -export default class Layout extends React.Component { - public refreshLayout() { - const layoutState = this.props.getState(); - const newDims = this.props.renderer.render( + onResize: (width: number, height: number) => void; +}) { + const update = () => { + const layoutState = getState(); + const newDims = renderer.render( layoutState.ptr, - this.props.layoutUrlCache.imageCache.ptr, + layoutUrlCache.imageCache.ptr, ); - if (newDims !== undefined) { - this.props.onResize(newDims[0], newDims[1]); + if (newDims != null) { + onResize(newDims[0], newDims[1]); } - } + }; - public render() { - return ( - this.refreshLayout()} - > + return ( + +
{ + element?.appendChild(renderer.element()); }} - > -
{ - element?.appendChild(this.props.renderer.element()); - }} - /> - {this.props.allowResize && ( -
- e.stopPropagation()} - className="resizable-handle-east" - /> - } - onResize={(_event, data) => - this.props.onResize( - data.size.width, - data.size.height, - ) - } - /> - e.stopPropagation()} - className="resizable-handle-south" - /> - } - onResize={(_event, data) => - this.props.onResize( - data.size.width, - data.size.height, - ) - } - /> - e.stopPropagation()} - className="resizable-handle-south-east" - /> - } - onResize={(_event, data) => - this.props.onResize( - data.size.width, - data.size.height, - ) - } - /> -
- )} -
- - ); - } + /> + {allowResize && ( +
+ e.stopPropagation()} + className={classes.handleEast} + /> + } + onResize={(_event, data) => + onResize(data.size.width, data.size.height) + } + /> + e.stopPropagation()} + className={classes.handleSouth} + /> + } + onResize={(_event, data) => + onResize(data.size.width, data.size.height) + } + /> + e.stopPropagation()} + className={classes.handleSouthEast} + /> + } + onResize={(_event, data) => + onResize(data.size.width, data.size.height) + } + /> +
+ )} +
+ + ); } diff --git a/src/ui/components/Settings.tsx b/src/ui/components/Settings.tsx deleted file mode 100644 index 2ae2dca..0000000 --- a/src/ui/components/Settings.tsx +++ /dev/null @@ -1,1752 +0,0 @@ -import * as React from "react"; -import { Color, SettingsDescriptionValueJson } from "../../livesplit-core"; -import { assertNever, expect, Option } from "../../util/OptionUtil"; -import ColorPicker from "./ColorPicker"; -import HotkeyButton from "./HotkeyButton"; -import { UrlCache } from "../../util/UrlCache"; -import { FILE_EXT_IMAGES, openFileAsArrayBuffer } from "../../util/FileUtil"; -import * as FontList from "../../util/FontList"; -import { LiveSplitServer } from "../../api/LiveSplitServer"; -import { showDialog } from "./Dialog"; -import { toast } from "react-toastify"; -import Switch from "./Switch"; -import { Trash } from "lucide-react"; - -import "../../css/Tooltip.scss"; -import "../../css/LiveSplitServerButton.scss"; - -export interface Props { - context: string; - setValue: (index: number, value: T) => void; - state: ExtendedSettingsDescriptionJson; - factory: SettingValueFactory; - editorUrlCache: UrlCache; - allComparisons: string[]; - allVariables: Set; -} - -export interface ExtendedSettingsDescriptionJson { - fields: ExtendedSettingsDescriptionFieldJson[]; -} - -export interface ExtendedSettingsDescriptionFieldJson { - text: string | React.JSX.Element; - tooltip: string | React.JSX.Element; - value: ExtendedSettingsDescriptionValueJson; -} - -export type ExtendedSettingsDescriptionValueJson = - | SettingsDescriptionValueJson - | { RemovableString: string | null } - | { - ServerConnection: { - url: string | undefined; - connection: Option; - }; - }; - -export interface SettingValueFactory { - fromBool(v: boolean): T; - fromUint(value: number): T; - fromInt(value: number): T; - fromString(value: string): T; - fromOptionalString(value: string): T; - fromOptionalEmptyString(): T; - fromRemovableString?(value: string): T; - fromRemovableEmptyString?(): T; - fromAccuracy(value: string): T | null; - fromDigitsFormat(value: string): T | null; - fromOptionalTimingMethod(value: string): T | null; - fromOptionalEmptyTimingMethod(): T; - fromColor(r: number, g: number, b: number, a: number): T; - fromOptionalColor(r: number, g: number, b: number, a: number): T; - fromOptionalEmptyColor(): T; - fromTransparentGradient(): T; - fromVerticalGradient( - r1: number, - g1: number, - b1: number, - a1: number, - r2: number, - g2: number, - b2: number, - a2: number, - ): T; - fromHorizontalGradient( - r1: number, - g1: number, - b1: number, - a1: number, - r2: number, - g2: number, - b2: number, - a2: number, - ): T; - fromAlternatingGradient( - r1: number, - g1: number, - b1: number, - a1: number, - r2: number, - g2: number, - b2: number, - a2: number, - ): T; - fromAlignment(value: string): T | null; - fromColumnKind(value: string): T | null; - fromColumnStartWith(value: string): T | null; - fromColumnUpdateWith(value: string): T | null; - fromColumnUpdateTrigger(value: string): T | null; - fromLayoutDirection(value: string): T | null; - fromFont( - name: string, - style: string, - weight: string, - stretch: string, - ): T | null; - fromEmptyFont(): T; - fromDeltaGradient(value: string): T | null; - fromBackgroundImage( - imageId: string, - brightness: number, - opacity: number, - blur: number, - ): T | null; -} - -export class JsonSettingValueFactory - implements SettingValueFactory -{ - public fromBool(v: boolean): ExtendedSettingsDescriptionValueJson { - return { Bool: v }; - } - public fromUint(_: number): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromInt(_: number): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromString(v: string): ExtendedSettingsDescriptionValueJson { - return { String: v }; - } - public fromOptionalString(_: string): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromOptionalEmptyString(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromRemovableString( - v: string, - ): ExtendedSettingsDescriptionValueJson { - return { RemovableString: v }; - } - public fromRemovableEmptyString(): ExtendedSettingsDescriptionValueJson { - return { RemovableString: null }; - } - public fromAccuracy( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromDigitsFormat( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromOptionalTimingMethod( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromOptionalEmptyTimingMethod(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromColor(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromOptionalColor(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromOptionalEmptyColor(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromTransparentGradient(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromVerticalGradient(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromHorizontalGradient(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromAlternatingGradient(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromAlignment( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromColumnKind( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromColumnStartWith( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromColumnUpdateWith( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromColumnUpdateTrigger( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromLayoutDirection( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromFont( - _name: string, - _style: string, - _weight: string, - _stretch: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromEmptyFont(): ExtendedSettingsDescriptionValueJson { - throw new Error("Not implemented"); - } - public fromDeltaGradient( - _: string, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } - public fromBackgroundImage( - _imageId: string, - _brightness: number, - _opacity: number, - _blur: number, - ): ExtendedSettingsDescriptionValueJson | null { - throw new Error("Not implemented"); - } -} - -export class SettingsComponent extends React.Component> { - public render() { - const settingsRows: React.JSX.Element[] = []; - const { factory } = this.props; - - this.props.state.fields.forEach((field, valueIndex) => { - const { value } = field; - let component; - if ("Bool" in value) { - component = ( -
- { - this.props.setValue( - valueIndex, - factory.fromBool(value), - ); - }} - /> -
- ); - } else if ("UInt" in value) { - component = ( -
- { - this.props.setValue( - valueIndex, - factory.fromUint(e.target.valueAsNumber), - ); - }} - /> -
- ); - } else if ("Int" in value) { - component = ( -
- { - this.props.setValue( - valueIndex, - factory.fromInt(e.target.valueAsNumber), - ); - }} - /> -
- ); - } else if ("String" in value) { - // FIXME: This is a hack that we need for now until the way - // settings are represented is refactored. - if ( - typeof field.text === "string" && - /^Variable/.test(field.text) - ) { - if (this.props.allVariables.size === 0) { - component = ( -
- - No variables available - - Custom variables can be defined in the - Variables tab when editing splits. - Additional custom variables can be - provided automatically by auto - splitters. - - -
- ); - } else { - component = ( -
- -
- ); - } - } else { - component = ( -
- { - this.props.setValue( - valueIndex, - factory.fromString(e.target.value), - ); - }} - /> -
- ); - } - } else if ("OptionalString" in value) { - // FIXME: This is a hack that we need for now until the way - // settings are represented is refactored. - if ( - typeof field.text === "string" && - /^Comparison( \d)?$/.test(field.text) - ) { - component = ( -
- -
- ); - } else { - const children = [ - { - if (value) { - this.props.setValue( - valueIndex, - factory.fromOptionalString(""), - ); - } else { - this.props.setValue( - valueIndex, - factory.fromOptionalEmptyString(), - ); - } - }} - />, - ]; - - if (value.OptionalString !== null) { - children.push( - { - this.props.setValue( - valueIndex, - factory.fromOptionalString( - e.target.value, - ), - ); - }} - />, - ); - } - - component = ( -
- {children} -
- ); - } - } else if ("RemovableString" in value) { - component = ( -
- { - if (factory.fromRemovableString) { - this.props.setValue( - valueIndex, - factory.fromRemovableString( - e.target.value, - ), - ); - } else { - throw Error("Method is not implemented"); - } - }} - /> - { - if (factory.fromRemovableEmptyString) { - this.props.setValue( - valueIndex, - factory.fromRemovableEmptyString(), - ); - } else { - throw Error("Method is not implemented"); - } - }} - /> -
- ); - } else if ("Accuracy" in value) { - component = ( -
- -
- ); - } else if ("DigitsFormat" in value) { - component = ( -
- -
- ); - } else if ("Color" in value) { - component = ( -
- { - this.props.setValue( - valueIndex, - factory.fromColor( - color[0], - color[1], - color[2], - color[3], - ), - ); - }} - /> -
- ); - } else if ("OptionalColor" in value) { - const children = []; - - if (value.OptionalColor !== null) { - children.push( - { - this.props.setValue( - valueIndex, - factory.fromOptionalColor( - color[0], - color[1], - color[2], - color[3], - ), - ); - }} - />, - ); - } - - component = ( -
- { - if (value) { - this.props.setValue( - valueIndex, - factory.fromOptionalColor( - 1.0, - 1.0, - 1.0, - 1.0, - ), - ); - } else { - this.props.setValue( - valueIndex, - factory.fromOptionalEmptyColor(), - ); - } - }} - /> - {children} -
- ); - } else if ("Gradient" in value) { - let type: string; - let color1: Option = null; - let color2: Option = null; - const gradient = value.Gradient; - - if (gradient !== "Transparent") { - type = Object.keys(gradient)[0]; - if ("Plain" in gradient) { - color1 = gradient.Plain; - } else if ("Vertical" in gradient) { - [color1, color2] = gradient.Vertical; - } else if ("Horizontal" in gradient) { - [color1, color2] = gradient.Horizontal; - } else { - assertNever(gradient); - } - } else { - type = "Transparent"; - } - - const colorsToValue = ( - type: string, - color1: Option, - color2: Option, - ) => { - color1 = color1 ? color1 : [0.0, 0.0, 0.0, 0.0]; - color2 = color2 ? color2 : color1; - switch (type) { - case "Transparent": - return factory.fromTransparentGradient(); - case "Plain": - return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], - ); - case "Vertical": - return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - case "Horizontal": - return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - default: - throw new Error("Unexpected Gradient Type"); - } - }; - - const children: React.JSX.Element[] = [ - , - ]; - - if (color1) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color, color2), - ); - }} - />, - ); - } - - if (color2) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color1, color), - ); - }} - />, - ); - } - - if (color2) { - component = ( -
- {children} -
- ); - } else if (color1) { - component = ( -
- {children} -
- ); - } else { - component = ( -
{children}
- ); - } - } else if ("ListGradient" in value) { - let type: string; - let color1: Option = null; - let color2: Option = null; - const listGradient = value.ListGradient; - - if ("Alternating" in listGradient) { - type = Object.keys(listGradient)[0]; - [color1, color2] = listGradient.Alternating; - } else { - const gradient = listGradient.Same; - if (gradient !== "Transparent") { - type = Object.keys(gradient)[0]; - if ("Plain" in gradient) { - color1 = gradient.Plain; - } else if ("Vertical" in gradient) { - [color1, color2] = gradient.Vertical; - } else if ("Horizontal" in gradient) { - [color1, color2] = gradient.Horizontal; - } else { - assertNever(gradient); - } - } else { - type = "Transparent"; - } - } - - const colorsToValue = ( - type: string, - color1: Option, - color2: Option, - ) => { - color1 = color1 ? color1 : [0.0, 0.0, 0.0, 0.0]; - color2 = color2 ? color2 : color1; - switch (type) { - case "Transparent": - return factory.fromTransparentGradient(); - case "Plain": - return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], - ); - case "Vertical": - return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - case "Horizontal": - return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - case "Alternating": - return factory.fromAlternatingGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - default: - throw new Error("Unexpected Gradient Type"); - } - }; - - const children: React.JSX.Element[] = [ - , - ]; - - if (color1) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color, color2), - ); - }} - />, - ); - } - - if (color2) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color1, color), - ); - }} - />, - ); - } - - if (color2) { - component = ( -
- {children} -
- ); - } else if (color1) { - component = ( -
- {children} -
- ); - } else { - component = ( -
{children}
- ); - } - } else if ("OptionalTimingMethod" in value) { - const children = [ - { - if (value) { - this.props.setValue( - valueIndex, - expect( - factory.fromOptionalTimingMethod( - "RealTime", - ), - "Unexpected Optional Timing Method", - ), - ); - } else { - this.props.setValue( - valueIndex, - factory.fromOptionalEmptyTimingMethod(), - ); - } - }} - />, - ]; - - if (value.OptionalTimingMethod !== null) { - children.push( - , - ); - } - - component = ( -
- {children} -
- ); - } else if ("Alignment" in value) { - component = ( -
- -
- ); - } else if ("ColumnKind" in value) { - component = ( -
- -
- ); - } else if ("ColumnStartWith" in value) { - component = ( -
- -
- ); - } else if ("ColumnUpdateWith" in value) { - component = ( -
- -
- ); - } else if ("ColumnUpdateTrigger" in value) { - component = ( -
- -
- ); - } else if ("CustomCombobox" in value) { - const isError = - value.CustomCombobox.mandatory && - !value.CustomCombobox.value; - component = ( -
- -
- ); - } else if ("Hotkey" in value) { - component = ( -
- { - if (value != null) { - this.props.setValue( - valueIndex, - factory.fromOptionalString(value), - ); - } else { - this.props.setValue( - valueIndex, - factory.fromOptionalEmptyString(), - ); - } - }} - /> -
- ); - } else if ("LayoutDirection" in value) { - component = ( -
- -
- ); - } else if ("Font" in value) { - const children = [ - { - if (value) { - this.props.setValue( - valueIndex, - expect( - factory.fromFont( - "", - "normal", - "normal", - "normal", - ), - "Unexpected Font", - ), - ); - } else { - this.props.setValue( - valueIndex, - factory.fromEmptyFont(), - ); - } - }} - />, - ]; - - if (value.Font !== null) { - FontList.load(() => this.setState({})); - - const { family, style, weight, stretch } = value.Font; - - const styles = FontList.knownStyles.get(family); - - if (FontList.knownFamilies.length > 0) { - children.push( - , - ); - } else { - children.push( - { - this.props.setValue( - valueIndex, - expect( - factory.fromFont( - e.target.value, - style, - weight, - stretch, - ), - "Unexpected Font", - ), - ); - }} - />, - ); - } - - children.push( - <>Style, - , - <>Weight, - , - <>Stretch, - , - ); - } - - component = ( -
- {children} -
- ); - } else if ("DeltaGradient" in value) { - let type: string; - let color1: Option = null; - let color2: Option = null; - const gradient = value.DeltaGradient; - - if (typeof gradient !== "string") { - [type] = Object.keys(gradient); - if ("Plain" in gradient) { - color1 = gradient.Plain; - } else if ("Vertical" in gradient) { - [color1, color2] = gradient.Vertical; - } else if ("Horizontal" in gradient) { - [color1, color2] = gradient.Horizontal; - } else { - assertNever(gradient); - } - } else { - type = gradient; - } - - const colorsToValue = ( - type: string, - color1: Option, - color2: Option, - ) => { - color1 = color1 ? color1 : [0.0, 0.0, 0.0, 0.0]; - color2 = color2 ? color2 : color1; - switch (type) { - case "Transparent": - return factory.fromTransparentGradient(); - case "Plain": - return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], - ); - case "Vertical": - return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - case "Horizontal": - return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - default: - return expect( - factory.fromDeltaGradient(type), - "Unexpected Gradient Type", - ); - } - }; - - const children: React.JSX.Element[] = [ - , - ]; - - if (color1) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color, color2), - ); - }} - />, - ); - } - - if (color2) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color1, color), - ); - }} - />, - ); - } - - if (color2) { - component = ( -
- {children} -
- ); - } else if (color1) { - component = ( -
- {children} -
- ); - } else { - component = ( -
{children}
- ); - } - } else if ("LayoutBackground" in value) { - let type: string; - let color1: Option = null; - let color2: Option = null; - let imageId = - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - let brightness = 100; - let opacity = 100; - let blur = 0; - const gradient = value.LayoutBackground; - - const children: React.JSX.Element[] = []; - - const colorsToValue = ( - type: string, - color1: Option, - color2: Option, - ) => { - color1 = color1 ? color1 : [0.0, 0.0, 0.0, 0.0]; - color2 = color2 ? color2 : color1; - switch (type) { - case "Transparent": - return factory.fromTransparentGradient(); - case "Plain": - return factory.fromColor( - color1[0], - color1[1], - color1[2], - color1[3], - ); - case "Vertical": - return factory.fromVerticalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - case "Horizontal": - return factory.fromHorizontalGradient( - color1[0], - color1[1], - color1[2], - color1[3], - color2[0], - color2[1], - color2[2], - color2[3], - ); - default: - return expect( - factory.fromBackgroundImage( - imageId, - brightness / 100, - opacity / 100, - blur / 100, - ), - "Unexpected layout background", - ); - } - }; - - if (typeof gradient !== "string") { - [type] = Object.keys(gradient); - if ("Plain" in gradient) { - color1 = gradient.Plain; - } else if ("Vertical" in gradient) { - [color1, color2] = gradient.Vertical; - } else if ("Horizontal" in gradient) { - [color1, color2] = gradient.Horizontal; - } else if ("image" in gradient) { - imageId = gradient.image; - brightness = 100 * gradient.brightness; - opacity = 100 * gradient.opacity; - blur = 100 * gradient.blur; - type = "Image"; - const imageUrl = - this.props.editorUrlCache.cache(imageId); - children.push( -
{ - const maybeFile = - await openFileAsArrayBuffer( - FILE_EXT_IMAGES, - ); - if (maybeFile === undefined) { - return; - } - if (maybeFile instanceof Error) { - toast.error( - `Failed to read the file: ${maybeFile.message}`, - ); - return; - } - const [file] = maybeFile; - const imageId = - this.props.editorUrlCache.imageCache.cacheFromArray( - new Uint8Array(file), - true, - ); - this.props.editorUrlCache.cache(imageId); - const value = expect( - factory.fromBackgroundImage( - imageId, - brightness / 100, - opacity / 100, - blur / 100, - ), - "Unexpected layout background", - ); - this.props.setValue(valueIndex, value); - }} - />, -
- Brightness - { - brightness = Number(e.target.value); - this.props.setValue( - valueIndex, - colorsToValue(type, color1, color2), - ); - }} - /> - Opacity - { - opacity = Number(e.target.value); - this.props.setValue( - valueIndex, - colorsToValue(type, color1, color2), - ); - }} - /> - Blur - { - blur = Number(e.target.value); - this.props.setValue( - valueIndex, - colorsToValue(type, color1, color2), - ); - }} - /> -
, - ); - } else { - assertNever(gradient); - } - } else { - type = gradient; - } - - children.splice( - 0, - 0, - , - ); - - if (color1) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color, color2), - ); - }} - />, - ); - } - - if (color2) { - children.push( - { - this.props.setValue( - valueIndex, - colorsToValue(type, color1, color), - ); - }} - />, - ); - } - - if (color2) { - component = ( -
- {children} -
- ); - } else if (color1 || type === "Image") { - component = ( -
- {children} -
- ); - } else { - component = ( -
{children}
- ); - } - } else if ("ServerConnection" in value) { - component = ( -
- -
- ); - } else { - assertNever(value); - } - - settingsRows.push( - - - {field.text} - {field.tooltip} - - {component} - , - ); - }); - - return ( - - {settingsRows} -
- ); - } - - private async connectToServerOrDisconnect( - valueIndex: number, - serverUrl: string | undefined, - connection: Option, - ) { - if (connection) { - connection.close(); - return; - } - const [result, url] = await showDialog({ - title: "Connect to Server", - description: "Specify the WebSocket URL:", - textInput: true, - defaultText: serverUrl, - buttons: ["Connect", "Cancel"], - }); - if (result !== 0) { - return; - } - this.props.setValue(valueIndex, this.props.factory.fromString(url)); - } -} diff --git a/src/ui/components/Settings/Accuracy.tsx b/src/ui/components/Settings/Accuracy.tsx new file mode 100644 index 0000000..552c290 --- /dev/null +++ b/src/ui/components/Settings/Accuracy.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { AccuracyJson } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; + +export function Accuracy({ + value, + setValue, + factory, +}: { + value: AccuracyJson; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} diff --git a/src/ui/components/Settings/Alignment.tsx b/src/ui/components/Settings/Alignment.tsx new file mode 100644 index 0000000..199d112 --- /dev/null +++ b/src/ui/components/Settings/Alignment.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { Alignment } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; + +export function Alignment({ + value, + setValue, + factory, +}: { + value: Alignment; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} diff --git a/src/ui/components/Settings/Color.tsx b/src/ui/components/Settings/Color.tsx new file mode 100644 index 0000000..c6e16b2 --- /dev/null +++ b/src/ui/components/Settings/Color.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; +import { Color } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; +import { ColorPicker } from "../ColorPicker"; +import { Switch } from "../Switch"; + +export function Color({ + value, + setValue, + factory, +}: { + value: Color; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ + setValue( + factory.fromColor( + color[0], + color[1], + color[2], + color[3], + ), + ) + } + /> +
+ ); +} + +export function OptionalColor({ + value, + setValue, + factory, +}: { + value: Color | null; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + const children: React.ReactNode[] = []; + + if (value !== null) { + children.push( + + setValue( + factory.fromOptionalColor( + color[0], + color[1], + color[2], + color[3], + ), + ) + } + />, + ); + } + + return ( +
+ { + if (checked) { + setValue(factory.fromOptionalColor(1, 1, 1, 1)); + } else { + setValue(factory.fromOptionalEmptyColor()); + } + }} + /> + {children} +
+ ); +} diff --git a/src/ui/components/Settings/Column.tsx b/src/ui/components/Settings/Column.tsx new file mode 100644 index 0000000..deeafef --- /dev/null +++ b/src/ui/components/Settings/Column.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { + ColumnKind, + ColumnStartWith, + ColumnUpdateWith, + ColumnUpdateTrigger, +} from "../../../livesplit-core"; +import { SettingValueFactory } from "."; + +export function ColumnKind({ + value, + setValue, + factory, +}: { + value: ColumnKind; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} + +export function ColumnStartWith({ + value, + setValue, + factory, +}: { + value: ColumnStartWith; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} + +export function ColumnUpdateWith({ + value, + setValue, + factory, +}: { + value: ColumnUpdateWith; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} + +export function ColumnUpdateTrigger({ + value, + setValue, + factory, +}: { + value: ColumnUpdateTrigger; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} diff --git a/src/ui/components/Settings/DigitsFormat.tsx b/src/ui/components/Settings/DigitsFormat.tsx new file mode 100644 index 0000000..70d0f07 --- /dev/null +++ b/src/ui/components/Settings/DigitsFormat.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { DigitsFormatJson } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; + +export function DigitsFormat({ + value, + setValue, + factory, +}: { + value: DigitsFormatJson; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} diff --git a/src/ui/components/Settings/Font.tsx b/src/ui/components/Settings/Font.tsx new file mode 100644 index 0000000..00f6c42 --- /dev/null +++ b/src/ui/components/Settings/Font.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { Font } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; +import { Switch } from "../Switch"; +import * as FontList from "../../../util/FontList"; + +export function Font({ + value, + setValue, + factory, + loadedCallback, +}: { + value: Font | null; + setValue: (value: T) => void; + factory: SettingValueFactory; + loadedCallback: () => void; +}) { + const children = [ + { + if (value) { + setValue( + expect( + factory.fromFont("", "normal", "normal", "normal"), + "Unexpected Font", + ), + ); + } else { + setValue(factory.fromEmptyFont()); + } + }} + />, + ]; + + if (value !== null) { + // FIXME: We should do a proper promise hook. + FontList.load(loadedCallback); + + const { family, style, weight, stretch } = value; + + const styles = FontList.knownStyles.get(family); + + if (FontList.knownFamilies.length > 0) { + children.push( + , + ); + } else { + children.push( + + setValue( + expect( + factory.fromFont( + e.target.value, + style, + weight, + stretch, + ), + "Unexpected Font", + ), + ) + } + />, + ); + } + + children.push( + <>Style, + , + <>Weight, + , + <>Stretch, + , + ); + } + + return
{children}
; +} diff --git a/src/ui/components/Settings/Gradient.tsx b/src/ui/components/Settings/Gradient.tsx new file mode 100644 index 0000000..e4492f2 --- /dev/null +++ b/src/ui/components/Settings/Gradient.tsx @@ -0,0 +1,384 @@ +import * as React from "react"; +import { + Color, + DeltaGradient, + Gradient, + ListGradient, +} from "../../../livesplit-core"; +import { SettingValueFactory } from "."; +import { assertNever, expect, Option } from "../../../util/OptionUtil"; +import { ColorPicker } from "../ColorPicker"; + +export function Gradient({ + value, + setValue, + factory, +}: { + value: Gradient; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + let type: string | undefined; + let color1: Color | undefined; + let color2: Color | undefined; + + if (value !== "Transparent") { + type = Object.keys(value)[0]; + if ("Plain" in value) { + color1 = value.Plain; + } else if ("Vertical" in value) { + [color1, color2] = value.Vertical; + } else if ("Horizontal" in value) { + [color1, color2] = value.Horizontal; + } else { + assertNever(value); + } + } else { + type = "Transparent"; + } + + const colorsToValue = ( + type: string, + color1: Option, + color2: Option, + ) => { + color1 = color1 ?? [0, 0, 0, 0]; + color2 = color2 ?? color1; + switch (type) { + case "Transparent": + return factory.fromTransparentGradient(); + case "Plain": + return factory.fromColor( + color1[0], + color1[1], + color1[2], + color1[3], + ); + case "Vertical": + return factory.fromVerticalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + case "Horizontal": + return factory.fromHorizontalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + default: + throw new Error("Unexpected Gradient Type"); + } + }; + + const children: React.JSX.Element[] = [ + , + ]; + + if (color1) { + children.push( + + setValue(colorsToValue(type, color, color2)) + } + />, + ); + } + + if (color2) { + children.push( + + setValue(colorsToValue(type, color1, color)) + } + />, + ); + } + + if (color2) { + return
{children}
; + } else if (color1) { + return
{children}
; + } else { + return
{children}
; + } +} +export function DeltaGradient({ + value, + setValue, + factory, +}: { + value: DeltaGradient; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + let type: string | undefined; + let color1: Color | undefined; + let color2: Color | undefined; + + if (typeof value !== "string") { + [type] = Object.keys(value); + if ("Plain" in value) { + color1 = value.Plain; + } else if ("Vertical" in value) { + [color1, color2] = value.Vertical; + } else if ("Horizontal" in value) { + [color1, color2] = value.Horizontal; + } else { + assertNever(value); + } + } else { + type = value; + } + + const colorsToValue = ( + type: string, + color1: Option, + color2: Option, + ) => { + color1 = color1 ?? [0, 0, 0, 0]; + color2 = color2 ?? color1; + switch (type) { + case "Transparent": + return factory.fromTransparentGradient(); + case "Plain": + return factory.fromColor( + color1[0], + color1[1], + color1[2], + color1[3], + ); + case "Vertical": + return factory.fromVerticalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + case "Horizontal": + return factory.fromHorizontalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + default: + return expect( + factory.fromDeltaGradient(type), + "Unexpected Gradient Type", + ); + } + }; + + const children: React.JSX.Element[] = [ + , + ]; + + if (color1) { + children.push( + + setValue(colorsToValue(type, color, color2)) + } + />, + ); + } + + if (color2) { + children.push( + + setValue(colorsToValue(type, color1, color)) + } + />, + ); + } + + if (color2) { + return
{children}
; + } else if (color1) { + return
{children}
; + } else { + return
{children}
; + } +} + +export function ListGradient({ + value, + setValue, + factory, +}: { + value: ListGradient; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + let type: string | undefined; + let color1: Color | undefined; + let color2: Color | undefined; + + if ("Alternating" in value) { + type = Object.keys(value)[0]; + [color1, color2] = value.Alternating; + } else { + const gradient = value.Same; + if (gradient !== "Transparent") { + type = Object.keys(gradient)[0]; + if ("Plain" in gradient) { + color1 = gradient.Plain; + } else if ("Vertical" in gradient) { + [color1, color2] = gradient.Vertical; + } else if ("Horizontal" in gradient) { + [color1, color2] = gradient.Horizontal; + } else { + assertNever(gradient); + } + } else { + type = "Transparent"; + } + } + + const colorsToValue = ( + type: string, + color1: Option, + color2: Option, + ) => { + color1 = color1 ?? [0, 0, 0, 0]; + color2 = color2 ?? color1; + switch (type) { + case "Transparent": + return factory.fromTransparentGradient(); + case "Plain": + return factory.fromColor( + color1[0], + color1[1], + color1[2], + color1[3], + ); + case "Vertical": + return factory.fromVerticalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + case "Horizontal": + return factory.fromHorizontalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + case "Alternating": + return factory.fromAlternatingGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + default: + throw new Error("Unexpected Gradient Type"); + } + }; + + const children: React.JSX.Element[] = [ + , + ]; + + if (color1) { + children.push( + + setValue(colorsToValue(type, color, color2)) + } + />, + ); + } + + if (color2) { + children.push( + + setValue(colorsToValue(type, color1, color)) + } + />, + ); + } + + if (color2) { + return
{children}
; + } else if (color1) { + return
{children}
; + } else { + return
{children}
; + } +} diff --git a/src/ui/components/Settings/LayoutBackground.tsx b/src/ui/components/Settings/LayoutBackground.tsx new file mode 100644 index 0000000..bbedc9e --- /dev/null +++ b/src/ui/components/Settings/LayoutBackground.tsx @@ -0,0 +1,236 @@ +import * as React from "react"; +import { Option, assertNever, expect } from "../../../util/OptionUtil"; +import { Color, LayoutBackground } from "../../../livesplit-core"; +import { ColorPicker } from "../ColorPicker"; +import { SettingValueFactory } from "."; +import { UrlCache } from "../../../util/UrlCache"; +import { toast } from "react-toastify"; +import { FILE_EXT_IMAGES, openFileAsArrayBuffer } from "../../../util/FileUtil"; + +export function LayoutBackground({ + value, + setValue, + factory, + editorUrlCache, +}: { + value: LayoutBackground; + setValue: (value: T) => void; + factory: SettingValueFactory; + editorUrlCache: UrlCache; +}) { + let type: string; + let color1: Option = null; + let color2: Option = null; + let imageId = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + let brightness = 100; + let opacity = 100; + let blur = 0; + + const children: React.JSX.Element[] = []; + + const colorsToValue = ( + type: string, + color1: Option, + color2: Option, + ) => { + color1 = color1 ?? [0, 0, 0, 0]; + color2 = color2 ?? color1; + switch (type) { + case "Transparent": + return factory.fromTransparentGradient(); + case "Plain": + return factory.fromColor( + color1[0], + color1[1], + color1[2], + color1[3], + ); + case "Vertical": + return factory.fromVerticalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + case "Horizontal": + return factory.fromHorizontalGradient( + color1[0], + color1[1], + color1[2], + color1[3], + color2[0], + color2[1], + color2[2], + color2[3], + ); + default: + return expect( + factory.fromBackgroundImage( + imageId, + brightness / 100, + opacity / 100, + blur / 100, + ), + "Unexpected layout background", + ); + } + }; + + if (typeof value !== "string") { + [type] = Object.keys(value); + if ("Plain" in value) { + color1 = value.Plain; + } else if ("Vertical" in value) { + [color1, color2] = value.Vertical; + } else if ("Horizontal" in value) { + [color1, color2] = value.Horizontal; + } else if ("image" in value) { + imageId = value.image; + brightness = 100 * value.brightness; + opacity = 100 * value.opacity; + blur = 100 * value.blur; + type = "Image"; + const imageUrl = editorUrlCache.cache(imageId); + children.push( +
{ + const maybeFile = + await openFileAsArrayBuffer(FILE_EXT_IMAGES); + if (maybeFile === undefined) { + return; + } + if (maybeFile instanceof Error) { + toast.error( + `Failed to read the file: ${maybeFile.message}`, + ); + return; + } + const [file] = maybeFile; + const imageId = + editorUrlCache.imageCache.cacheFromArray( + new Uint8Array(file), + true, + ); + editorUrlCache.cache(imageId); + const value = expect( + factory.fromBackgroundImage( + imageId, + brightness / 100, + opacity / 100, + blur / 100, + ), + "Unexpected layout background", + ); + setValue(value); + }} + />, +
+ Brightness + { + brightness = Number(e.target.value); + setValue(colorsToValue(type, color1, color2)); + }} + /> + Opacity + { + opacity = Number(e.target.value); + setValue(colorsToValue(type, color1, color2)); + }} + /> + Blur + { + blur = Number(e.target.value); + setValue(colorsToValue(type, color1, color2)); + }} + /> +
, + ); + } else { + assertNever(value); + } + } else { + type = value; + } + + children.splice( + 0, + 0, + , + ); + + if (color1) { + children.push( + { + setValue(colorsToValue(type, color, color2)); + }} + />, + ); + } + + if (color2) { + children.push( + { + setValue(colorsToValue(type, color1, color)); + }} + />, + ); + } + + if (color2) { + return
{children}
; + } else if (color1 || type === "Image") { + return
{children}
; + } else { + return
{children}
; + } +} diff --git a/src/ui/components/Settings/LayoutDirection.tsx b/src/ui/components/Settings/LayoutDirection.tsx new file mode 100644 index 0000000..5d3a27f --- /dev/null +++ b/src/ui/components/Settings/LayoutDirection.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { LayoutDirection } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; + +export function LayoutDirection({ + value, + setValue, + factory, +}: { + value: LayoutDirection; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ +
+ ); +} diff --git a/src/ui/components/Settings/ServerConnectionButton.tsx b/src/ui/components/Settings/ServerConnectionButton.tsx new file mode 100644 index 0000000..cf36b17 --- /dev/null +++ b/src/ui/components/Settings/ServerConnectionButton.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; +import { Option } from "../../../util/OptionUtil"; +import { LiveSplitServer } from "../../../api/LiveSplitServer"; + +import "../../../css/LiveSplitServerButton.scss"; + +export function ServerConnectionButton({ + value, + connectOrDisconnect, +}: { + value: { + url: string | undefined; + connection: Option; + }; + connectOrDisconnect: () => void; +}) { + return ( +
+ +
+ ); +} diff --git a/src/ui/components/Settings/String.tsx b/src/ui/components/Settings/String.tsx new file mode 100644 index 0000000..5a72e77 --- /dev/null +++ b/src/ui/components/Settings/String.tsx @@ -0,0 +1,174 @@ +import * as React from "react"; +import { SettingValueFactory } from "."; +import { Trash } from "lucide-react"; +import { Switch } from "../Switch"; + +export function String({ + value, + setValue, + factory, +}: { + value: string; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ setValue(factory.fromString(e.target.value))} + /> +
+ ); +} + +export function OptionalString({ + value, + setValue, + factory, +}: { + value: string | null; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + const children = [ + { + if (checked) { + setValue(factory.fromOptionalString("")); + } else { + setValue(factory.fromOptionalEmptyString()); + } + }} + />, + ]; + + if (value !== null) { + children.push( + + setValue(factory.fromOptionalString(e.target.value)) + } + />, + ); + } + + return
{children}
; +} + +export function RemovableString({ + value, + setValue, + factory, +}: { + value: string | null; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + return ( +
+ { + if (factory.fromRemovableString) { + setValue(factory.fromRemovableString(e.target.value)); + } else { + throw Error("Method is not implemented"); + } + }} + /> + { + if (factory.fromRemovableEmptyString) { + setValue(factory.fromRemovableEmptyString()); + } else { + throw Error("Method is not implemented"); + } + }} + /> +
+ ); +} + +export function Comparison({ + value, + setValue, + factory, + allComparisons, +}: { + value: string | null; + setValue: (value: T) => void; + factory: SettingValueFactory; + allComparisons: string[]; +}) { + return ( +
+ +
+ ); +} + +export function CustomVariable({ + value, + setValue, + factory, + allVariables, +}: { + value: string; + setValue: (value: T) => void; + factory: SettingValueFactory; + allVariables: Set; +}) { + if (allVariables.size === 0) { + return ( +
+ + No variables available + + Custom variables can be defined in the Variables tab + when editing splits. Additional custom variables can be + provided automatically by auto splitters. + + +
+ ); + } else { + return ( +
+ +
+ ); + } +} diff --git a/src/ui/components/Settings/TimingMethod.tsx b/src/ui/components/Settings/TimingMethod.tsx new file mode 100644 index 0000000..abe9bee --- /dev/null +++ b/src/ui/components/Settings/TimingMethod.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { expect } from "../../../util/OptionUtil"; +import { TimingMethodJson } from "../../../livesplit-core"; +import { SettingValueFactory } from "."; +import { Switch } from "../Switch"; + +export function OptionalTimingMethod({ + value, + setValue, + factory, +}: { + value: TimingMethodJson | null; + setValue: (value: T) => void; + factory: SettingValueFactory; +}) { + const children = [ + { + if (value) { + setValue( + expect( + factory.fromOptionalTimingMethod("RealTime"), + "Unexpected Optional Timing Method", + ), + ); + } else { + setValue(factory.fromOptionalEmptyTimingMethod()); + } + }} + />, + ]; + + if (value !== null) { + children.push( + , + ); + } + + return
{children}
; +} diff --git a/src/ui/components/Settings/index.tsx b/src/ui/components/Settings/index.tsx new file mode 100644 index 0000000..991814f --- /dev/null +++ b/src/ui/components/Settings/index.tsx @@ -0,0 +1,645 @@ +import * as React from "react"; +import { SettingsDescriptionValueJson } from "../../../livesplit-core"; +import { assertNever, Option } from "../../../util/OptionUtil"; +import { HotkeyButton } from "../HotkeyButton"; +import { UrlCache } from "../../../util/UrlCache"; +import { LiveSplitServer } from "../../../api/LiveSplitServer"; +import { showDialog } from "../Dialog"; +import { Switch } from "../Switch"; +import { ServerConnectionButton } from "./ServerConnectionButton"; +import { LayoutBackground } from "./LayoutBackground"; +import { Font } from "./Font"; + +import "../../../css/Tooltip.scss"; +import { LayoutDirection } from "./LayoutDirection"; +import { + ColumnKind, + ColumnStartWith, + ColumnUpdateWith, + ColumnUpdateTrigger, +} from "./Column"; +import { Alignment } from "./Alignment"; +import { OptionalTimingMethod } from "./TimingMethod"; +import { DeltaGradient, Gradient, ListGradient } from "./Gradient"; +import { Color, OptionalColor } from "./Color"; +import { DigitsFormat } from "./DigitsFormat"; +import { Accuracy } from "./Accuracy"; +import { + Comparison, + CustomVariable, + OptionalString, + RemovableString, + String, +} from "./String"; + +export interface Props { + context: string; + setValue: (index: number, value: T) => void; + state: ExtendedSettingsDescriptionJson; + factory: SettingValueFactory; + editorUrlCache: UrlCache; + allComparisons: string[]; + allVariables: Set; +} + +export interface ExtendedSettingsDescriptionJson { + fields: ExtendedSettingsDescriptionFieldJson[]; +} + +export interface ExtendedSettingsDescriptionFieldJson { + text: string | React.JSX.Element; + tooltip: string | React.JSX.Element; + value: ExtendedSettingsDescriptionValueJson; +} + +export type ExtendedSettingsDescriptionValueJson = + | SettingsDescriptionValueJson + | { RemovableString: string | null } + | { + ServerConnection: { + url: string | undefined; + connection: Option; + }; + }; + +export interface SettingValueFactory { + fromBool(v: boolean): T; + fromUint(value: number): T; + fromInt(value: number): T; + fromString(value: string): T; + fromOptionalString(value: string): T; + fromOptionalEmptyString(): T; + fromRemovableString?(value: string): T; + fromRemovableEmptyString?(): T; + fromAccuracy(value: string): T | null; + fromDigitsFormat(value: string): T | null; + fromOptionalTimingMethod(value: string): T | null; + fromOptionalEmptyTimingMethod(): T; + fromColor(r: number, g: number, b: number, a: number): T; + fromOptionalColor(r: number, g: number, b: number, a: number): T; + fromOptionalEmptyColor(): T; + fromTransparentGradient(): T; + fromVerticalGradient( + r1: number, + g1: number, + b1: number, + a1: number, + r2: number, + g2: number, + b2: number, + a2: number, + ): T; + fromHorizontalGradient( + r1: number, + g1: number, + b1: number, + a1: number, + r2: number, + g2: number, + b2: number, + a2: number, + ): T; + fromAlternatingGradient( + r1: number, + g1: number, + b1: number, + a1: number, + r2: number, + g2: number, + b2: number, + a2: number, + ): T; + fromAlignment(value: string): T | null; + fromColumnKind(value: string): T | null; + fromColumnStartWith(value: string): T | null; + fromColumnUpdateWith(value: string): T | null; + fromColumnUpdateTrigger(value: string): T | null; + fromLayoutDirection(value: string): T | null; + fromFont( + name: string, + style: string, + weight: string, + stretch: string, + ): T | null; + fromEmptyFont(): T; + fromDeltaGradient(value: string): T | null; + fromBackgroundImage( + imageId: string, + brightness: number, + opacity: number, + blur: number, + ): T | null; +} + +export class JsonSettingValueFactory + implements SettingValueFactory +{ + public fromBool(v: boolean): ExtendedSettingsDescriptionValueJson { + return { Bool: v }; + } + public fromUint(_: number): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromInt(_: number): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromString(v: string): ExtendedSettingsDescriptionValueJson { + return { String: v }; + } + public fromOptionalString(_: string): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromOptionalEmptyString(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromRemovableString( + v: string, + ): ExtendedSettingsDescriptionValueJson { + return { RemovableString: v }; + } + public fromRemovableEmptyString(): ExtendedSettingsDescriptionValueJson { + return { RemovableString: null }; + } + public fromAccuracy( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromDigitsFormat( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromOptionalTimingMethod( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromOptionalEmptyTimingMethod(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromColor(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromOptionalColor(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromOptionalEmptyColor(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromTransparentGradient(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromVerticalGradient(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromHorizontalGradient(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromAlternatingGradient(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromAlignment( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromColumnKind( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromColumnStartWith( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromColumnUpdateWith( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromColumnUpdateTrigger( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromLayoutDirection( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromFont( + _name: string, + _style: string, + _weight: string, + _stretch: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromEmptyFont(): ExtendedSettingsDescriptionValueJson { + throw new Error("Not implemented"); + } + public fromDeltaGradient( + _: string, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } + public fromBackgroundImage( + _imageId: string, + _brightness: number, + _opacity: number, + _blur: number, + ): ExtendedSettingsDescriptionValueJson | null { + throw new Error("Not implemented"); + } +} + +export class SettingsComponent extends React.Component> { + public render() { + const settingsRows: React.JSX.Element[] = []; + const { factory } = this.props; + + this.props.state.fields.forEach((field, valueIndex) => { + const { value } = field; + let component; + if ("Bool" in value) { + component = ( +
+ { + this.props.setValue( + valueIndex, + factory.fromBool(value), + ); + }} + /> +
+ ); + } else if ("UInt" in value) { + component = ( +
+ { + this.props.setValue( + valueIndex, + factory.fromUint(e.target.valueAsNumber), + ); + }} + /> +
+ ); + } else if ("Int" in value) { + component = ( +
+ { + this.props.setValue( + valueIndex, + factory.fromInt(e.target.valueAsNumber), + ); + }} + /> +
+ ); + } else if ("String" in value) { + // FIXME: This is a hack that we need for now until the way + // settings are represented is refactored. + if ( + typeof field.text === "string" && + /^Variable/.test(field.text) + ) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + allVariables={this.props.allVariables} + /> + ); + } else { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } + } else if ("OptionalString" in value) { + // FIXME: This is a hack that we need for now until the way + // settings are represented is refactored. + if ( + typeof field.text === "string" && + /^Comparison( \d)?$/.test(field.text) + ) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } + } else if ("RemovableString" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("Accuracy" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("DigitsFormat" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("Color" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("OptionalColor" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("Gradient" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("ListGradient" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("OptionalTimingMethod" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("Alignment" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("ColumnKind" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("ColumnStartWith" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("ColumnUpdateWith" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("ColumnUpdateTrigger" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("CustomCombobox" in value) { + const isError = + value.CustomCombobox.mandatory && + !value.CustomCombobox.value; + component = ( +
+ +
+ ); + } else if ("Hotkey" in value) { + component = ( +
+ { + if (value != null) { + this.props.setValue( + valueIndex, + factory.fromOptionalString(value), + ); + } else { + this.props.setValue( + valueIndex, + factory.fromOptionalEmptyString(), + ); + } + }} + /> +
+ ); + } else if ("LayoutDirection" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("Font" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + loadedCallback={() => this.setState({})} + /> + ); + } else if ("DeltaGradient" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + /> + ); + } else if ("LayoutBackground" in value) { + component = ( + + this.props.setValue(valueIndex, value) + } + factory={this.props.factory} + editorUrlCache={this.props.editorUrlCache} + /> + ); + } else if ("ServerConnection" in value) { + component = ( + + this.connectToServerOrDisconnect( + valueIndex, + value.ServerConnection.url, + value.ServerConnection.connection, + ) + } + /> + ); + } else { + assertNever(value); + } + + settingsRows.push( + + + {field.text} + {field.tooltip} + + {component} + , + ); + }); + + return ( + + {settingsRows} +
+ ); + } + + // FIXME: Move to the component if possible. + private async connectToServerOrDisconnect( + valueIndex: number, + serverUrl: string | undefined, + connection: Option, + ) { + if (connection) { + connection.close(); + return; + } + const [result, url] = await showDialog({ + title: "Connect to Server", + description: "Specify the WebSocket URL:", + textInput: true, + defaultText: serverUrl, + buttons: ["Connect", "Cancel"], + }); + if (result !== 0) { + return; + } + this.props.setValue(valueIndex, this.props.factory.fromString(url)); + } +} diff --git a/src/ui/components/Switch.tsx b/src/ui/components/Switch.tsx index f1dc06b..808ed0f 100644 --- a/src/ui/components/Switch.tsx +++ b/src/ui/components/Switch.tsx @@ -2,7 +2,7 @@ import React from "react"; import * as classes from "../../css/Switch.module.scss"; -export default function Switch({ +export function Switch({ checked, setIsChecked, }: { diff --git a/src/ui/components/TextBox.tsx b/src/ui/components/TextBox.tsx index 1bb86fe..96bfc11 100644 --- a/src/ui/components/TextBox.tsx +++ b/src/ui/components/TextBox.tsx @@ -1,53 +1,56 @@ import * as React from "react"; -export interface Props { +import * as classes from "../../css/TextBox.module.scss"; + +export function TextBox({ + className, + value, + onChange, + onBlur, + label, + invalid, + list, +}: { className?: string; - value?: any; - onChange?: React.EventHandler>; - onBlur?: React.EventHandler>; + value?: string | number | readonly string[]; + onChange?: React.ChangeEventHandler; + onBlur?: React.FocusEventHandler; label: string; invalid?: boolean; - small?: boolean; list?: [string, string[]]; -} +}) { + let outerClassName = classes.group; + if (invalid) { + outerClassName += ` ${classes.invalid}`; + } -export class TextBox extends React.Component { - public render() { - let className = "group"; - if (this.props.invalid) { - className += " invalid"; - } - if (this.props.small) { - className += " small"; - } - let name; - let list; - if (this.props.list !== undefined) { - name = this.props.list[0]; - list = ( - - {this.props.list[1].map((n, i) => ( - - ); - } - - return ( -
- - {list} - - -
+ let name; + let listElement; + if (list !== undefined) { + name = list[0]; + listElement = ( + + {list[1].map((n, i) => ( + ); } + + return ( +
+ + {listElement} + + +
+ ); } diff --git a/src/ui/views/About.tsx b/src/ui/views/About.tsx index acc6a10..f997acd 100644 --- a/src/ui/views/About.tsx +++ b/src/ui/views/About.tsx @@ -5,11 +5,7 @@ import { Markdown } from "../../util/Markdown"; import { ArrowLeft } from "lucide-react"; import * as variables from "../../css/variables.icss.scss"; -import "../../css/About.scss"; - -export interface Props { - callbacks: Callbacks; -} +import * as classes from "../../css/About.module.scss"; interface Callbacks { renderViewWithSidebar( @@ -21,96 +17,90 @@ interface Callbacks { const contributorAvatarSize = parseFloat(variables.contributorAvatarSize); -export class About extends React.Component { - public render() { - const renderedView = this.renderView(); - const sidebarContent = this.renderSidebarContent(); - return this.props.callbacks.renderViewWithSidebar( - renderedView, - sidebarContent, - ); - } +export function About({ callbacks }: { callbacks: Callbacks }) { + const renderedView = renderView(); + const sidebarContent = renderSidebarContent(callbacks); + return callbacks.renderViewWithSidebar(renderedView, sidebarContent); +} - private renderView() { - const idealAvatarResolution = Math.round( - devicePixelRatio * contributorAvatarSize, - ); +function renderView() { + const idealAvatarResolution = Math.round( + devicePixelRatio * contributorAvatarSize, + ); - return ( -
-
-
- - LiveSplit Logo - -
LiveSplit One
-
-

- - Version: {BUILD_DATE} - -

-

- LiveSplit One is a multiplatform version of LiveSplit, - the sleek, highly-customizable timer for speedrunners. -

-

- - View Source Code on GitHub - -

-

Recent Changes

-
- {CHANGELOG.map((change) => ( - <> - - {change.date} - - - - ))} -
-

Contributors

-
- {CONTRIBUTORS_LIST.map((contributor) => ( + return ( +
+
+
+ LiveSplit Logo +
LiveSplit One
+
+

+ + Version: {BUILD_DATE} + +

+

+ LiveSplit One is a multiplatform version of LiveSplit, the + sleek, highly-customizable timer for speedrunners. +

+

+ + View Source Code on GitHub + +

+

Recent Changes

+ + + + ))} +
+

Contributors

+
+ {CONTRIBUTORS_LIST.map((contributor) => ( + + (e.target as any).remove()} + /> + {contributor.name} + + ))}
- ); - } - - private renderSidebarContent() { - return ( -
-

About

-
- -
- ); - } +
+ ); +} + +function renderSidebarContent(callbacks: Callbacks) { + return ( +
+

About

+
+ +
+ ); } diff --git a/src/ui/views/RunEditor.tsx b/src/ui/views/RunEditor.tsx index 779e491..e9756ea 100644 --- a/src/ui/views/RunEditor.tsx +++ b/src/ui/views/RunEditor.tsx @@ -212,7 +212,6 @@ export class RunEditor extends React.Component { value={this.state.editor.offset} onChange={(e) => this.handleOffsetChange(e)} onBlur={(_) => this.handleOffsetBlur()} - small invalid={!this.state.offsetIsValid} label="Start Timer At" /> @@ -225,7 +224,6 @@ export class RunEditor extends React.Component { this.handleAttemptsChange(e) } onBlur={(_) => this.handleAttemptsBlur()} - small invalid={!this.state.attemptCountIsValid} label="Attempts" /> diff --git a/src/ui/views/SplitsSelection.tsx b/src/ui/views/SplitsSelection.tsx index 201d6f8..78edded 100644 --- a/src/ui/views/SplitsSelection.tsx +++ b/src/ui/views/SplitsSelection.tsx @@ -17,7 +17,7 @@ import { FILE_EXT_SPLITS, } from "../../util/FileUtil"; import { Option, bug, maybeDisposeAndThen } from "../../util/OptionUtil"; -import DragUpload from "../components/DragUpload"; +import { DragUpload } from "../components/DragUpload"; import { GeneralSettings } from "./MainSettings"; import { LSOCommandSink } from "../LSOCommandSink"; import { showDialog } from "../components/Dialog"; diff --git a/src/ui/views/TimerView.tsx b/src/ui/views/TimerView.tsx index 8187130..3bf2ffc 100644 --- a/src/ui/views/TimerView.tsx +++ b/src/ui/views/TimerView.tsx @@ -7,7 +7,7 @@ import { } from "../../livesplit-core"; import * as LiveSplit from "../../livesplit-core"; import { Option, expect } from "../../util/OptionUtil"; -import DragUpload from "../components/DragUpload"; +import { DragUpload } from "../components/DragUpload"; import Layout from "../components/Layout"; import { UrlCache } from "../../util/UrlCache"; import { WebRenderer } from "../../livesplit-core/livesplit_core"; @@ -119,7 +119,7 @@ export class TimerView extends React.Component { } }} style={{ - display: "inline-block", + width: "fit-content", cursor: this.props.generalSettings .showControlButtons ? "pointer" diff --git a/src/util/AutoRefresh.tsx b/src/util/AutoRefresh.tsx index 9168d7a..0587359 100644 --- a/src/util/AutoRefresh.tsx +++ b/src/util/AutoRefresh.tsx @@ -5,55 +5,63 @@ import { batteryAwareFrameRate, } from "./FrameRate"; -export interface Props { +interface State { + reqId?: number; + previousTime: number; frameRate: FrameRateSetting; - update(): void; - children: React.ReactNode; + update: () => void; } -export default class AutoRefresh extends React.Component { - private reqId?: number; - private previousTime: number = 0; +export default function AutoRefresh({ + frameRate, + update, + children, +}: { + frameRate: FrameRateSetting; + update: () => void; + children: React.ReactNode; +}) { + const { current: state } = React.useRef({ + previousTime: 0, + frameRate, + update, + }); + state.frameRate = frameRate; + state.update = update; - public componentDidMount() { - this.startAnimation(); - } + const animate = React.useCallback(() => { + state.reqId = requestAnimationFrame(animate); - public componentWillUnmount() { - if (this.reqId) { - cancelAnimationFrame(this.reqId); - } - } - - public render() { - return this.props.children; - } - - private startAnimation() { - this.previousTime = 0; - this.animate(); - } - - private animate() { - this.reqId = requestAnimationFrame(() => this.animate()); - - let frameRate = this.props.frameRate; + let frameRate = state.frameRate; if (frameRate === FRAME_RATE_AUTOMATIC) { frameRate = batteryAwareFrameRate; } if (typeof frameRate === "number") { const currentTime = performance.now(); - const elapsed = currentTime - this.previousTime; + const elapsed = currentTime - state.previousTime; const refreshInterval = 1000 / frameRate; if (elapsed < refreshInterval) { return; } - this.previousTime = currentTime - (elapsed % refreshInterval); + state.previousTime = currentTime - (elapsed % refreshInterval); } - this.props.update(); - } + state.update(); + }, []); + + React.useEffect(() => { + state.previousTime = 0; + animate(); + + return () => { + if (state.reqId) { + cancelAnimationFrame(state.reqId); + } + }; + }, []); + + return children; } diff --git a/src/util/OptionUtil.tsx b/src/util/OptionUtil.tsx index 84cb6c9..7de0b23 100644 --- a/src/util/OptionUtil.tsx +++ b/src/util/OptionUtil.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import * as React from "react"; import { toast } from "react-toastify"; export type Option = T | null | undefined;