diff --git a/src/actions/app.ts b/src/actions/app.ts index 227b1c51..daaf204c 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -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; + +/** 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; + +/** 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; @@ -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; diff --git a/src/actions/index.ts b/src/actions/index.ts index c8ac0a27..11b549ac 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -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; /** diff --git a/src/actions/settings.ts b/src/actions/settings.ts new file mode 100644 index 00000000..cfa4ba1a --- /dev/null +++ b/src/actions/settings.ts @@ -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 = { + /** 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 & + SettingInfo; + +/** 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 & { + 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 & + SettingInfo; + +/** 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; diff --git a/src/components/App.tsx b/src/components/App.tsx index 3c0c90bc..04a15162 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { )} + ); } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 9bf4d335..75449edf 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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 { @@ -68,14 +76,14 @@ class Editor extends React.Component { } render(): JSX.Element { - const { i18n, onSessionChanged } = this.props; + const { darkMode, i18n, onSessionChanged } = this.props; return (
this.editor?.resize()}> { }} 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()} /> { }} text={i18n.translate(EditorStringId.Paste)} icon="clipboard" - label={/mac/i.test(navigator.platform) ? 'Cmd-V' : 'Ctrl-V'} + label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'} /> { } } +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)); diff --git a/src/components/DocsButton.tsx b/src/components/SettingsButton.tsx similarity index 61% rename from src/components/DocsButton.tsx rename to src/components/SettingsButton.tsx index d6206163..7d4eafb4 100644 --- a/src/components/DocsButton.tsx +++ b/src/components/SettingsButton.tsx @@ -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; -type OwnProps = Pick & - Pick; +type OwnProps = Pick; 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); diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx new file mode 100644 index 00000000..c7d3d68e --- /dev/null +++ b/src/components/SettingsDrawer.tsx @@ -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 { + render(): JSX.Element { + const { + i18n, + open, + onClose, + showDocs, + onShowDocsChanged, + darkMode, + onDarkModeChanged, + flashCurrentProgram, + onFlashCurrentProgramChanged, + } = this.props; + return ( + onClose()} + > +
+ {isMacOS() ? 'Cmd' : 'Ctrl'}-+, + out: {isMacOS() ? 'Cmd' : 'Ctrl'}--, + }, + )} + > + + + onShowDocsChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> + + + + onDarkModeChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> + + + + + + onFlashCurrentProgramChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> + + +
+
+ ); + } +} + +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), +); diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index d137dfd6..aa330b5a 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -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; diff --git a/src/components/SupportButton.tsx b/src/components/SupportButton.tsx deleted file mode 100644 index 1886d89a..00000000 --- a/src/components/SupportButton.tsx +++ /dev/null @@ -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; - -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); diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx index 315bd848..54b869cb 100644 --- a/src/components/Terminal.tsx +++ b/src/components/Terminal.tsx @@ -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 | null; + darkMode: boolean; } interface DispatchProps { @@ -45,13 +47,6 @@ class Terminal extends React.Component { 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 { } 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 (
this.fitAddon.fit()}> @@ -134,7 +138,7 @@ class Terminal extends React.Component { }} 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()} /> { }} text={i18n.translate(TerminalStringId.Paste)} icon="clipboard" - label={/mac/i.test(navigator.platform) ? 'Cmd-V' : 'Ctrl-V'} + label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'} /> { const mapStateToProps = (state: RootState): StateProps => ({ dataSource: state.terminal.dataSource, + darkMode: state.settings.darkMode, }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f1d38e41..5107bd8d 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -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 { - + - + - - + diff --git a/src/components/app.scss b/src/components/app.scss new file mode 100644 index 00000000..626932eb --- /dev/null +++ b/src/components/app.scss @@ -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; +} diff --git a/src/components/button-i18n.en.json b/src/components/button-i18n.en.json index 077e559f..e178f5f1 100644 --- a/src/components/button-i18n.en.json +++ b/src/components/button-i18n.en.json @@ -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" } } diff --git a/src/components/button-i18n.ts b/src/components/button-i18n.ts index 831c6b61..b34b8aac 100644 --- a/src/components/button-i18n.ts +++ b/src/components/button-i18n.ts @@ -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', } diff --git a/src/components/editor.scss b/src/components/editor.scss new file mode 100644 index 00000000..71697224 --- /dev/null +++ b/src/components/editor.scss @@ -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 +} diff --git a/src/beta.svg b/src/components/images/beta.svg similarity index 100% rename from src/beta.svg rename to src/components/images/beta.svg diff --git a/src/components/images/settings.svg b/src/components/images/settings.svg new file mode 100644 index 00000000..559cd5f1 --- /dev/null +++ b/src/components/images/settings.svg @@ -0,0 +1,131 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json new file mode 100644 index 00000000..121d1298 --- /dev/null +++ b/src/components/settings-i18n.en.json @@ -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." + } + } + } +} diff --git a/src/components/settings-i18n.en.test.ts b/src/components/settings-i18n.en.test.ts new file mode 100644 index 00000000..679896e4 --- /dev/null +++ b/src/components/settings-i18n.en.test.ts @@ -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(); + }); +}); diff --git a/src/components/settings-i18n.ts b/src/components/settings-i18n.ts new file mode 100644 index 00000000..6dc5c3fd --- /dev/null +++ b/src/components/settings-i18n.ts @@ -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', +} diff --git a/src/components/settings.scss b/src/components/settings.scss new file mode 100644 index 00000000..a1c23ce4 --- /dev/null +++ b/src/components/settings.scss @@ -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; +} diff --git a/src/components/status-bar.scss b/src/components/status-bar.scss new file mode 100644 index 00000000..60448c17 --- /dev/null +++ b/src/components/status-bar.scss @@ -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; +} diff --git a/src/components/toolbar.scss b/src/components/toolbar.scss new file mode 100644 index 00000000..efe477c7 --- /dev/null +++ b/src/components/toolbar.scss @@ -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; +} diff --git a/src/index.scss b/src/index.scss index f251ffe8..b1bab40c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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 -} diff --git a/src/index.tsx b/src/index.tsx index f3c90ed1..28a05b9a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( diff --git a/src/reducers/app.ts b/src/reducers/app.ts index 6b70b2b2..ca68133a 100644 --- a/src/reducers/app.ts +++ b/src/reducers/app.ts @@ -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 = (state = false, action) => { +const showSettings: Reducer = (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 }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 832c8a9a..a6ccb937 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -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, }); diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts new file mode 100644 index 00000000..51d4294e --- /dev/null +++ b/src/reducers/settings.ts @@ -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 = ( + 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 = ( + 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 = ( + 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 }); diff --git a/src/sagas/app.test.ts b/src/sagas/app.test.ts deleted file mode 100644 index 0030133c..00000000 --- a/src/sagas/app.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/sagas/app.ts b/src/sagas/app.ts deleted file mode 100644 index 6b2e3e3c..00000000 --- a/src/sagas/app.ts +++ /dev/null @@ -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); -} diff --git a/src/sagas/flash-firmware.ts b/src/sagas/flash-firmware.ts index eb7a862b..7ef58344 100644 --- a/src/sagas/flash-firmware.ts +++ b/src/sagas/flash-firmware.ts @@ -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([ @@ -112,15 +115,21 @@ function* firmwareIterator(data: DataView, maxSize: number): Generator { /** * 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 { 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( diff --git a/src/sagas/index.ts b/src/sagas/index.ts index d7594ae1..24a2cc0c 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -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()), ]); diff --git a/src/sagas/settings.test.ts b/src/sagas/settings.test.ts new file mode 100644 index 00000000..ad4c762a --- /dev/null +++ b/src/sagas/settings.test.ts @@ -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(); + }); +}); diff --git a/src/sagas/settings.ts b/src/sagas/settings.ts new file mode 100644 index 00000000..1cbf66d5 --- /dev/null +++ b/src/sagas/settings.ts @@ -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 { + 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; + + 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); +} diff --git a/src/settings/index.ts b/src/settings/index.ts new file mode 100644 index 00000000..0c9a2406 --- /dev/null +++ b/src/settings/index.ts @@ -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}`); + } +} diff --git a/src/utils/os.test.ts b/src/utils/os.test.ts new file mode 100644 index 00000000..665f08d6 --- /dev/null +++ b/src/utils/os.test.ts @@ -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(); + }); +}); diff --git a/src/utils/os.ts b/src/utils/os.ts new file mode 100644 index 00000000..44a11446 --- /dev/null +++ b/src/utils/os.ts @@ -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); +} diff --git a/src/variables.scss b/src/variables.scss new file mode 100644 index 00000000..3005c042 --- /dev/null +++ b/src/variables.scss @@ -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; diff --git a/test/index.ts b/test/index.ts index 3f0e1685..5e54bb9c 100644 --- a/test/index.ts +++ b/test/index.ts @@ -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)}`, + ); } }