Merge pull request #241 from pybricks/dlech

implement settings
This commit is contained in:
David Lechner
2021-01-14 18:27:59 -06:00
committed by GitHub
39 changed files with 1251 additions and 294 deletions
+26 -2
View File
@@ -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;
+2
View File
@@ -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;
/**
+60
View File
@@ -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;
+4 -1
View File
@@ -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>
);
}
+19 -7
View File
@@ -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);
+167
View File
@@ -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),
);
+2
View File
@@ -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;
-24
View File
@@ -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
View File
@@ -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 => ({
+6 -6
View File
@@ -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>
+24
View File
@@ -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;
}
+1 -2
View File
@@ -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" }
}
+1 -2
View File
@@ -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',
}
+28
View File
@@ -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

+131
View File
@@ -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

+26
View File
@@ -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."
}
}
}
}
+12
View File
@@ -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();
});
});
+17
View File
@@ -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',
}
+17
View File
@@ -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;
}
+26
View File
@@ -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;
}
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
+3
View File
@@ -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,
});
+60
View File
@@ -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 });
-110
View File
@@ -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();
});
});
-30
View File
@@ -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);
}
+33 -4
View File
@@ -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
View File
@@ -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()),
]);
+350
View File
@@ -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();
});
});
+106
View File
@@ -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);
}
+23
View File
@@ -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}`);
}
}
+15
View File
@@ -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();
});
});
+8
View File
@@ -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);
}
+18
View File
@@ -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
View File
@@ -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)}`,
);
}
}