diff --git a/src/common/TResult.ts b/src/common/TResult.ts new file mode 100644 index 0000000..5155bea --- /dev/null +++ b/src/common/TResult.ts @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2021-present Rong "Mantle" Bao + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/ . + */ + +export type TResult = { ok: true; value?: T } | { ok: false; err?: E } diff --git a/src/common/index.ts b/src/common/index.ts index 900c5f0..06f7300 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -39,6 +39,7 @@ import { waitForEvent } from "./waitForEvent" import type { ISize } from "./ISize" import type { TPosition } from "./TPosition" +import type { TResult } from "./TResult" export { AutoplaySentinel, @@ -61,4 +62,4 @@ export { rearrange, waitForEvent, } -export type { ISize, TPosition } +export type { ISize, TPosition, TResult } diff --git a/src/components/FileFormControl.tsx b/src/components/FileFormControl.tsx index 8c643ca..faf84f8 100644 --- a/src/components/FileFormControl.tsx +++ b/src/components/FileFormControl.tsx @@ -26,7 +26,7 @@ import TextField from "@mui/material/TextField" import Tooltip from "@mui/material/Tooltip" import styled from "@mui/system/styled" -import { isNil } from "../common" +import type { TResult } from "../common" const HiddenInput = styled("input")({ display: "none", @@ -79,13 +79,14 @@ interface IFileFormControlProps { readonly helperText: string readonly readOnly?: boolean readonly disabled?: boolean - readonly onFileChange: (newContent: string) => boolean | void + readonly onFileChange: (newContent: string) => TResult readonly contentPreprocessor?: (content: string) => string } export const FileFormControl = (props: IFileFormControlProps) => { const [fileContent, setFileContent] = React.useState(props.initialFileContent) const [error, setError] = React.useState(false) + const [helperText, setHelperText] = React.useState(props.helperText) return ( @@ -95,7 +96,7 @@ export const FileFormControl = (props: IFileFormControlProps) => { fullWidth value={fileContent} label={props.label} - helperText={props.helperText} + helperText={helperText} disabled={props.disabled ?? false} error={error} InputProps={{ @@ -113,9 +114,11 @@ export const FileFormControl = (props: IFileFormControlProps) => { let content = (await head(event.target.files)?.text()) ?? "" content = props.contentPreprocessor?.(content) ?? content const result = props.onFileChange(content) - const isSuccessful = isNil(result) || result setFileContent(content) - setError(!isSuccessful) + setError(!result.ok) + setHelperText( + result.ok ? props.helperText : result.err ?? props.helperText + ) }} /> diff --git a/src/components/NumberFormControl.tsx b/src/components/NumberFormControl.tsx index 25aa4d9..36f3067 100644 --- a/src/components/NumberFormControl.tsx +++ b/src/components/NumberFormControl.tsx @@ -21,6 +21,8 @@ import TextField from "@mui/material/TextField" import { isNil } from "../common" +import type { TResult } from "../common" + interface INumberFormControlProps { readonly id: string readonly initialContent: string @@ -34,13 +36,14 @@ interface INumberFormControlProps { readonly min?: number readonly max?: number readonly disabled?: boolean - readonly onChange: (newContent: string) => boolean | void + readonly onChange: (newContent: string) => TResult readonly contentPreprocessor?: (content: string) => string } export const NumberFormControl = (props: INumberFormControlProps) => { const [content, setContent] = React.useState(props.initialContent) const [error, setError] = React.useState(false) + const [helperText, setHelperText] = React.useState(props.helperText) return ( { value={content} label={props.label} type="number" - helperText={props.helperText} + helperText={helperText} InputProps={{ ...props.adornments }} inputProps={{ + inputMode: "numeric", step: props.step, min: props.min, max: props.max, @@ -62,9 +66,11 @@ export const NumberFormControl = (props: INumberFormControlProps) => { ? event.target.value : props.contentPreprocessor(event.target.value) const result = props.onChange(content) - const isSuccessful = isNil(result) || result setContent(content) - setError(!isSuccessful) + setError(!result.ok) + setHelperText( + result.ok ? props.helperText : result.err ?? props.helperText + ) }} /> ) diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 4e1b46f..0fa330d 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -46,9 +46,12 @@ import { queryPath } from "../common" import { CommonHead, FileFormControl, NumberFormControl } from "../components" import { customizationFacade } from "../customization" +const INT_REGEX = /^[0-9]+$/ + const tryParseInt = (s: string): readonly [boolean, number] => { - const v = parseInt(s, 10) - return [!isNaN(v), v] + const isInt = INT_REGEX.test(s) + const v = isInt ? parseInt(s, 10) : Number.NaN + return [isInt, v] } const App = ({ data }: PageProps) => { @@ -172,12 +175,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.gridLineThickness = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -210,12 +210,9 @@ const App = ({ data }: PageProps) => { const obj = JSON.parse(newContent) if (validateColorScheme(obj)) { customizationFacade.settings.colorScheme = obj - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_color_scheme_invalid"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_color_scheme_invalid") } } }} contentPreprocessor={jsonMinifyPreprocessor} @@ -245,12 +242,9 @@ const App = ({ data }: PageProps) => { const obj = JSON.parse(newContent) if (validateMap(obj)) { customizationFacade.settings.gameMap = obj - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_game_map_invalid"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_game_map_invalid") } } }} contentPreprocessor={jsonMinifyPreprocessor} @@ -270,12 +264,9 @@ const App = ({ data }: PageProps) => { if (isValid) { customizationFacade.settings.gameUpdateIntervalMilliseconds = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -304,12 +295,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.swipeThreshold = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -327,12 +315,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.swipeDeltaX = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -350,12 +335,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.swipeDeltaY = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -373,12 +355,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.pressThreshold = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -404,12 +383,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.concurrency = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} />