mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
+26
-2
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
// File: actions/app.ts
|
||||
// Actions for the app in general.
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Action } from 'redux';
|
||||
export enum AppActionType {
|
||||
/** The app has just ben started. */
|
||||
Startup = 'app.action.startup',
|
||||
/** Open settings dialog. */
|
||||
OpenSettings = 'app.action.openSettings',
|
||||
/** Close settings dialog. */
|
||||
CloseSettings = 'app.action.closeSettings',
|
||||
/** Toggle documentation visibility. */
|
||||
ToggleDocs = 'app.action.toggleDocs',
|
||||
}
|
||||
@@ -21,6 +25,22 @@ export function startup(): AppStartupAction {
|
||||
return { type: AppActionType.Startup };
|
||||
}
|
||||
|
||||
/** Action to open the settings dialog. */
|
||||
export type AppOpenSettingsAction = Action<AppActionType.OpenSettings>;
|
||||
|
||||
/** Creates an action to open the settings dialog. */
|
||||
export function openSettings(): AppOpenSettingsAction {
|
||||
return { type: AppActionType.OpenSettings };
|
||||
}
|
||||
|
||||
/** Action to close the settings dialog. */
|
||||
export type AppCloseSettingsAction = Action<AppActionType.CloseSettings>;
|
||||
|
||||
/** Creates an action to close the settings dialog. */
|
||||
export function closeSettings(): AppCloseSettingsAction {
|
||||
return { type: AppActionType.CloseSettings };
|
||||
}
|
||||
|
||||
/** Action to toggle documentation visibility. */
|
||||
export type AppToggleDocsAction = Action<AppActionType.ToggleDocs>;
|
||||
|
||||
@@ -30,4 +50,8 @@ export function toggleDocs(): AppToggleDocsAction {
|
||||
}
|
||||
|
||||
/** common type for all app actions. */
|
||||
export type AppAction = AppStartupAction | AppToggleDocsAction;
|
||||
export type AppAction =
|
||||
| AppStartupAction
|
||||
| AppOpenSettingsAction
|
||||
| AppCloseSettingsAction
|
||||
| AppToggleDocsAction;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { MpyAction } from './mpy';
|
||||
import { NotificationAction } from './notification';
|
||||
import { ServiceWorkerAction } from './service-worker';
|
||||
import { SettingsAction } from './settings';
|
||||
import { TerminalDataAction } from './terminal';
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,7 @@ export type Action =
|
||||
| MpyAction
|
||||
| NotificationAction
|
||||
| ServiceWorkerAction
|
||||
| SettingsAction
|
||||
| TerminalDataAction;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { Action } from 'redux';
|
||||
import { SettingId } from '../settings';
|
||||
|
||||
/** Actions related to settings. */
|
||||
export enum SettingsActionType {
|
||||
SetBoolean = 'settings.action.setBoolean',
|
||||
DidFailToSetBoolean = 'settings.action.didFailToSetBoolean',
|
||||
DidBooleanChange = 'settings.action.didBooleanChange',
|
||||
}
|
||||
|
||||
type SettingInfo<T> = {
|
||||
/** The ID of the setting. */
|
||||
id: SettingId;
|
||||
/** The new state for the setting. */
|
||||
newState: T;
|
||||
};
|
||||
|
||||
/** Action to set/store a setting. */
|
||||
export type SettingsSetBooleanAction = Action<SettingsActionType.SetBoolean> &
|
||||
SettingInfo<boolean>;
|
||||
|
||||
/** Creates an action to set/store a setting. */
|
||||
export function setBoolean(id: SettingId, newState: boolean): SettingsSetBooleanAction {
|
||||
return { type: SettingsActionType.SetBoolean, id, newState };
|
||||
}
|
||||
|
||||
/** Action that indicates setting/storing a setting failed. */
|
||||
export type SettingsDidFailToSetBooleanAction = Action<SettingsActionType.DidFailToSetBoolean> & {
|
||||
id: SettingId;
|
||||
err: Error;
|
||||
};
|
||||
|
||||
/** Creates an action indicating that setting/storing a setting failed. */
|
||||
export function didFailToSetBoolean(
|
||||
id: SettingId,
|
||||
err: Error,
|
||||
): SettingsDidFailToSetBooleanAction {
|
||||
return { type: SettingsActionType.DidFailToSetBoolean, id, err };
|
||||
}
|
||||
|
||||
/** Action that indicates a stored boolean setting value changed. */
|
||||
export type SettingsDidBooleanChangeAction = Action<SettingsActionType.DidBooleanChange> &
|
||||
SettingInfo<boolean>;
|
||||
|
||||
/** Creates an action that indicates a stored boolean setting value changed. */
|
||||
export function didBooleanChange(
|
||||
id: SettingId,
|
||||
newState: boolean,
|
||||
): SettingsDidBooleanChangeAction {
|
||||
return { type: SettingsActionType.DidBooleanChange, id, newState };
|
||||
}
|
||||
|
||||
/** Common type for all settings actions. */
|
||||
export type SettingsAction =
|
||||
| SettingsSetBooleanAction
|
||||
| SettingsDidFailToSetBooleanAction
|
||||
| SettingsDidBooleanChangeAction;
|
||||
@@ -6,14 +6,16 @@ import { useSelector } from 'react-redux';
|
||||
import SplitterLayout from 'react-splitter-layout';
|
||||
import { RootState } from '../reducers';
|
||||
import Editor from './Editor';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
import StatusBar from './StatusBar';
|
||||
import Terminal from './Terminal';
|
||||
import Toolbar from './Toolbar';
|
||||
|
||||
import 'react-splitter-layout/lib/index.css';
|
||||
import './app.scss';
|
||||
|
||||
function App(): JSX.Element {
|
||||
const showDocs = useSelector((s: RootState): boolean => s.app.showDocs);
|
||||
const showDocs = useSelector((s: RootState): boolean => s.settings.showDocs);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -61,6 +63,7 @@ function App(): JSX.Element {
|
||||
)}
|
||||
</SplitterLayout>
|
||||
<StatusBar />
|
||||
<SettingsDrawer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
import {
|
||||
ContextMenuTarget,
|
||||
@@ -16,23 +16,31 @@ import { IAceEditor } from 'react-ace/lib/types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Action, Dispatch } from '../actions';
|
||||
import { setEditSession, storageChanged } from '../actions/editor';
|
||||
import { RootState } from '../reducers';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { EditorStringId } from './editor-i18n';
|
||||
import en from './editor-i18n.en.json';
|
||||
|
||||
import 'ace-builds/src-noconflict/mode-python';
|
||||
import 'ace-builds/src-noconflict/theme-tomorrow_night_eighties';
|
||||
import 'ace-builds/src-noconflict/theme-xcode';
|
||||
import 'ace-builds/src-noconflict/ext-searchbox';
|
||||
import 'ace-builds/src-noconflict/ext-keybinding_menu';
|
||||
import 'ace-builds/src-noconflict/ext-language_tools';
|
||||
|
||||
import './editor-snippets';
|
||||
import './editor.scss';
|
||||
|
||||
type StateProps = {
|
||||
darkMode: boolean;
|
||||
};
|
||||
|
||||
type DispatchProps = {
|
||||
onSessionChanged: (session?: Ace.EditSession) => void;
|
||||
onProgramStorageChanged: (newValue: string) => void;
|
||||
};
|
||||
|
||||
type EditorProps = DispatchProps & WithI18nProps;
|
||||
type EditorProps = StateProps & DispatchProps & WithI18nProps;
|
||||
|
||||
@ContextMenuTarget
|
||||
class Editor extends React.Component<EditorProps> {
|
||||
@@ -68,14 +76,14 @@ class Editor extends React.Component<EditorProps> {
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
const { i18n, onSessionChanged } = this.props;
|
||||
const { darkMode, i18n, onSessionChanged } = this.props;
|
||||
return (
|
||||
<div className="h-100 watermark">
|
||||
<ResizeSensor onResize={(): void => this.editor?.resize()}>
|
||||
<AceEditor
|
||||
ref={this.editorRef}
|
||||
mode="python"
|
||||
theme="xcode"
|
||||
theme={darkMode ? 'tomorrow_night_eighties' : 'xcode'}
|
||||
fontSize="16pt"
|
||||
width="100%"
|
||||
height="100%"
|
||||
@@ -136,7 +144,7 @@ class Editor extends React.Component<EditorProps> {
|
||||
}}
|
||||
text={i18n.translate(EditorStringId.Copy)}
|
||||
icon="duplicate"
|
||||
label={/mac/i.test(navigator.platform) ? 'Cmd-C' : 'Ctrl-C'}
|
||||
label={isMacOS() ? 'Cmd-C' : 'Ctrl-C'}
|
||||
disabled={this.editor?.getSelection().isEmpty()}
|
||||
/>
|
||||
<MenuItem
|
||||
@@ -148,7 +156,7 @@ class Editor extends React.Component<EditorProps> {
|
||||
}}
|
||||
text={i18n.translate(EditorStringId.Paste)}
|
||||
icon="clipboard"
|
||||
label={/mac/i.test(navigator.platform) ? 'Cmd-V' : 'Ctrl-V'}
|
||||
label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
@@ -171,12 +179,16 @@ class Editor extends React.Component<EditorProps> {
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => ({
|
||||
darkMode: state.settings.darkMode,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
onSessionChanged: (s): Action => dispatch(setEditSession(s)),
|
||||
onProgramStorageChanged: (v): Action => dispatch(storageChanged(v)),
|
||||
});
|
||||
|
||||
export default connect(
|
||||
undefined,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(withI18n({ id: 'editor', fallback: en, translations: { en } })(Editor));
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// File: components/DocsButton.ts
|
||||
// Toolbar button for toggling documentation.
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { Action, Dispatch } from '../actions';
|
||||
import { toggleDocs } from '../actions/app';
|
||||
import { openSettings as openSettings } from '../actions/app';
|
||||
import ActionButton, { ActionButtonProps } from './ActionButton';
|
||||
import { TooltipId } from './button-i18n';
|
||||
import docsIcon from './images/pybricks.svg';
|
||||
import settingsIcon from './images/settings.svg';
|
||||
|
||||
type StateProps = undefined;
|
||||
type DispatchProps = Pick<ActionButtonProps, 'onAction'>;
|
||||
type OwnProps = Pick<ActionButtonProps, 'id'> &
|
||||
Pick<ActionButtonProps, 'keyboardShortcut'>;
|
||||
type OwnProps = Pick<ActionButtonProps, 'id'>;
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
onAction: (): Action => dispatch(toggleDocs()),
|
||||
onAction: (): Action => dispatch(openSettings()),
|
||||
});
|
||||
|
||||
const mergeProps = (
|
||||
@@ -24,10 +21,10 @@ const mergeProps = (
|
||||
dispatchProps: DispatchProps,
|
||||
ownProps: OwnProps,
|
||||
): ActionButtonProps => ({
|
||||
tooltip: TooltipId.Docs,
|
||||
icon: docsIcon,
|
||||
...ownProps,
|
||||
tooltip: TooltipId.Settings,
|
||||
icon: settingsIcon,
|
||||
...dispatchProps,
|
||||
...ownProps,
|
||||
});
|
||||
|
||||
export default connect(undefined, mapDispatchToProps, mergeProps)(ActionButton);
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { Drawer, FormGroup, Position, Switch, Tooltip } from '@blueprintjs/core';
|
||||
import { WithI18nProps, withI18n } from '@shopify/react-i18n';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Action, Dispatch } from '../actions';
|
||||
import { closeSettings } from '../actions/app';
|
||||
import { setBoolean } from '../actions/settings';
|
||||
import { RootState } from '../reducers';
|
||||
import { SettingId } from '../settings';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { SettingsStringId } from './settings-i18n';
|
||||
import en from './settings-i18n.en.json';
|
||||
|
||||
import './settings.scss';
|
||||
|
||||
const tooltipDelay = 1000;
|
||||
|
||||
type StateProps = {
|
||||
open: boolean;
|
||||
showDocs: boolean;
|
||||
darkMode: boolean;
|
||||
flashCurrentProgram: boolean;
|
||||
};
|
||||
|
||||
type DispatchProps = {
|
||||
onClose: () => void;
|
||||
onShowDocsChanged: (checked: boolean) => void;
|
||||
onDarkModeChanged: (checked: boolean) => void;
|
||||
onFlashCurrentProgramChanged: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
type SettingsProps = StateProps & DispatchProps & WithI18nProps;
|
||||
|
||||
class SettingsDrawer extends React.PureComponent<SettingsProps> {
|
||||
render(): JSX.Element {
|
||||
const {
|
||||
i18n,
|
||||
open,
|
||||
onClose,
|
||||
showDocs,
|
||||
onShowDocsChanged,
|
||||
darkMode,
|
||||
onDarkModeChanged,
|
||||
flashCurrentProgram,
|
||||
onFlashCurrentProgramChanged,
|
||||
} = this.props;
|
||||
return (
|
||||
<Drawer
|
||||
isOpen={open}
|
||||
icon="cog"
|
||||
size={Drawer.SIZE_SMALL}
|
||||
title={i18n.translate(SettingsStringId.Title)}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<div className="pb-settings">
|
||||
<FormGroup
|
||||
label={i18n.translate(SettingsStringId.AppearanceTitle)}
|
||||
helperText={i18n.translate(
|
||||
SettingsStringId.AppearanceZoomHelp,
|
||||
{
|
||||
in: <span>{isMacOS() ? 'Cmd' : 'Ctrl'}-+</span>,
|
||||
out: <span>{isMacOS() ? 'Cmd' : 'Ctrl'}--</span>,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
content={i18n.translate(
|
||||
SettingsStringId.AppearanceDocumentationTooltip,
|
||||
)}
|
||||
position={Position.LEFT}
|
||||
targetTagName="div"
|
||||
hoverOpenDelay={tooltipDelay}
|
||||
>
|
||||
<Switch
|
||||
label={i18n.translate(
|
||||
SettingsStringId.AppearanceDocumentationLabel,
|
||||
)}
|
||||
large={true}
|
||||
checked={showDocs}
|
||||
onChange={(e) =>
|
||||
onShowDocsChanged(
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={i18n.translate(
|
||||
SettingsStringId.AppearanceDarkModeTooltip,
|
||||
)}
|
||||
position={Position.LEFT}
|
||||
targetTagName="div"
|
||||
hoverOpenDelay={tooltipDelay}
|
||||
>
|
||||
<Switch
|
||||
label={i18n.translate(
|
||||
SettingsStringId.AppearanceDarkModeLabel,
|
||||
)}
|
||||
large={true}
|
||||
checked={darkMode}
|
||||
onChange={(e) =>
|
||||
onDarkModeChanged(
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</FormGroup>
|
||||
<FormGroup label={i18n.translate(SettingsStringId.FirmwareTitle)}>
|
||||
<Tooltip
|
||||
content={i18n.translate(
|
||||
SettingsStringId.FirmwareCurrentProgramTooltip,
|
||||
)}
|
||||
position={Position.LEFT}
|
||||
targetTagName="div"
|
||||
hoverOpenDelay={tooltipDelay}
|
||||
>
|
||||
<Switch
|
||||
label={i18n.translate(
|
||||
SettingsStringId.FirmwareCurrentProgramLabel,
|
||||
)}
|
||||
large={true}
|
||||
checked={flashCurrentProgram}
|
||||
onChange={(e) =>
|
||||
onFlashCurrentProgramChanged(
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => ({
|
||||
open: state.app.showSettings,
|
||||
showDocs: state.settings.showDocs,
|
||||
darkMode: state.settings.darkMode,
|
||||
flashCurrentProgram: state.settings.flashCurrentProgram,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
onClose: (): Action => dispatch(closeSettings()),
|
||||
onShowDocsChanged: (checked): Action =>
|
||||
dispatch(setBoolean(SettingId.ShowDocs, checked)),
|
||||
onDarkModeChanged: (checked): Action =>
|
||||
dispatch(setBoolean(SettingId.DarkMode, checked)),
|
||||
onFlashCurrentProgramChanged: (checked): Action =>
|
||||
dispatch(setBoolean(SettingId.FlashCurrentProgram, checked)),
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(
|
||||
withI18n({
|
||||
id: 'settings',
|
||||
fallback: en,
|
||||
translations: { en },
|
||||
})(SettingsDrawer),
|
||||
);
|
||||
@@ -6,6 +6,8 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { RootState } from '../reducers';
|
||||
|
||||
import './status-bar.scss';
|
||||
|
||||
type StateProps = { progress: number };
|
||||
|
||||
type StatusProps = StateProps;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import LinkButton, { LinkButtonProps } from './LinkButton';
|
||||
import { TooltipId } from './button-i18n';
|
||||
import supportIcon from './images/support.svg';
|
||||
|
||||
type StateProps = undefined;
|
||||
type DispatchProps = undefined;
|
||||
type OwnProps = Pick<LinkButtonProps, 'id'>;
|
||||
|
||||
const mergeProps = (
|
||||
_stateProps: StateProps,
|
||||
_dispatchProps: DispatchProps,
|
||||
ownProps: OwnProps,
|
||||
): LinkButtonProps => ({
|
||||
url: 'https://github.com/pybricks/support/issues',
|
||||
tooltip: TooltipId.Support,
|
||||
icon: supportIcon,
|
||||
...ownProps,
|
||||
});
|
||||
|
||||
export default connect(undefined, undefined, mergeProps)(LinkButton);
|
||||
+15
-10
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
import {
|
||||
ContextMenuTarget,
|
||||
@@ -17,6 +17,7 @@ import { FitAddon } from 'xterm-addon-fit';
|
||||
import { Dispatch } from '../actions';
|
||||
import { receiveData } from '../actions/terminal';
|
||||
import { RootState } from '../reducers';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { TerminalStringId } from './terminal-i18n';
|
||||
import en from './terminal-i18n.en.json';
|
||||
|
||||
@@ -24,6 +25,7 @@ import 'xterm/css/xterm.css';
|
||||
|
||||
interface StateProps {
|
||||
dataSource: Observable<string> | null;
|
||||
darkMode: boolean;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
@@ -45,13 +47,6 @@ class Terminal extends React.Component<TerminalProps> {
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'underline',
|
||||
fontSize: 18,
|
||||
theme: {
|
||||
background: 'white',
|
||||
foreground: 'black',
|
||||
cursor: 'black',
|
||||
// transparency is needed to work around https://github.com/xtermjs/xterm.js/issues/2808
|
||||
selection: 'rgba(181,213,255,0.5)', // this should match AceEditor theme
|
||||
},
|
||||
});
|
||||
this.fitAddon = new FitAddon();
|
||||
this.xterm.loadAddon(this.fitAddon);
|
||||
@@ -112,6 +107,15 @@ class Terminal extends React.Component<TerminalProps> {
|
||||
}
|
||||
|
||||
render(): JSX.Element {
|
||||
this.xterm.setOption('theme', {
|
||||
background: this.props.darkMode ? 'black' : 'white',
|
||||
foreground: this.props.darkMode ? 'white' : 'black',
|
||||
cursor: this.props.darkMode ? 'white' : 'black',
|
||||
// transparency is needed to work around https://github.com/xtermjs/xterm.js/issues/2808
|
||||
selection: this.props.darkMode
|
||||
? 'rgb(81,81,81,0.5)'
|
||||
: 'rgba(181,213,255,0.5)', // this should match AceEditor theme
|
||||
});
|
||||
return (
|
||||
<div className="h-100">
|
||||
<ResizeSensor onResize={(): void => this.fitAddon.fit()}>
|
||||
@@ -134,7 +138,7 @@ class Terminal extends React.Component<TerminalProps> {
|
||||
}}
|
||||
text={i18n.translate(TerminalStringId.Copy)}
|
||||
icon="duplicate"
|
||||
label={/mac/i.test(navigator.platform) ? 'Cmd-C' : 'Ctrl-Shift-C'}
|
||||
label={isMacOS() ? 'Cmd-C' : 'Ctrl-Shift-C'}
|
||||
disabled={!this.xterm.hasSelection()}
|
||||
/>
|
||||
<MenuItem
|
||||
@@ -143,7 +147,7 @@ class Terminal extends React.Component<TerminalProps> {
|
||||
}}
|
||||
text={i18n.translate(TerminalStringId.Paste)}
|
||||
icon="clipboard"
|
||||
label={/mac/i.test(navigator.platform) ? 'Cmd-V' : 'Ctrl-V'}
|
||||
label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
@@ -163,6 +167,7 @@ class Terminal extends React.Component<TerminalProps> {
|
||||
|
||||
const mapStateToProps = (state: RootState): StateProps => ({
|
||||
dataSource: state.terminal.dataSource,
|
||||
darkMode: state.settings.darkMode,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
import { Alignment, ButtonGroup, Navbar } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import BluetoothButton from './BluetoothButton';
|
||||
import DocsButton from './DocsButton';
|
||||
import FlashButton from './FlashButton';
|
||||
import OpenButton from './OpenButton';
|
||||
import ReplButton from './ReplButton';
|
||||
import RunButton from './RunButton';
|
||||
import SaveAsButton from './SaveAsButton';
|
||||
import SettingsButton from './SettingsButton';
|
||||
import StopButton from './StopButton';
|
||||
import SupportButton from './SupportButton';
|
||||
|
||||
import './toolbar.scss';
|
||||
|
||||
class Toolbar extends React.Component {
|
||||
render(): JSX.Element {
|
||||
@@ -27,20 +28,19 @@ class Toolbar extends React.Component {
|
||||
</ButtonGroup>
|
||||
<Navbar.Divider />
|
||||
<ButtonGroup>
|
||||
<BluetoothButton id="bluetooth" />
|
||||
<RunButton id="run" keyboardShortcut="F5" />
|
||||
<StopButton id="stop" keyboardShortcut="F6" />
|
||||
<ReplButton id="repl" />
|
||||
</ButtonGroup>
|
||||
<Navbar.Divider />
|
||||
<ButtonGroup>
|
||||
<ReplButton id="repl" />
|
||||
<FlashButton id="flash" />
|
||||
<BluetoothButton id="bluetooth" />
|
||||
</ButtonGroup>
|
||||
</Navbar.Group>
|
||||
<Navbar.Group align={Alignment.RIGHT}>
|
||||
<ButtonGroup>
|
||||
<SupportButton id="support" />
|
||||
<DocsButton id="docs" />
|
||||
<SettingsButton id="settings" />
|
||||
</ButtonGroup>
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
// Custom styling for the App control.
|
||||
|
||||
@import '../variables.scss';
|
||||
|
||||
.bp3-dark .splitter-layout > .layout-splitter {
|
||||
// make layout splitter match app color scheme
|
||||
background-color: $pt-dark-app-background-color;
|
||||
}
|
||||
|
||||
.splitter-layout > .layout-splitter {
|
||||
// make layout splitter match app color scheme
|
||||
background-color: $pt-app-background-color;
|
||||
}
|
||||
|
||||
.bp3-dark .terminal-padding {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.terminal-padding {
|
||||
padding-left: 10px;
|
||||
}
|
||||
@@ -9,6 +9,5 @@
|
||||
"disconnect": { "tooltip": "Disconnect Bluetooth" }
|
||||
},
|
||||
"flash": { "tooltip": "Flash hub firmware" },
|
||||
"docs": { "tooltip": "Show/hide documentation" },
|
||||
"support": { "tooltip": "Open Pybricks Support web site" }
|
||||
"settings": { "tooltip": "Settings" }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,5 @@ export enum TooltipId {
|
||||
Flash = 'flash.tooltip',
|
||||
BluetoothConnect = 'bluetooth.connect.tooltip',
|
||||
BluetoothDisconnect = 'bluetooth.disconnect.tooltip',
|
||||
Docs = 'docs.tooltip',
|
||||
Support = 'support.tooltip',
|
||||
Settings = 'settings.tooltip',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
// Custom styling for the Editor control.
|
||||
|
||||
@import '../variables.scss';
|
||||
|
||||
.bp3-dark .ace_gutter {
|
||||
// make ace editor match app backgound color
|
||||
background-color: $pt-dark-app-background-color;
|
||||
}
|
||||
|
||||
.ace_gutter {
|
||||
// make ace editor match app backgound color
|
||||
background-color: $pt-app-background-color;
|
||||
}
|
||||
|
||||
.watermark::after {
|
||||
content: "";
|
||||
background: url("./images/beta.svg");
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
pointer-events: none
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="Capa_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 50 50"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="settings.svg"
|
||||
width="50"
|
||||
height="50"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"><metadata
|
||||
id="metadata41"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs39" /><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="2089"
|
||||
inkscape:window-height="1160"
|
||||
id="namedview37"
|
||||
showgrid="false"
|
||||
inkscape:pagecheckerboard="true"
|
||||
inkscape:zoom="15.418475"
|
||||
inkscape:cx="21.7408"
|
||||
inkscape:cy="28.83975"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="Capa_1" />
|
||||
<g
|
||||
id="g4"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,4.9999835,4.9999835)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
<path
|
||||
d="m 20.701,281.901 32.1,0.2 c 4.8,24.7 14.3,48.7 28.7,70.5 l -22.8,22.6 c -8.2,8.1 -8.2,21.2 -0.2,29.4 l 24.6,24.9 c 8.1,8.2 21.2,8.2 29.4,0.2 l 22.8,-22.6 c 21.6,14.6 45.5,24.5 70.2,29.5 l -0.2,32.1 c -0.1,11.5 9.2,20.8 20.7,20.9 l 35,0.2 c 11.5,0.1 20.8,-9.2 20.9,-20.7 l 0.2,-32.1 c 24.7,-4.8 48.7,-14.3 70.5,-28.7 l 22.6,22.8 c 8.1,8.2 21.2,8.2 29.4,0.2 l 24.9,-24.6 c 8.2,-8.1 8.2,-21.2 0.2,-29.4 l -22.6,-22.8 c 14.6,-21.6 24.5,-45.5 29.5,-70.2 l 32.1,0.2 c 11.5,0.1 20.8,-9.2 20.9,-20.7 l 0.2,-35 c 0.1,-11.5 -9.2,-20.8 -20.7,-20.9 l -32.1,-0.2 c -4.8,-24.7 -14.3,-48.7 -28.7,-70.5 l 22.8,-22.6 c 8.2,-8.1 8.2,-21.2 0.2,-29.4 l -24.6,-24.9 c -8.1,-8.2 -21.2,-8.2 -29.4,-0.2 l -22.8,22.6 c -21.6,-14.6 -45.5,-24.5 -70.2,-29.5 l 0.2,-32.1 c 0.1,-11.5 -9.2,-20.8 -20.7,-20.9 l -35,-0.2 c -11.5,-0.1 -20.8,9.2 -20.9,20.7 l -0.3,32.1 c -24.8,4.8 -48.8,14.3 -70.5,28.7 l -22.6,-22.8 c -8.1,-8.2 -21.2,-8.2 -29.4,-0.2 l -24.8,24.6 c -8.2,8.1 -8.2,21.2 -0.2,29.4 l 22.6,22.8 c -14.6,21.6 -24.5,45.5 -29.5,70.2 l -32.1,-0.2 c -11.5,-0.1 -20.8,9.2 -20.9,20.7 l -0.2,35 c -0.1,11.4 9.2,20.8 20.7,20.9 z m 158.6,-103.3 c 36.6,-36.2 95.5,-35.9 131.7,0.7 36.2,36.6 35.9,95.5 -0.7,131.7 -36.6,36.2 -95.5,35.9 -131.7,-0.7 -36.2,-36.6 -35.9,-95.5 0.7,-131.7 z"
|
||||
id="path2"
|
||||
inkscape:connector-curvature="0"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964" />
|
||||
</g>
|
||||
<g
|
||||
id="g6"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g8"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g10"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g12"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g14"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g16"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g18"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g20"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g22"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g24"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g26"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g28"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g30"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g32"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
<g
|
||||
id="g34"
|
||||
transform="matrix(0.08166572,0,0,0.08166572,224.90098,-214.90102)"
|
||||
style="fill:#ffffff;fill-opacity:0.90707964">
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"documentation": {
|
||||
"label": "Documentation",
|
||||
"tooltip": "Hides/shows the documentation pane in the app."
|
||||
},
|
||||
"dark-mode": {
|
||||
"label": "Dark mode",
|
||||
"tooltip": "Disables/enables dark mode."
|
||||
},
|
||||
"zoom": {
|
||||
"help": "Use {in} and {out} to zoom."
|
||||
}
|
||||
},
|
||||
"firmware": {
|
||||
"title": "Firmware",
|
||||
"flash-current-program": {
|
||||
"label": "Flash my program",
|
||||
"tooltip": "Selects including a default program or your program when flashing firmware on a hub."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { lookup } from '../../test';
|
||||
import { SettingsStringId } from './settings-i18n';
|
||||
import en from './settings-i18n.en.json';
|
||||
|
||||
describe('Ensure .json file has matches for SettingsStringIds', () => {
|
||||
test.each(Object.values(SettingsStringId))('%s', (id) => {
|
||||
expect(lookup(en, id)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
// File: components/settings-i18n.ts
|
||||
// Settings translation keys.
|
||||
|
||||
export enum SettingsStringId {
|
||||
Title = 'settings.title',
|
||||
AppearanceTitle = 'settings.appearance.title',
|
||||
AppearanceDocumentationLabel = 'settings.appearance.documentation.label',
|
||||
AppearanceDocumentationTooltip = 'settings.appearance.documentation.tooltip',
|
||||
AppearanceDarkModeLabel = 'settings.appearance.dark-mode.label',
|
||||
AppearanceDarkModeTooltip = 'settings.appearance.dark-mode.tooltip',
|
||||
AppearanceZoomHelp = 'settings.appearance.zoom.help',
|
||||
FirmwareTitle = 'settings.firmware.title',
|
||||
FirmwareCurrentProgramLabel = 'settings.firmware.flash-current-program.label',
|
||||
FirmwareCurrentProgramTooltip = 'settings.firmware.flash-current-program.tooltip',
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
// Custom styling for the Settings* controls.
|
||||
|
||||
.pb-settings .bp3-form-group {
|
||||
margin: 25px;
|
||||
}
|
||||
|
||||
.pb-settings .bp3-label {
|
||||
font-size: 18px;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.pb-settings .bp3-form-helper-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
// Custom styling for the StatusBar control.
|
||||
|
||||
@import '../variables.scss';
|
||||
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
top: calc(100vh - #{$pb-status-bar-height} - var(--mobile-pad));
|
||||
background-color: $pb-pybricks-blue;
|
||||
height: $pb-status-bar-height;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-bar-item {
|
||||
width: 25%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.bp3-progress-bar.status-bar-item {
|
||||
// override progress bar default gray1 backgound
|
||||
background-color: $pt-app-background-color;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
// Custom styling for the Toolbar control.
|
||||
|
||||
.bp3-navbar-divider {
|
||||
// don't draw vertical line since we are just using button groups
|
||||
border-left: unset;
|
||||
}
|
||||
+3
-74
@@ -1,26 +1,10 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
|
||||
@import '@blueprintjs/core/lib/scss/variables.scss';
|
||||
|
||||
// override variables here
|
||||
// See https://blueprintjs.com/docs/#core/variables
|
||||
|
||||
$pt-navbar-height: 72px;
|
||||
|
||||
$pybricks-blue: #0088ce;
|
||||
$pt-app-background-color: #e8e8e8;
|
||||
$pt-intent-primary: $pybricks-blue;
|
||||
$pt-outline-color: rgba($pybricks-blue, 0.6);
|
||||
|
||||
$navbar-background-color: $pt-app-background-color;
|
||||
$dark-navbar-background-color: $pt-dark-app-background-color;
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
@import './variables.scss';
|
||||
@import '~normalize.css';
|
||||
@import '@blueprintjs/core/src/blueprint.scss';
|
||||
|
||||
$status-bar-height: 3vh;
|
||||
|
||||
:root {
|
||||
--mobile-pad: 0px;
|
||||
}
|
||||
@@ -34,7 +18,7 @@ body {
|
||||
|
||||
.h-body {
|
||||
// height makes everything fit without scrolling
|
||||
height: calc(100vh - #{$pt-navbar-height} - #{$status-bar-height} - var(--mobile-pad)) !important;
|
||||
height: calc(100vh - #{$pt-navbar-height} - #{$pb-status-bar-height} - var(--mobile-pad)) !important;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
@@ -52,58 +36,3 @@ body {
|
||||
.no-box-shadow {
|
||||
box-shadow: unset !important;
|
||||
}
|
||||
|
||||
// Status bar. TODO: move this to separate file
|
||||
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
top: calc(100vh - #{$status-bar-height} - var(--mobile-pad));
|
||||
background-color: $pybricks-blue;
|
||||
height: $status-bar-height;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-bar-item {
|
||||
width: 25%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.bp3-progress-bar.status-bar-item {
|
||||
// override progress bar default gray1 backgound
|
||||
background-color: $pt-app-background-color;
|
||||
}
|
||||
|
||||
// Hacks
|
||||
|
||||
.bp3-navbar-divider {
|
||||
// don't draw vertical line since we are just using button groups
|
||||
border-left: unset;
|
||||
}
|
||||
|
||||
.ace_gutter {
|
||||
// make ace editor match app backgound color
|
||||
background-color: $pt-app-background-color !important;
|
||||
}
|
||||
|
||||
.layout-splitter {
|
||||
// make layout splitter match app color scheme
|
||||
background-color: $pt-app-background-color !important;
|
||||
}
|
||||
|
||||
.terminal-padding {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.watermark::after {
|
||||
content: "";
|
||||
background: url("./beta.svg");
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
import { ResizeSensor } from '@blueprintjs/core';
|
||||
import { I18nContext, I18nManager } from '@shopify/react-i18n';
|
||||
@@ -32,6 +32,20 @@ const store = createStore(
|
||||
applyMiddleware(sagaMiddleware, loggerMiddleware),
|
||||
);
|
||||
|
||||
// Hook in blueprints dark mode class to setting
|
||||
let oldDarkMode = false;
|
||||
store.subscribe(() => {
|
||||
const newDarkMode = store.getState().settings.darkMode;
|
||||
if (newDarkMode !== oldDarkMode) {
|
||||
if (newDarkMode) {
|
||||
document.body.classList.add('bp3-dark');
|
||||
} else {
|
||||
document.body.classList.remove('bp3-dark');
|
||||
}
|
||||
oldDarkMode = newDarkMode;
|
||||
}
|
||||
});
|
||||
|
||||
sagaMiddleware.run(rootSaga);
|
||||
|
||||
ReactDOM.render(
|
||||
|
||||
+8
-6
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
// File: reducers/app.ts
|
||||
// Manages state the app in general.
|
||||
|
||||
@@ -7,17 +7,19 @@ import { Reducer, combineReducers } from 'redux';
|
||||
import { Action } from '../actions';
|
||||
import { AppActionType } from '../actions/app';
|
||||
|
||||
const showDocs: Reducer<boolean, Action> = (state = false, action) => {
|
||||
const showSettings: Reducer<boolean, Action> = (state = false, action) => {
|
||||
switch (action.type) {
|
||||
case AppActionType.ToggleDocs:
|
||||
return !state;
|
||||
case AppActionType.OpenSettings:
|
||||
return true;
|
||||
case AppActionType.CloseSettings:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export interface AppState {
|
||||
readonly showDocs: boolean;
|
||||
readonly showSettings: boolean;
|
||||
}
|
||||
|
||||
export default combineReducers({ showDocs });
|
||||
export default combineReducers({ showSettings });
|
||||
|
||||
@@ -8,6 +8,7 @@ import bootloader, { BootloaderState } from './bootloader';
|
||||
import editor, { EditorState } from './editor';
|
||||
import hub, { HubState } from './hub';
|
||||
import notification, { NotificationState } from './notification';
|
||||
import settings, { SettingsState } from './settings';
|
||||
import status, { StatusState } from './status';
|
||||
import terminal, { TerminalState } from './terminal';
|
||||
|
||||
@@ -21,6 +22,7 @@ export interface RootState {
|
||||
readonly editor: EditorState;
|
||||
readonly hub: HubState;
|
||||
readonly notification: NotificationState;
|
||||
readonly settings: SettingsState;
|
||||
readonly status: StatusState;
|
||||
readonly terminal: TerminalState;
|
||||
}
|
||||
@@ -32,6 +34,7 @@ export default combineReducers({
|
||||
editor,
|
||||
hub,
|
||||
notification,
|
||||
settings,
|
||||
status,
|
||||
terminal,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { Reducer, combineReducers } from 'redux';
|
||||
import { Action } from '../actions';
|
||||
import { SettingsActionType } from '../actions/settings';
|
||||
import { SettingId, getDefaultBooleanValue } from '../settings';
|
||||
|
||||
export interface SettingsState {
|
||||
readonly darkMode: boolean;
|
||||
readonly showDocs: boolean;
|
||||
readonly flashCurrentProgram: boolean;
|
||||
}
|
||||
|
||||
const darkMode: Reducer<boolean, Action> = (
|
||||
state = getDefaultBooleanValue(SettingId.DarkMode),
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidBooleanChange:
|
||||
if (action.id === SettingId.DarkMode) {
|
||||
return action.newState;
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const showDocs: Reducer<boolean, Action> = (
|
||||
state = getDefaultBooleanValue(SettingId.ShowDocs),
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidBooleanChange:
|
||||
if (action.id === SettingId.ShowDocs) {
|
||||
return action.newState;
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const flashCurrentProgram: Reducer<boolean, Action> = (
|
||||
state = getDefaultBooleanValue(SettingId.FlashCurrentProgram),
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidBooleanChange:
|
||||
if (action.id === SettingId.FlashCurrentProgram) {
|
||||
return action.newState;
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default combineReducers({ darkMode, showDocs, flashCurrentProgram });
|
||||
@@ -1,110 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// File: sagas/app.test.ts
|
||||
// Tests for app sagas.
|
||||
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { AppActionType, startup, toggleDocs } from '../actions/app';
|
||||
import app from './app';
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('startup', () => {
|
||||
test('with large screen', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockReturnValue(null);
|
||||
innerWidth = 1024;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// toggles documentation to be visible
|
||||
const toggleDocsAction = await saga.take();
|
||||
expect(toggleDocsAction.type).toBe(AppActionType.ToggleDocs);
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with small screen', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockReturnValue(null);
|
||||
innerWidth = 800;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with stored value "true"', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockReturnValue('true');
|
||||
innerWidth = 800;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// toggles documentation to be visible
|
||||
const toggleDocsAction = await saga.take();
|
||||
expect(toggleDocsAction.type).toBe(AppActionType.ToggleDocs);
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with stored value "false"', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockReturnValue('false');
|
||||
innerWidth = 1024;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeDocsState', () => {
|
||||
test('showing', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
const mockSetItem = jest
|
||||
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
|
||||
.mockImplementation((_key, value) => expect(value).toBe('true'));
|
||||
saga.setState({ app: { showDocs: true } });
|
||||
saga.put(toggleDocs());
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('hidden', async () => {
|
||||
const saga = new AsyncSaga(app);
|
||||
|
||||
const mockSetItem = jest
|
||||
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
|
||||
.mockImplementation((_key, value) => expect(value).toBe('false'));
|
||||
saga.setState({ app: { showDocs: false } });
|
||||
saga.put(toggleDocs());
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020 The Pybricks Authors
|
||||
// File: sagas/app.ts
|
||||
// Manages the application lifecycle.
|
||||
|
||||
import { put, select, takeEvery } from 'redux-saga/effects';
|
||||
import {
|
||||
AppActionType,
|
||||
AppStartupAction,
|
||||
AppToggleDocsAction,
|
||||
toggleDocs,
|
||||
} from '../actions/app';
|
||||
import { RootState } from '../reducers';
|
||||
|
||||
function* handleStartup(_action: AppStartupAction): Generator {
|
||||
const showDocs = localStorage.getItem('showDocs');
|
||||
if (showDocs === null ? window.innerWidth >= 1024 : showDocs === 'true') {
|
||||
yield put(toggleDocs());
|
||||
}
|
||||
}
|
||||
|
||||
function* storeDocsState(_action: AppToggleDocsAction): Generator {
|
||||
const showDocs = (yield select((s: RootState) => s.app.showDocs)) as boolean;
|
||||
localStorage.setItem('showDocs', String(showDocs));
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield takeEvery(AppActionType.Startup, handleStartup);
|
||||
yield takeEvery(AppActionType.ToggleDocs, storeDocsState);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { FirmwareMetadata, FirmwareReader, HubType } from '@pybricks/firmware';
|
||||
import cityHubZip from '@pybricks/firmware/build/cityhub.zip';
|
||||
import moveHubZip from '@pybricks/firmware/build/movehub.zip';
|
||||
import technicHubZip from '@pybricks/firmware/build/technichub.zip';
|
||||
import { Ace } from 'ace-builds';
|
||||
import {
|
||||
Effect,
|
||||
all,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
delay,
|
||||
put,
|
||||
race,
|
||||
select,
|
||||
take,
|
||||
takeEvery,
|
||||
} from 'redux-saga/effects';
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
} from '../actions/mpy';
|
||||
import * as notification from '../actions/notification';
|
||||
import { MaxProgramFlashSize } from '../protocols/lwp3-bootloader';
|
||||
import { RootState } from '../reducers';
|
||||
import { fmod, sumComplement32 } from '../utils/math';
|
||||
|
||||
const firmwareZipMap = new Map<HubType, string>([
|
||||
@@ -112,15 +115,21 @@ function* firmwareIterator(data: DataView, maxSize: number): Generator<number> {
|
||||
/**
|
||||
* Loads Pybricks firmware from a .zip file
|
||||
* @param data The zip file raw data
|
||||
* @param program User program or `undefined` to use main.py from firmware.zip
|
||||
*/
|
||||
function* loadFirmware(
|
||||
data: ArrayBuffer,
|
||||
program: string | undefined,
|
||||
): Generator<unknown, { firmware: Uint8Array; deviceId: HubType }> {
|
||||
const reader = (yield call(() => FirmwareReader.load(data))) as FirmwareReader;
|
||||
|
||||
const firmwareBase = (yield call(() => reader.readFirmwareBase())) as Uint8Array;
|
||||
const metadata = (yield call(() => reader.readMetadata())) as FirmwareMetadata;
|
||||
const main = (yield call(() => reader.readMainPy())) as string;
|
||||
|
||||
// if a user program was not given, then use main.py from the frimware.zip
|
||||
if (program === undefined) {
|
||||
program = (yield call(() => reader.readMainPy())) as string;
|
||||
}
|
||||
|
||||
if (metadata['mpy-abi-version'] !== 5) {
|
||||
throw Error(
|
||||
@@ -128,7 +137,7 @@ function* loadFirmware(
|
||||
);
|
||||
}
|
||||
|
||||
yield put(compile(main, metadata['mpy-cross-options']));
|
||||
yield put(compile(program, metadata['mpy-cross-options']));
|
||||
const [mpy, mpyFail] = (yield race([
|
||||
take(MpyActionType.DidCompile),
|
||||
take(MpyActionType.DidFailToCompile),
|
||||
@@ -174,8 +183,28 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator {
|
||||
let firmware: Uint8Array | undefined = undefined;
|
||||
let deviceId: HubType | undefined = undefined;
|
||||
|
||||
let program: string | undefined = undefined;
|
||||
|
||||
const flashCurrentProgram = (yield select(
|
||||
(s: RootState) => s.settings.flashCurrentProgram,
|
||||
)) as boolean;
|
||||
|
||||
if (flashCurrentProgram) {
|
||||
const editor = (yield select(
|
||||
(s: RootState) => s.editor.current,
|
||||
)) as Ace.EditSession | null;
|
||||
|
||||
// istanbul ignore if: it is a bug to dispatch this action with no current editor
|
||||
if (editor === null) {
|
||||
console.error('flashFirmware: No current editor');
|
||||
return;
|
||||
}
|
||||
|
||||
program = editor.getValue();
|
||||
}
|
||||
|
||||
if (action.data !== undefined) {
|
||||
({ firmware, deviceId } = yield* loadFirmware(action.data));
|
||||
({ firmware, deviceId } = yield* loadFirmware(action.data, program));
|
||||
}
|
||||
|
||||
yield put(connect());
|
||||
@@ -227,7 +256,7 @@ function* flashFirmware(action: FlashFirmwareFlashAction): Generator {
|
||||
}
|
||||
|
||||
const data = (yield call(() => response.arrayBuffer())) as ArrayBuffer;
|
||||
({ firmware, deviceId } = yield* loadFirmware(data));
|
||||
({ firmware, deviceId } = yield* loadFirmware(data, program));
|
||||
|
||||
if (deviceId !== undefined && info[0].hubType !== deviceId) {
|
||||
throw Error(
|
||||
|
||||
+2
-2
@@ -3,7 +3,6 @@
|
||||
|
||||
import { all, put } from 'redux-saga/effects';
|
||||
import { startup } from '../actions/app';
|
||||
import app from './app';
|
||||
import bleUart from './ble-uart';
|
||||
import editor from './editor';
|
||||
import errorLog from './error-log';
|
||||
@@ -12,12 +11,12 @@ import hub from './hub';
|
||||
import lwp3BootloaderBle from './lwp3-bootloader-ble';
|
||||
import lwp3BootloaderProtocol from './lwp3-bootloader-protocol';
|
||||
import mpy from './mpy';
|
||||
import settings from './settings';
|
||||
import terminal from './terminal';
|
||||
|
||||
/* istanbul ignore next */
|
||||
export default function* (): Generator {
|
||||
yield all([
|
||||
app(),
|
||||
bleUart(),
|
||||
lwp3BootloaderBle(),
|
||||
lwp3BootloaderProtocol(),
|
||||
@@ -26,6 +25,7 @@ export default function* (): Generator {
|
||||
flashFirmware(),
|
||||
hub(),
|
||||
mpy(),
|
||||
settings(),
|
||||
terminal(),
|
||||
put(startup()),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
// File: sagas/settings.test.ts
|
||||
// Tests for settings sagas.
|
||||
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { startup } from '../actions/app';
|
||||
import { didBooleanChange, didFailToSetBoolean, setBoolean } from '../actions/settings';
|
||||
import { SettingsState } from '../reducers/settings';
|
||||
import { SettingId } from '../settings';
|
||||
import settings from './settings';
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('startup', () => {
|
||||
describe('showDocs', () => {
|
||||
test('with large screen and no value set', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockReturnValue(null);
|
||||
innerWidth = 1024;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with small screen and no value set', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockReturnValue(null);
|
||||
innerWidth = 800;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with large screen and stored value "true"', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockImplementation((key) => {
|
||||
switch (key) {
|
||||
case 'setting.showDocs':
|
||||
return 'true';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
innerWidth = 1024;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with small screen and stored value "true"', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockImplementation((key) => {
|
||||
switch (key) {
|
||||
case 'setting.showDocs':
|
||||
return 'true';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
innerWidth = 800;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// requests documentation to be shown
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with large screen stored value "false"', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockImplementation((key) => {
|
||||
switch (key) {
|
||||
case 'setting.showDocs':
|
||||
return 'false';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
innerWidth = 1024;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// requests documentation to be hidden
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, false));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with small screen stored value "false"', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockImplementation((key) => {
|
||||
switch (key) {
|
||||
case 'setting.showDocs':
|
||||
return 'false';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
innerWidth = 800;
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('darkMode', () => {
|
||||
test('with no value set', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockReturnValue(null);
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with value set to true', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockImplementation((key) => {
|
||||
switch (key) {
|
||||
case 'setting.darkMode':
|
||||
return 'true';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// requests to enable dark mode
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.DarkMode, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('with value set to false', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
jest.spyOn(
|
||||
Object.getPrototypeOf(window.localStorage),
|
||||
'getItem',
|
||||
).mockImplementation((key) => {
|
||||
switch (key) {
|
||||
case 'setting.darkMode':
|
||||
return 'false';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
saga.put(startup());
|
||||
|
||||
// does nothing
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('store settings to local storage', () => {
|
||||
test('failed storage', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
const testError = new Error('local storage is disabled');
|
||||
|
||||
const mockSetItem = jest
|
||||
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
saga.setState({ settings: { showDocs: false } as SettingsState });
|
||||
saga.put(setBoolean(SettingId.ShowDocs, true));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
// raises error that storing setting didn't work
|
||||
const action1 = await saga.take();
|
||||
expect(action1).toEqual(didFailToSetBoolean(SettingId.ShowDocs, testError));
|
||||
|
||||
// but the setting is still applied anyway
|
||||
const action2 = await saga.take();
|
||||
expect(action2).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('showDocs', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
const mockSetItem = jest
|
||||
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
|
||||
.mockImplementation((key, value) => {
|
||||
expect(key).toBe('setting.showDocs');
|
||||
expect(value).toBe('true');
|
||||
});
|
||||
|
||||
saga.setState({ settings: { showDocs: false } as SettingsState });
|
||||
saga.put(setBoolean(SettingId.ShowDocs, true));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('darkMode', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
const mockSetItem = jest
|
||||
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
|
||||
.mockImplementation((key, value) => {
|
||||
expect(key).toBe('setting.darkMode');
|
||||
expect(value).toBe('false');
|
||||
});
|
||||
|
||||
saga.setState({ settings: { darkMode: true } as SettingsState });
|
||||
saga.put(setBoolean(SettingId.DarkMode, false));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.DarkMode, false));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('flashCurrentProgram', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
const mockSetItem = jest
|
||||
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
|
||||
.mockImplementation((key, value) => {
|
||||
expect(key).toBe('setting.flashCurrentProgram');
|
||||
expect(value).toBe('false');
|
||||
});
|
||||
|
||||
saga.setState({ settings: { flashCurrentProgram: true } as SettingsState });
|
||||
saga.put(setBoolean(SettingId.FlashCurrentProgram, false));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.FlashCurrentProgram, false));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage monitor', () => {
|
||||
test('ignores other keys', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'not a setting',
|
||||
storageArea: localStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
// nothing happens
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('puts action when setting changes', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'setting.showDocs',
|
||||
newValue: 'true',
|
||||
oldValue: 'false',
|
||||
storageArea: localStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
test('ignores session storage', async () => {
|
||||
const saga = new AsyncSaga(settings);
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'setting.showDocs',
|
||||
newValue: 'true',
|
||||
oldValue: 'false',
|
||||
storageArea: sessionStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
// nothing happens
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
// This manages settings by storing them in local storage whenever the app
|
||||
// request to set a setting. When local storage changes, it triggers a did
|
||||
// change action that can be used by reducers to compute the new state.
|
||||
|
||||
import { EventChannel, eventChannel } from 'redux-saga';
|
||||
import { call, fork, put, select, take, takeEvery } from 'redux-saga/effects';
|
||||
import { AppActionType } from '../actions/app';
|
||||
import {
|
||||
SettingsActionType,
|
||||
SettingsSetBooleanAction,
|
||||
didBooleanChange,
|
||||
didFailToSetBoolean,
|
||||
} from '../actions/settings';
|
||||
import { RootState } from '../reducers';
|
||||
import { SettingId, getDefaultBooleanValue } from '../settings';
|
||||
|
||||
function stringToBoolean(value: string): boolean {
|
||||
return value.toLowerCase().match(/(true|yes|1)/) !== null;
|
||||
}
|
||||
|
||||
function createLocalStorageEventChannel(): EventChannel<StorageEvent> {
|
||||
return eventChannel((emitter) => {
|
||||
const handler: (e: StorageEvent) => void = (e) => {
|
||||
if (e.storageArea !== localStorage) {
|
||||
return;
|
||||
}
|
||||
emitter(e);
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
// istanbul ignore next: this is not normally called
|
||||
return () => window.removeEventListener('storage', handler);
|
||||
});
|
||||
}
|
||||
|
||||
function* monitorLocalStorage(): Generator {
|
||||
const chan = (yield call(
|
||||
createLocalStorageEventChannel,
|
||||
)) as EventChannel<StorageEvent>;
|
||||
|
||||
while (true) {
|
||||
const event = (yield take(chan)) as StorageEvent;
|
||||
|
||||
// only care about storage keys 'setting.*'
|
||||
if (!event.key?.startsWith('setting.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = event.key.replace(/^setting\./, '') as SettingId;
|
||||
|
||||
// istanbul ignore if: should not happen normally
|
||||
if (!Object.values(SettingId).includes(id)) {
|
||||
console.error(`Bad setting id: ${id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield put(didBooleanChange(id, stringToBoolean(event.newValue || 'false')));
|
||||
}
|
||||
}
|
||||
|
||||
function* loadSettings(): Generator {
|
||||
for (const id of Object.values(SettingId)) {
|
||||
const storageValue = localStorage.getItem(`setting.${id}`);
|
||||
const defaultValue = getDefaultBooleanValue(id);
|
||||
const value =
|
||||
storageValue === null ? defaultValue : stringToBoolean(storageValue);
|
||||
|
||||
if (value !== defaultValue) {
|
||||
yield put(didBooleanChange(id, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* storeSetting(action: SettingsSetBooleanAction): Generator {
|
||||
const key = `setting.${action.id}`;
|
||||
const newValue = String(action.newState);
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, newValue);
|
||||
} catch (err) {
|
||||
yield put(didFailToSetBoolean(action.id, err));
|
||||
}
|
||||
|
||||
// storage event is only raised when a value is changed externally, so we
|
||||
// mimic the event when we call setItem(), whether it actually succeeded
|
||||
// or not.
|
||||
const oldState = (yield select((s: RootState) => s.settings[action.id])) as boolean;
|
||||
if (action.newState !== oldState) {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key,
|
||||
newValue,
|
||||
oldValue: String(oldState),
|
||||
storageArea: localStorage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield fork(monitorLocalStorage);
|
||||
yield takeEvery(AppActionType.Startup, loadSettings);
|
||||
yield takeEvery(SettingsActionType.SetBoolean, storeSetting);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
// Definitions for user settings.
|
||||
|
||||
export enum SettingId {
|
||||
ShowDocs = 'showDocs',
|
||||
DarkMode = 'darkMode',
|
||||
FlashCurrentProgram = 'flashCurrentProgram',
|
||||
}
|
||||
|
||||
export function getDefaultBooleanValue(id: SettingId): boolean {
|
||||
switch (id) {
|
||||
case SettingId.ShowDocs:
|
||||
return window.innerWidth >= 1024;
|
||||
case SettingId.DarkMode:
|
||||
case SettingId.FlashCurrentProgram:
|
||||
return false;
|
||||
// istanbul ignore next: it is a programmer error if we hit this
|
||||
default:
|
||||
throw Error(`Bad setting id: ${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { isMacOS } from './os';
|
||||
|
||||
describe('isMacOS', () => {
|
||||
test('is true', () => {
|
||||
jest.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel');
|
||||
expect(isMacOS()).toBeTruthy();
|
||||
});
|
||||
test('is false', () => {
|
||||
jest.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32');
|
||||
expect(isMacOS()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
// Utility functions for dealing with operating systems.
|
||||
|
||||
export function isMacOS(): boolean {
|
||||
return /mac/i.test(navigator.platform);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2021 The Pybricks Authors
|
||||
|
||||
@import '@blueprintjs/core/lib/scss/variables.scss';
|
||||
|
||||
// override variables here
|
||||
// See https://blueprintjs.com/docs/#core/variables
|
||||
|
||||
$pt-navbar-height: 72px;
|
||||
$pb-status-bar-height: 3vh;
|
||||
|
||||
$pb-pybricks-blue: #0088ce;
|
||||
$pt-app-background-color: #e8e8e8;
|
||||
$pt-intent-primary: $pb-pybricks-blue;
|
||||
$pt-outline-color: rgba($pb-pybricks-blue, 0.6);
|
||||
|
||||
$navbar-background-color: $pt-app-background-color;
|
||||
$dark-navbar-background-color: $pt-dark-app-background-color;
|
||||
+6
-2
@@ -22,7 +22,9 @@ export class AsyncSaga {
|
||||
channel: this.channel,
|
||||
dispatch: this.dispatch.bind(this),
|
||||
getState: () => this.state,
|
||||
onError: (e) => fail(e),
|
||||
onError: (e, _i): void => {
|
||||
throw e;
|
||||
},
|
||||
},
|
||||
saga,
|
||||
);
|
||||
@@ -68,7 +70,9 @@ export class AsyncSaga {
|
||||
this.task.cancel();
|
||||
await this.task.toPromise();
|
||||
if (this.dispatches.some((x) => x.type !== END.type)) {
|
||||
fail(`unhandled dispatches remain: ${JSON.stringify(this.dispatches)}`);
|
||||
throw Error(
|
||||
`unhandled dispatches remain: ${JSON.stringify(this.dispatches)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user