From 927debdf102ebaabbfc430deb12a98259b6a8539 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Tue, 11 Dec 2018 01:28:24 +0100 Subject: [PATCH] Implement Hotkey Settings --- livesplit-core | 2 +- src/livesplit.ts | 153 +++++++++++++++++++++++++++++++++++++ src/ui/HotkeyButton.css | 10 +++ src/ui/HotkeyButton.tsx | 55 ++++++++++++++ src/ui/LiveSplit.tsx | 155 ++++++++++++++++++++++++++++---------- src/ui/Settings.css | 0 src/ui/Settings.tsx | 15 ++++ src/ui/SettingsEditor.css | 3 + src/ui/SettingsEditor.tsx | 49 ++++++++++++ src/ui/SideBarContent.tsx | 71 +++++++++++------ src/util/OptionUtil.ts | 12 ++- 11 files changed, 457 insertions(+), 68 deletions(-) create mode 100644 src/ui/HotkeyButton.css create mode 100644 src/ui/HotkeyButton.tsx create mode 100644 src/ui/Settings.css create mode 100644 src/ui/SettingsEditor.css create mode 100644 src/ui/SettingsEditor.tsx diff --git a/livesplit-core b/livesplit-core index 789e2d0..422dbb9 160000 --- a/livesplit-core +++ b/livesplit-core @@ -1 +1 @@ -Subproject commit 789e2d007f18cbde0ce9bd98784320581a37bbb1 +Subproject commit 422dbb949a483f34e1a4b78fd727cdc17b8510cf diff --git a/src/livesplit.ts b/src/livesplit.ts index b289b4f..ffeb3bd 100644 --- a/src/livesplit.ts +++ b/src/livesplit.ts @@ -892,6 +892,7 @@ export type SettingsDescriptionValueJson = { ColumnStartWith: ColumnStartWith } | { ColumnUpdateWith: ColumnUpdateWith } | { ColumnUpdateTrigger: ColumnUpdateTrigger } | + { Hotkey: string } | { CustomCombobox: CustomCombobox }; /** @@ -2848,6 +2849,112 @@ export class GraphComponentState extends GraphComponentStateRefMut { } } +/** + * The configuration to use for a Hotkey System. It describes with keys to use + * as hotkeys for the different actions. + */ +export class HotkeyConfigRef { + ptr: number; + /** + * Encodes generic description of the settings available for the hotkey + * configuration and their current values as JSON. + */ + settingsDescriptionAsJson(): any { + if (this.ptr == 0) { + throw "this is disposed"; + } + const result = instance().exports.HotkeyConfig_settings_description_as_json(this.ptr); + return JSON.parse(decodeString(result)); + } + /** + * Encodes the hotkey configuration as JSON. + */ + asJson(): any { + if (this.ptr == 0) { + throw "this is disposed"; + } + const result = instance().exports.HotkeyConfig_as_json(this.ptr); + return JSON.parse(decodeString(result)); + } + /** + * This constructor is an implementation detail. Do not use this. + */ + constructor(ptr: number) { + this.ptr = ptr; + } +} + +/** + * The configuration to use for a Hotkey System. It describes with keys to use + * as hotkeys for the different actions. + */ +export class HotkeyConfigRefMut extends HotkeyConfigRef { + /** + * Sets a setting's value by its index to the given value. + * + * false is returned if a hotkey is already in use by a different action. + * + * This panics if the type of the value to be set is not compatible with the + * type of the setting's value. A panic can also occur if the index of the + * setting provided is out of bounds. + */ + setValue(index: number, value: SettingValue): boolean { + if (this.ptr == 0) { + throw "this is disposed"; + } + if (value.ptr == 0) { + throw "value is disposed"; + } + const result = instance().exports.HotkeyConfig_set_value(this.ptr, index, value.ptr) != 0; + value.ptr = 0; + return result; + } +} + +/** + * The configuration to use for a Hotkey System. It describes with keys to use + * as hotkeys for the different actions. + */ +export class HotkeyConfig extends HotkeyConfigRefMut { + /** + * Allows for scoped usage of the object. The object is guaranteed to get + * disposed once this function returns. You are free to dispose the object + * early yourself anywhere within the scope. The scope's return value gets + * carried to the outside of this function. + */ + with(closure: (obj: HotkeyConfig) => T): T { + try { + return closure(this); + } finally { + this.dispose(); + } + } + /** + * Disposes the object, allowing it to clean up all of its memory. You need + * to call this for every object that you don't use anymore and hasn't + * already been disposed. + */ + dispose() { + if (this.ptr != 0) { + instance().exports.HotkeyConfig_drop(this.ptr); + this.ptr = 0; + } + } + /** + * Parses a hotkey configuration from the given JSON description. null is + * returned if it couldn't be parsed. + */ + static parseJson(settings: any): HotkeyConfig | null { + const settings_allocated = allocString(JSON.stringify(settings)); + const result = new HotkeyConfig(instance().exports.HotkeyConfig_parse_json(settings_allocated.ptr)); + dealloc(settings_allocated); + if (result.ptr == 0) { + return null; + } + return result; + } +} + /** * With a Hotkey System the runner can use hotkeys on their keyboard to control * the Timer. The hotkeys are global, so the application doesn't need to be in @@ -2877,6 +2984,16 @@ export class HotkeySystemRef { } instance().exports.HotkeySystem_activate(this.ptr); } + /** + * Returns the hotkey configuration currently in use by the Hotkey System. + */ + config(): HotkeyConfig { + if (this.ptr == 0) { + throw "this is disposed"; + } + const result = new HotkeyConfig(instance().exports.HotkeySystem_config(this.ptr)); + return result; + } /** * This constructor is an implementation detail. Do not use this. */ @@ -2893,6 +3010,23 @@ export class HotkeySystemRef { * System temporarily. By default the Hotkey System is activated. */ export class HotkeySystemRefMut extends HotkeySystemRef { + /** + * Applies a new hotkey configuration to the Hotkey System. Each hotkey is + * changed to the one specified in the configuration. This operation may fail + * if you provide a hotkey configuration where a hotkey is used for multiple + * operations. Returns false if the operation failed. + */ + setConfig(config: HotkeyConfig): boolean { + if (this.ptr == 0) { + throw "this is disposed"; + } + if (config.ptr == 0) { + throw "config is disposed"; + } + const result = instance().exports.HotkeySystem_set_config(this.ptr, config.ptr) != 0; + config.ptr = 0; + return result; + } } /** @@ -2941,6 +3075,25 @@ export class HotkeySystem extends HotkeySystemRefMut { } return result; } + /** + * Creates a new Hotkey System for a Timer with a custom configuration for the + * hotkeys. + */ + static withConfig(sharedTimer: SharedTimer, config: HotkeyConfig): HotkeySystem | null { + if (sharedTimer.ptr == 0) { + throw "sharedTimer is disposed"; + } + if (config.ptr == 0) { + throw "config is disposed"; + } + const result = new HotkeySystem(instance().exports.HotkeySystem_with_config(sharedTimer.ptr, config.ptr)); + sharedTimer.ptr = 0; + config.ptr = 0; + if (result.ptr == 0) { + return null; + } + return result; + } } /** diff --git a/src/ui/HotkeyButton.css b/src/ui/HotkeyButton.css new file mode 100644 index 0000000..15de473 --- /dev/null +++ b/src/ui/HotkeyButton.css @@ -0,0 +1,10 @@ +.hotkey-button { + width: 100%; + margin: 0px; + font-size: 16px; + height: 22px; +} + +.hotkey-button:focus { + color: red; +} diff --git a/src/ui/HotkeyButton.tsx b/src/ui/HotkeyButton.tsx new file mode 100644 index 0000000..20bb2ff --- /dev/null +++ b/src/ui/HotkeyButton.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { Option } from "../util/OptionUtil"; + +import "./HotkeyButton.css"; + +export interface Props { + value: string, + setValue: (value: string) => void, +} + +export interface State { + listener: Option, +} + +export class HotkeyButton extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { + listener: null, + }; + } + + public render() { + return ( + + ); + } +} diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index 076d9d4..336b13f 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -4,23 +4,36 @@ import AutoRefreshLayout from "../layout/AutoRefreshLayout"; import { HotkeySystem, Layout, LayoutEditor, Run, RunEditor, Segment, SharedTimer, Timer, TimerPhase, TimingMethod, - TimeSpan, TimerRef, TimerRefMut, + TimeSpan, TimerRef, TimerRefMut, HotkeyConfig, } from "../livesplit"; import { exportFile, openFileAsArrayBuffer, openFileAsString } from "../util/FileUtil"; -import { Option, assertNull, expect, maybeDispose, maybeDisposeAndThen, map } from "../util/OptionUtil"; +import { Option, assertNull, expect, maybeDispose, maybeDisposeAndThen, map, panic } from "../util/OptionUtil"; import * as SplitsIO from "../util/SplitsIO"; import { LayoutEditor as LayoutEditorComponent } from "./LayoutEditor"; import { RunEditor as RunEditorComponent } from "./RunEditor"; +import { SettingsEditor as SettingsEditorComponent } from "./SettingsEditor"; import { Route, SideBarContent } from "./SideBarContent"; import { toast } from "react-toastify"; +enum MenuKind { + Timer, + RunEditor, + LayoutEditor, + SettingsEditor, +} + +type Menu = + { kind: MenuKind.Timer } | + { kind: MenuKind.RunEditor, editor: RunEditor } | + { kind: MenuKind.LayoutEditor, editor: LayoutEditor } | + { kind: MenuKind.SettingsEditor, config: HotkeyConfig }; + export interface State { - hotkeySystem: Option, + hotkeySystem: HotkeySystem, timer: SharedTimer, layout: Layout, sidebarOpen: boolean, - runEditor: Option, - layoutEditor: Option, + menu: Menu, } export class LiveSplit extends React.Component<{}, State> { @@ -41,7 +54,22 @@ export class LiveSplit extends React.Component<{}, State> { "The Default Run should be a valid Run", ).intoShared(); - const hotkeySystem = HotkeySystem.new(timer.share()); + let hotkeySystem = null; + const settings = localStorage.getItem("settings"); + try { + if (settings) { + const config = HotkeyConfig.parseJson(JSON.parse(settings).hotkeys); + if (config != null) { + hotkeySystem = HotkeySystem.withConfig(timer.share(), config); + } + } + } catch (_) { /* Looks like local storage has no valid data */ } + if (hotkeySystem == null) { + hotkeySystem = expect( + HotkeySystem.new(timer.share()), + "Couldn't initialize the hotkeys", + ); + } if (window.location.hash.indexOf("#/splits-io/") === 0) { const loadingRun = Run.new(); @@ -78,8 +106,7 @@ export class LiveSplit extends React.Component<{}, State> { this.state = { layout, - layoutEditor: null, - runEditor: null, + menu: { kind: MenuKind.Timer }, sidebarOpen: false, timer, hotkeySystem, @@ -117,19 +144,24 @@ export class LiveSplit extends React.Component<{}, State> { public render() { const [route, content] = ((): [Route, JSX.Element] => { - if (this.state.runEditor) { + if (this.state.menu.kind === MenuKind.RunEditor) { return [ "run-editor", - , + , ]; - } else if (this.state.layoutEditor) { + } else if (this.state.menu.kind === MenuKind.LayoutEditor) { return [ "layout-editor", , ]; + } else if (this.state.menu.kind === MenuKind.SettingsEditor) { + return [ + "settings-editor", + , + ]; } else { return [ "main", @@ -316,13 +348,14 @@ export class LiveSplit extends React.Component<{}, State> { }); if (run != null) { - if (this.state.hotkeySystem != null) { - this.state.hotkeySystem.deactivate(); - } - const editor = RunEditor.new(run); + this.state.hotkeySystem.deactivate(); + const editor = expect( + RunEditor.new(run), + "The Run Editor should always be able to be opened.", + ); this.setState({ ...this.state, - runEditor: editor, + menu: { kind: MenuKind.RunEditor, editor }, sidebarOpen: false, }); } else { @@ -331,61 +364,62 @@ export class LiveSplit extends React.Component<{}, State> { } public closeRunEditor(save: boolean) { - const runEditor = expect( - this.state.runEditor, - "No Run Editor to close", - ); + if (this.state.menu.kind !== MenuKind.RunEditor) { + panic("No Run Editor to close"); + return; + } + const runEditor = this.state.menu.editor; const run = runEditor.close(); if (save) { assertNull( this.writeWith((t) => t.setRun(run)), - "The Run Editor should always return a valid Run", + "The Run Editor should always return a valid Run.", ); this.setState({ ...this.state, - runEditor: null, + menu: { kind: MenuKind.Timer }, sidebarOpen: false, }); } else { run.dispose(); this.setState({ ...this.state, - runEditor: null, + menu: { kind: MenuKind.Timer }, sidebarOpen: false, }); } this.state.layout.remount(); - if (this.state.hotkeySystem != null) { - this.state.hotkeySystem.activate(); - } + this.state.hotkeySystem.activate(); } public openLayoutEditor() { - if (this.state.hotkeySystem != null) { - this.state.hotkeySystem.deactivate(); - } + this.state.hotkeySystem.deactivate(); const layout = this.state.layout.clone(); - const editor = LayoutEditor.new(layout); + const editor = expect( + LayoutEditor.new(layout), + "The Layout Editor should always be able to be opened.", + ); this.setState({ ...this.state, - layoutEditor: editor, + menu: { kind: MenuKind.LayoutEditor, editor }, sidebarOpen: false, }); } public closeLayoutEditor(save: boolean) { - const layoutEditor = expect( - this.state.layoutEditor, - "No Layout Editor to close", - ); + if (this.state.menu.kind !== MenuKind.LayoutEditor) { + panic("No Layout Editor to close."); + return; + } + const layoutEditor = this.state.menu.editor; const layout = layoutEditor.close(); if (save) { this.state.layout.dispose(); this.setState({ ...this.state, layout, - layoutEditor: null, + menu: { kind: MenuKind.Timer }, sidebarOpen: false, }); layout.remount(); @@ -393,14 +427,55 @@ export class LiveSplit extends React.Component<{}, State> { layout.dispose(); this.setState({ ...this.state, - layoutEditor: null, + menu: { kind: MenuKind.Timer }, sidebarOpen: false, }); this.state.layout.remount(); } - if (this.state.hotkeySystem != null) { - this.state.hotkeySystem.activate(); + this.state.hotkeySystem.activate(); + } + + public openSettingsEditor() { + this.state.hotkeySystem.deactivate(); + this.setState({ + ...this.state, + menu: { + kind: MenuKind.SettingsEditor, + config: this.state.hotkeySystem.config(), + }, + sidebarOpen: false, + }); + } + + public closeSettingsEditor(save: boolean) { + const menu = this.state.menu; + + if (menu.kind !== MenuKind.SettingsEditor) { + panic("No Settings Editor to close."); + return; } + + if (save) { + try { + const hotkeys = menu.config.asJson(); + const settings = { hotkeys }; + localStorage.setItem("settings", JSON.stringify(settings)); + } catch (_) { + toast.error("Failed to save the settings."); + } + this.state.hotkeySystem.setConfig(menu.config); + } else { + menu.config.dispose(); + } + + this.setState({ + ...this.state, + menu: { kind: MenuKind.Timer }, + sidebarOpen: false, + }); + + this.state.layout.remount(); + this.state.hotkeySystem.activate(); } public connectToServerOrDisconnect() { diff --git a/src/ui/Settings.css b/src/ui/Settings.css new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/Settings.tsx b/src/ui/Settings.tsx index bfe4c3b..612b6d6 100644 --- a/src/ui/Settings.tsx +++ b/src/ui/Settings.tsx @@ -3,6 +3,9 @@ import { Color, SettingsDescriptionJson, SettingsDescriptionValueJson } from ".. import { assertNever, expect, Option } from "../util/OptionUtil"; import ColorPicker from "./ColorPicker"; +import "./Settings.css"; +import { HotkeyButton } from "./HotkeyButton"; + export interface Props { setValue: (index: number, value: T) => void, state: SettingsDescriptionJson, @@ -705,6 +708,18 @@ export class SettingsComponent extends React.Component> { > {value.CustomCombobox.list.map((v) => )} ; + } else if ("Hotkey" in value) { + component = ( + { + this.props.setValue( + valueIndex, + factory.fromString(value), + ); + }} + /> + ); } else { assertNever(value); } diff --git a/src/ui/SettingsEditor.css b/src/ui/SettingsEditor.css new file mode 100644 index 0000000..2e08c69 --- /dev/null +++ b/src/ui/SettingsEditor.css @@ -0,0 +1,3 @@ +.settings-editor { + padding: 10px 10px 10px 10px; +} diff --git a/src/ui/SettingsEditor.tsx b/src/ui/SettingsEditor.tsx new file mode 100644 index 0000000..18f56a7 --- /dev/null +++ b/src/ui/SettingsEditor.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; + +import "./SettingsEditor.css"; +import { SettingsComponent } from "./Settings"; +import { SettingsDescriptionJson, SettingValue, HotkeyConfig } from "../livesplit"; +import { toast } from "react-toastify"; + +export interface Props { + hotkeyConfig: HotkeyConfig, +} + +export interface State { + settings: SettingsDescriptionJson, +} + +export class SettingsEditor extends React.Component { + constructor(props: Props) { + super(props); + + this.state = { + settings: props.hotkeyConfig.settingsDescriptionAsJson(), + }; + } + + public render() { + return ( +
+ { + if (!this.props.hotkeyConfig.setValue(index, value)) { + toast.error("The hotkey is already in use."); + return; + } + this.update(); + }} + /> +
+ ); + } + + private update() { + this.setState({ + ...this.state, + settings: this.props.hotkeyConfig.settingsDescriptionAsJson(), + }); + } +} diff --git a/src/ui/SideBarContent.tsx b/src/ui/SideBarContent.tsx index 61bce78..b8a2c0a 100644 --- a/src/ui/SideBarContent.tsx +++ b/src/ui/SideBarContent.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { SharedTimerRef, TimingMethod } from "../livesplit"; import { Option } from "../util/OptionUtil"; -export type Route = "main" | "run-editor" | "layout-editor"; +export type Route = "main" | "run-editor" | "layout-editor" | "settings-editor"; export interface SidebarCallbacks { closeRunEditor(save: boolean): void, @@ -21,6 +21,8 @@ export interface SidebarCallbacks { switchToNextComparison(): void, setCurrentTimingMethod(timingMethod: TimingMethod): void, connectToServerOrDisconnect(): void, + openSettingsEditor(): void, + closeSettingsEditor(save: boolean): void, } export interface Props { @@ -101,6 +103,26 @@ export class SideBarContent extends React.Component { ); } + case "settings-editor": { + return ( +
+
+