Implement Hotkey Settings

This commit is contained in:
Christopher Serr
2018-12-11 01:28:24 +01:00
parent 9122efacde
commit 927debdf10
11 changed files with 457 additions and 68 deletions
+153
View File
@@ -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<T>(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;
}
}
/**
+10
View File
@@ -0,0 +1,10 @@
.hotkey-button {
width: 100%;
margin: 0px;
font-size: 16px;
height: 22px;
}
.hotkey-button:focus {
color: red;
}
+55
View File
@@ -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<EventListenerObject>,
}
export class HotkeyButton extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
listener: null,
};
}
public render() {
return (
<button
className="hotkey-button"
onFocus={() => {
const listener = {
handleEvent: (ev: KeyboardEvent) => this.props.setValue(ev.code),
};
window.addEventListener("keypress", listener);
this.setState({
...this.state,
listener,
});
}}
onBlur={() => {
if (this.state.listener != null) {
window.removeEventListener("keypress", this.state.listener);
this.setState({
...this.state,
listener: null,
});
}
}}
>
{this.props.value}
</button>
);
}
}
+115 -40
View File
@@ -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: HotkeySystem,
timer: SharedTimer,
layout: Layout,
sidebarOpen: boolean,
runEditor: Option<RunEditor>,
layoutEditor: Option<LayoutEditor>,
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",
<RunEditorComponent editor={this.state.runEditor} />,
<RunEditorComponent editor={this.state.menu.editor} />,
];
} else if (this.state.layoutEditor) {
} else if (this.state.menu.kind === MenuKind.LayoutEditor) {
return [
"layout-editor",
<LayoutEditorComponent
editor={this.state.layoutEditor}
editor={this.state.menu.editor}
timer={this.state.timer}
/>,
];
} else if (this.state.menu.kind === MenuKind.SettingsEditor) {
return [
"settings-editor",
<SettingsEditorComponent hotkeyConfig={this.state.menu.config} />,
];
} 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() {
View File
+15
View File
@@ -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<T> {
setValue: (index: number, value: T) => void,
state: SettingsDescriptionJson,
@@ -705,6 +708,18 @@ export class SettingsComponent<T> extends React.Component<Props<T>> {
>
{value.CustomCombobox.list.map((v) => <option value={v}>{v}</option>)}
</select>;
} else if ("Hotkey" in value) {
component = (
<HotkeyButton
value={value.Hotkey}
setValue={(value) => {
this.props.setValue(
valueIndex,
factory.fromString(value),
);
}}
/>
);
} else {
assertNever(value);
}
+3
View File
@@ -0,0 +1,3 @@
.settings-editor {
padding: 10px 10px 10px 10px;
}
+49
View File
@@ -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<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
settings: props.hotkeyConfig.settingsDescriptionAsJson(),
};
}
public render() {
return (
<div className="settings-editor">
<SettingsComponent
factory={SettingValue}
state={this.state.settings}
setValue={(index, value) => {
if (!this.props.hotkeyConfig.setValue(index, value)) {
toast.error("The hotkey is already in use.");
return;
}
this.update();
}}
/>
</div>
);
}
private update() {
this.setState({
...this.state,
settings: this.props.hotkeyConfig.settingsDescriptionAsJson(),
});
}
}
+48 -23
View File
@@ -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<Props, State> {
</div>
);
}
case "settings-editor": {
return (
<div className="sidebar-buttons">
<div className="small">
<button
className="toggle-left"
onClick={(_) => this.props.callbacks.closeSettingsEditor(true)}
>
<i className="fa fa-check" aria-hidden="true" /> OK
</button>
<button
className="toggle-right"
onClick={(_) => this.props.callbacks.closeSettingsEditor(false)}
>
<i className="fa fa-times" aria-hidden="true" /> Cancel
</button>
</div>
</div>
);
}
case "main": {
return (
<div className="sidebar-buttons">
@@ -137,28 +159,6 @@ export class SideBarContent extends React.Component<Props, State> {
<i className="fa fa-upload" aria-hidden="true" /> Export Layout
</button>
<hr />
<button onClick={(_) => this.props.callbacks.connectToServerOrDisconnect()}>
{
(() => {
switch (this.props.connectionState) {
case WebSocket.OPEN:
return <div>
<i className="fa fa-power-off" aria-hidden="true" /> Disconnect
</div>;
case WebSocket.CLOSED:
return <div>
<i className="fa fa-desktop" aria-hidden="true" /> Connect to Server
</div>;
case WebSocket.CONNECTING:
return <div>Connecting...</div>;
case WebSocket.CLOSING:
return <div>Disconnecting...</div>;
default: throw new Error("Unknown WebSocket State");
}
})()
}
</button>
<hr />
<h2>Compare Against</h2>
<div className="choose-comparison">
<button onClick={(_) => this.props.callbacks.switchToPreviousComparison()}>
@@ -195,6 +195,31 @@ export class SideBarContent extends React.Component<Props, State> {
Game Time
</button>
</div>
<hr />
<button onClick={(_) => this.props.callbacks.connectToServerOrDisconnect()}>
{
(() => {
switch (this.props.connectionState) {
case WebSocket.OPEN:
return <div>
<i className="fa fa-power-off" aria-hidden="true" /> Disconnect
</div>;
case WebSocket.CLOSED:
return <div>
<i className="fa fa-desktop" aria-hidden="true" /> Connect to Server
</div>;
case WebSocket.CONNECTING:
return <div>Connecting...</div>;
case WebSocket.CLOSING:
return <div>Disconnecting...</div>;
default: throw new Error("Unknown WebSocket State");
}
})()
}
</button>
<button onClick={() => this.props.callbacks.openSettingsEditor()}>
<i className="fa fa-cog" aria-hidden="true" /> Settings
</button>
</div >
);
}
+8 -4
View File
@@ -6,8 +6,7 @@ export function expect<T>(obj: Option<T>, message: string): T {
if (obj != null) {
return obj;
}
toast.error(`Bug: ${message}`);
throw new Error(message);
return panic(message);
}
interface Disposable {
@@ -18,11 +17,16 @@ interface MaybeDisposable {
dispose?(): void,
}
export function panic(message: string): never {
toast.error(`Bug: ${message}`);
throw new Error(message);
}
export function assertNever(x: never): never { return x; }
export function assert(condition: boolean, message: string) {
if (!condition) {
throw new Error(message);
panic(message);
}
}
@@ -31,7 +35,7 @@ export function assertNull(obj: Option<MaybeDisposable>, message: string) {
if (obj.dispose) {
obj.dispose();
}
throw new Error(message);
panic(message);
}
}