From 4923ca3759c31c7e931b7efe3107eabae77fd8ed Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 11 Jan 2021 17:21:19 -0600 Subject: [PATCH 01/13] change button grouping --- src/components/Toolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f1d38e41..db49051c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -27,14 +27,14 @@ class Toolbar extends React.Component { - + - + From fedef669311463fb647e8c05fe2447070e9b94d8 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 11 Jan 2021 17:44:49 -0600 Subject: [PATCH 02/13] replace doc icon with question mark --- src/components/DocsButton.tsx | 2 +- src/components/images/question-mark.svg | 145 ++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/components/images/question-mark.svg diff --git a/src/components/DocsButton.tsx b/src/components/DocsButton.tsx index d6206163..debf79cf 100644 --- a/src/components/DocsButton.tsx +++ b/src/components/DocsButton.tsx @@ -8,7 +8,7 @@ import { Action, Dispatch } from '../actions'; import { toggleDocs } from '../actions/app'; import ActionButton, { ActionButtonProps } from './ActionButton'; import { TooltipId } from './button-i18n'; -import docsIcon from './images/pybricks.svg'; +import docsIcon from './images/question-mark.svg'; type StateProps = undefined; type DispatchProps = Pick; diff --git a/src/components/images/question-mark.svg b/src/components/images/question-mark.svg new file mode 100644 index 00000000..218ad12d --- /dev/null +++ b/src/components/images/question-mark.svg @@ -0,0 +1,145 @@ + + + +image/svg+xmlhttps://www.svgrepo.com/svg/131030/question-mark + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 41a1cee0923d7dfa12fb27870043f3ba778ea0e4 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 11 Jan 2021 20:14:35 -0600 Subject: [PATCH 03/13] change support button to settings button and add new empty settings drawer --- src/actions/app.ts | 28 ++++- src/components/App.tsx | 2 + src/components/SettingsButton.tsx | 30 ++++++ src/components/SettingsDrawer.tsx | 55 ++++++++++ src/components/SupportButton.tsx | 24 ----- src/components/Toolbar.tsx | 4 +- src/components/button-i18n.en.json | 2 +- src/components/button-i18n.ts | 2 +- src/components/images/settings.svg | 131 ++++++++++++++++++++++++ src/components/settings-i18n.en.json | 5 + src/components/settings-i18n.en.test.ts | 12 +++ src/components/settings-i18n.ts | 8 ++ src/reducers/app.ts | 16 ++- src/reducers/index.ts | 3 + src/reducers/settings.ts | 10 ++ src/sagas/app.test.ts | 4 +- 16 files changed, 302 insertions(+), 34 deletions(-) create mode 100644 src/components/SettingsButton.tsx create mode 100644 src/components/SettingsDrawer.tsx delete mode 100644 src/components/SupportButton.tsx create mode 100644 src/components/images/settings.svg create mode 100644 src/components/settings-i18n.en.json create mode 100644 src/components/settings-i18n.en.test.ts create mode 100644 src/components/settings-i18n.ts create mode 100644 src/reducers/settings.ts 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/components/App.tsx b/src/components/App.tsx index 3c0c90bc..f4490d82 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -6,6 +6,7 @@ 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'; @@ -61,6 +62,7 @@ function App(): JSX.Element { )} + ); } diff --git a/src/components/SettingsButton.tsx b/src/components/SettingsButton.tsx new file mode 100644 index 00000000..7d4eafb4 --- /dev/null +++ b/src/components/SettingsButton.tsx @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { connect } from 'react-redux'; +import { Action, Dispatch } from '../actions'; +import { openSettings as openSettings } from '../actions/app'; +import ActionButton, { ActionButtonProps } from './ActionButton'; +import { TooltipId } from './button-i18n'; +import settingsIcon from './images/settings.svg'; + +type StateProps = undefined; +type DispatchProps = Pick; +type OwnProps = Pick; + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onAction: (): Action => dispatch(openSettings()), +}); + +const mergeProps = ( + _stateProps: StateProps, + dispatchProps: DispatchProps, + ownProps: OwnProps, +): ActionButtonProps => ({ + 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..70ac15ab --- /dev/null +++ b/src/components/SettingsDrawer.tsx @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { Drawer } 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 { RootState } from '../reducers'; +import { SettingsStringId } from './settings-i18n'; +import en from './settings-i18n.en.json'; + +type StateProps = { + open: boolean; +}; + +type DispatchProps = { + onClose: () => void; +}; + +type SettingsProps = StateProps & DispatchProps & WithI18nProps; + +class SettingsDrawer extends React.PureComponent { + render(): JSX.Element { + const { i18n, open, onClose } = this.props; + return ( + onClose()} + > + ); + } +} + +const mapStateToProps = (state: RootState): StateProps => ({ + open: state.app.showSettings, +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onClose: (): Action => dispatch(closeSettings()), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)( + withI18n({ + id: 'settings', + fallback: en, + translations: { en }, + })(SettingsDrawer), +); 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/Toolbar.tsx b/src/components/Toolbar.tsx index db49051c..d4bfb6e9 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -10,8 +10,8 @@ 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'; class Toolbar extends React.Component { render(): JSX.Element { @@ -39,7 +39,7 @@ class Toolbar extends React.Component { - + diff --git a/src/components/button-i18n.en.json b/src/components/button-i18n.en.json index 077e559f..78d2547b 100644 --- a/src/components/button-i18n.en.json +++ b/src/components/button-i18n.en.json @@ -10,5 +10,5 @@ }, "flash": { "tooltip": "Flash hub firmware" }, "docs": { "tooltip": "Show/hide documentation" }, - "support": { "tooltip": "Open Pybricks Support web site" } + "settings": { "tooltip": "Open setting" } } diff --git a/src/components/button-i18n.ts b/src/components/button-i18n.ts index 831c6b61..fbad5705 100644 --- a/src/components/button-i18n.ts +++ b/src/components/button-i18n.ts @@ -13,5 +13,5 @@ export enum TooltipId { BluetoothConnect = 'bluetooth.connect.tooltip', BluetoothDisconnect = 'bluetooth.disconnect.tooltip', Docs = 'docs.tooltip', - Support = 'support.tooltip', + Settings = 'settings.tooltip', } 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..903e298a --- /dev/null +++ b/src/components/settings-i18n.en.json @@ -0,0 +1,5 @@ +{ + "settings": { + "title": "Settings" + } +} 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..dfc23355 --- /dev/null +++ b/src/components/settings-i18n.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors +// File: components/settings-i18n.ts +// Settings translation keys. + +export enum SettingsStringId { + Title = 'settings.title', +} diff --git a/src/reducers/app.ts b/src/reducers/app.ts index 6b70b2b2..2b2171ee 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,6 +7,17 @@ import { Reducer, combineReducers } from 'redux'; import { Action } from '../actions'; import { AppActionType } from '../actions/app'; +const showSettings: Reducer = (state = false, action) => { + switch (action.type) { + case AppActionType.OpenSettings: + return true; + case AppActionType.CloseSettings: + return false; + default: + return state; + } +}; + const showDocs: Reducer = (state = false, action) => { switch (action.type) { case AppActionType.ToggleDocs: @@ -17,7 +28,8 @@ const showDocs: Reducer = (state = false, action) => { }; export interface AppState { + readonly showSettings: boolean; readonly showDocs: boolean; } -export default combineReducers({ showDocs }); +export default combineReducers({ showSettings, showDocs }); 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..fe0dba5b --- /dev/null +++ b/src/reducers/settings.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { combineReducers } from 'redux'; + +export interface SettingsState { + readonly visible: boolean; +} + +export default combineReducers({}); diff --git a/src/sagas/app.test.ts b/src/sagas/app.test.ts index 0030133c..77ef1431 100644 --- a/src/sagas/app.test.ts +++ b/src/sagas/app.test.ts @@ -88,7 +88,7 @@ describe('storeDocsState', () => { const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') .mockImplementation((_key, value) => expect(value).toBe('true')); - saga.setState({ app: { showDocs: true } }); + saga.setState({ app: { showSettings: false, showDocs: true } }); saga.put(toggleDocs()); expect(mockSetItem).toHaveBeenCalled(); @@ -101,7 +101,7 @@ describe('storeDocsState', () => { const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') .mockImplementation((_key, value) => expect(value).toBe('false')); - saga.setState({ app: { showDocs: false } }); + saga.setState({ app: { showSettings: false, showDocs: false } }); saga.put(toggleDocs()); expect(mockSetItem).toHaveBeenCalled(); From d7dbaaa12dc57995768ccb9ea41298afba15d676 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 11 Jan 2021 22:56:12 -0600 Subject: [PATCH 04/13] add dark mode setting --- src/actions/index.ts | 2 ++ src/actions/settings.ts | 20 +++++++++++++++ src/components/Editor.tsx | 20 +++++++++++---- src/components/SettingsDrawer.tsx | 26 ++++++++++++++++--- src/components/Terminal.tsx | 20 +++++++++------ src/components/settings-i18n.en.json | 8 +++++- src/components/settings-i18n.ts | 2 ++ src/components/settings.scss | 6 +++++ src/index.scss | 16 +++++++++++- src/reducers/settings.ts | 17 ++++++++++--- src/sagas/index.ts | 2 ++ src/sagas/settings.ts | 38 ++++++++++++++++++++++++++++ 12 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 src/actions/settings.ts create mode 100644 src/components/settings.scss create mode 100644 src/sagas/settings.ts 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..4b2f6556 --- /dev/null +++ b/src/actions/settings.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { Action } from 'redux'; + +/** Actions related to settings. */ +export enum SettingsActionType { + ToggleDarkMode = 'settings.action.toggleDarkMode', +} + +/** Action to toggle dark mode setting. */ +export type SettingsToggleDarkModeAction = Action; + +/** Toggles dark mode setting on or off. */ +export function toggleDarkMode(): SettingsToggleDarkModeAction { + return { type: SettingsActionType.ToggleDarkMode }; +} + +/** common type for all settings actions. */ +export type SettingsAction = SettingsToggleDarkModeAction; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 9bf4d335..25038573 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,10 +16,12 @@ 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 { 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'; @@ -27,12 +29,16 @@ import 'ace-builds/src-noconflict/ext-language_tools'; import './editor-snippets'; +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 +74,14 @@ class Editor extends React.Component { } render(): JSX.Element { - const { i18n, onSessionChanged } = this.props; + const { darkMode, i18n, onSessionChanged } = this.props; return (
this.editor?.resize()}> { } } +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/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 70ac15ab..71ee2b77 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -1,46 +1,66 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Drawer } from '@blueprintjs/core'; +import { Drawer, FormGroup, Switch } 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 { toggleDarkMode } from '../actions/settings'; import { RootState } from '../reducers'; import { SettingsStringId } from './settings-i18n'; import en from './settings-i18n.en.json'; +import './settings.scss'; + type StateProps = { open: boolean; + darkMode: boolean; }; type DispatchProps = { onClose: () => void; + onDarkModeChanged: () => void; }; type SettingsProps = StateProps & DispatchProps & WithI18nProps; class SettingsDrawer extends React.PureComponent { render(): JSX.Element { - const { i18n, open, onClose } = this.props; + const { i18n, open, onClose, darkMode, onDarkModeChanged } = this.props; return ( onClose()} - > + > +
+ + onDarkModeChanged()} + /> + +
+ ); } } const mapStateToProps = (state: RootState): StateProps => ({ open: state.app.showSettings, + darkMode: state.settings.darkMode, }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ onClose: (): Action => dispatch(closeSettings()), + onDarkModeChanged: (): Action => dispatch(toggleDarkMode()), }); export default connect( diff --git a/src/components/Terminal.tsx b/src/components/Terminal.tsx index 315bd848..ebdd9247 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, @@ -24,6 +24,7 @@ import 'xterm/css/xterm.css'; interface StateProps { dataSource: Observable | null; + darkMode: boolean; } interface DispatchProps { @@ -45,13 +46,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 +106,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()}> @@ -163,6 +166,7 @@ class Terminal extends React.Component { const mapStateToProps = (state: RootState): StateProps => ({ dataSource: state.terminal.dataSource, + darkMode: state.settings.darkMode, }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json index 903e298a..49c8212d 100644 --- a/src/components/settings-i18n.en.json +++ b/src/components/settings-i18n.en.json @@ -1,5 +1,11 @@ { "settings": { - "title": "Settings" + "title": "Settings", + "appearance": { + "title": "Appearance", + "dark-mode": { + "label": "Dark mode" + } + } } } diff --git a/src/components/settings-i18n.ts b/src/components/settings-i18n.ts index dfc23355..06464b31 100644 --- a/src/components/settings-i18n.ts +++ b/src/components/settings-i18n.ts @@ -5,4 +5,6 @@ export enum SettingsStringId { Title = 'settings.title', + AppearanceTitle = 'settings.appearance.title', + AppearanceDarkModeLabel = 'settings.appearance.dark-mode.label', } diff --git a/src/components/settings.scss b/src/components/settings.scss new file mode 100644 index 00000000..480c0325 --- /dev/null +++ b/src/components/settings.scss @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +.pb-settings { + margin: 25px; +} diff --git a/src/index.scss b/src/index.scss index f251ffe8..c7a9e7e4 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2021 The Pybricks Authors @import '@blueprintjs/core/lib/scss/variables.scss'; @@ -82,16 +82,30 @@ body { border-left: unset; } +.bp3-dark .ace_gutter { + // make ace editor match app backgound color + background-color: $pt-dark-app-background-color !important; +} + .ace_gutter { // make ace editor match app backgound color background-color: $pt-app-background-color !important; } +.bp3-dark .splitter-layout > .layout-splitter { + // make layout splitter match app color scheme + background-color: $pt-dark-app-background-color !important; +} + .layout-splitter { // make layout splitter match app color scheme background-color: $pt-app-background-color !important; } +.bp3-dark .terminal-padding { + background-color: black; +} + .terminal-padding { padding-left: 10px; } diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts index fe0dba5b..a4c6e6e6 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -1,10 +1,21 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { combineReducers } from 'redux'; +import { Reducer, combineReducers } from 'redux'; +import { Action } from '../actions'; +import { SettingsActionType } from '../actions/settings'; export interface SettingsState { - readonly visible: boolean; + readonly darkMode: boolean; } -export default combineReducers({}); +const darkMode: Reducer = (state = false, action) => { + switch (action.type) { + case SettingsActionType.ToggleDarkMode: + return !state; + default: + return state; + } +}; + +export default combineReducers({ darkMode }); diff --git a/src/sagas/index.ts b/src/sagas/index.ts index d7594ae1..2c5f370a 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -12,6 +12,7 @@ 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 */ @@ -26,6 +27,7 @@ export default function* (): Generator { flashFirmware(), hub(), mpy(), + settings(), terminal(), put(startup()), ]); diff --git a/src/sagas/settings.ts b/src/sagas/settings.ts new file mode 100644 index 00000000..1d888233 --- /dev/null +++ b/src/sagas/settings.ts @@ -0,0 +1,38 @@ +import { put, select, takeEvery } from 'redux-saga/effects'; +import { AppActionType } from '../actions/app'; +import { SettingsActionType, toggleDarkMode } from '../actions/settings'; +import { RootState } from '../reducers'; +import { SettingsState } from '../reducers/settings'; + +function* loadSettings(): Generator { + const settingsString = localStorage.getItem('settings'); + if (!settingsString) { + return; + } + const settings = JSON.parse(settingsString) as SettingsState; + + // TODO: there has to be a better way to initialize app state from settings + if (settings.darkMode) { + yield put(toggleDarkMode()); + } +} + +function* saveSettings(): Generator { + const settings = (yield select((s: RootState) => s.settings)) as SettingsState; + localStorage.setItem('settings', JSON.stringify(settings)); +} + +function* updateDarkModeClass(): Generator { + const darkMode = (yield select((s: RootState) => s.settings.darkMode)) as boolean; + if (darkMode) { + document.body.classList.add('bp3-dark'); + } else { + document.body.classList.remove('bp3-dark'); + } +} + +export default function* (): Generator { + yield takeEvery(AppActionType.Startup, loadSettings); + yield takeEvery(Object.values(SettingsActionType), saveSettings); + yield takeEvery(SettingsActionType.ToggleDarkMode, updateDarkModeClass); +} From b6bda26484304db19bb84527348c8bbec0d7beed Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 10:50:28 -0600 Subject: [PATCH 05/13] fix error handling in test saga Jest used to define fail() but doesn't seem to do this anymore. Replace it with throwing an error instead, which is basically the same thing. --- test/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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)}`, + ); } } From b15b2e1deaa0ffdccabd6f6112b42ec61761c8a7 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 10:55:00 -0600 Subject: [PATCH 06/13] move docs toggle to settings --- src/actions/settings.ts | 11 +- src/components/App.tsx | 2 +- src/components/DocsButton.tsx | 33 ----- src/components/SettingsDrawer.tsx | 25 +++- src/components/Toolbar.tsx | 2 - src/components/button-i18n.en.json | 3 +- src/components/button-i18n.ts | 1 - src/components/images/question-mark.svg | 145 -------------------- src/components/settings-i18n.en.json | 3 + src/components/settings-i18n.ts | 1 + src/reducers/app.ts | 12 +- src/reducers/settings.ts | 12 +- src/sagas/app.ts | 30 ---- src/sagas/index.ts | 2 - src/sagas/{app.test.ts => settings.test.ts} | 51 ++++--- src/sagas/settings.ts | 17 ++- 16 files changed, 93 insertions(+), 257 deletions(-) delete mode 100644 src/components/DocsButton.tsx delete mode 100644 src/components/images/question-mark.svg delete mode 100644 src/sagas/app.ts rename src/sagas/{app.test.ts => settings.test.ts} (55%) diff --git a/src/actions/settings.ts b/src/actions/settings.ts index 4b2f6556..d3731c6e 100644 --- a/src/actions/settings.ts +++ b/src/actions/settings.ts @@ -5,9 +5,18 @@ import { Action } from 'redux'; /** Actions related to settings. */ export enum SettingsActionType { + ToggleDocs = 'settings.action.toggleDocs', ToggleDarkMode = 'settings.action.toggleDarkMode', } +/** Action to toggle show docs setting. */ +export type SettingsToggleDocsAction = Action; + +/** Toggles show docs setting on or off. */ +export function toggleDocs(): SettingsToggleDocsAction { + return { type: SettingsActionType.ToggleDocs }; +} + /** Action to toggle dark mode setting. */ export type SettingsToggleDarkModeAction = Action; @@ -17,4 +26,4 @@ export function toggleDarkMode(): SettingsToggleDarkModeAction { } /** common type for all settings actions. */ -export type SettingsAction = SettingsToggleDarkModeAction; +export type SettingsAction = SettingsToggleDocsAction | SettingsToggleDarkModeAction; diff --git a/src/components/App.tsx b/src/components/App.tsx index f4490d82..61d7417d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -14,7 +14,7 @@ import Toolbar from './Toolbar'; import 'react-splitter-layout/lib/index.css'; 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 ( diff --git a/src/components/DocsButton.tsx b/src/components/DocsButton.tsx deleted file mode 100644 index debf79cf..00000000 --- a/src/components/DocsButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors -// File: components/DocsButton.ts -// Toolbar button for toggling documentation. - -import { connect } from 'react-redux'; -import { Action, Dispatch } from '../actions'; -import { toggleDocs } from '../actions/app'; -import ActionButton, { ActionButtonProps } from './ActionButton'; -import { TooltipId } from './button-i18n'; -import docsIcon from './images/question-mark.svg'; - -type StateProps = undefined; -type DispatchProps = Pick; -type OwnProps = Pick & - Pick; - -const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - onAction: (): Action => dispatch(toggleDocs()), -}); - -const mergeProps = ( - _stateProps: StateProps, - dispatchProps: DispatchProps, - ownProps: OwnProps, -): ActionButtonProps => ({ - tooltip: TooltipId.Docs, - icon: docsIcon, - ...ownProps, - ...dispatchProps, -}); - -export default connect(undefined, mapDispatchToProps, mergeProps)(ActionButton); diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 71ee2b77..a66cf5b8 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { Action, Dispatch } from '../actions'; import { closeSettings } from '../actions/app'; -import { toggleDarkMode } from '../actions/settings'; +import { toggleDarkMode, toggleDocs } from '../actions/settings'; import { RootState } from '../reducers'; import { SettingsStringId } from './settings-i18n'; import en from './settings-i18n.en.json'; @@ -16,11 +16,13 @@ import './settings.scss'; type StateProps = { open: boolean; + showDocs: boolean; darkMode: boolean; }; type DispatchProps = { onClose: () => void; + onShowDocsChanged: () => void; onDarkModeChanged: () => void; }; @@ -28,16 +30,33 @@ type SettingsProps = StateProps & DispatchProps & WithI18nProps; class SettingsDrawer extends React.PureComponent { render(): JSX.Element { - const { i18n, open, onClose, darkMode, onDarkModeChanged } = this.props; + const { + i18n, + open, + onClose, + showDocs, + onShowDocsChanged, + darkMode, + onDarkModeChanged, + } = this.props; return ( onClose()} >
+ onShowDocsChanged()} + /> { const mapStateToProps = (state: RootState): StateProps => ({ open: state.app.showSettings, + showDocs: state.settings.showDocs, darkMode: state.settings.darkMode, }); const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ onClose: (): Action => dispatch(closeSettings()), + onShowDocsChanged: (): Action => dispatch(toggleDocs()), onDarkModeChanged: (): Action => dispatch(toggleDarkMode()), }); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index d4bfb6e9..b8ed8c90 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -4,7 +4,6 @@ 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'; @@ -40,7 +39,6 @@ class Toolbar extends React.Component { - diff --git a/src/components/button-i18n.en.json b/src/components/button-i18n.en.json index 78d2547b..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" }, - "settings": { "tooltip": "Open setting" } + "settings": { "tooltip": "Settings" } } diff --git a/src/components/button-i18n.ts b/src/components/button-i18n.ts index fbad5705..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', Settings = 'settings.tooltip', } diff --git a/src/components/images/question-mark.svg b/src/components/images/question-mark.svg deleted file mode 100644 index 218ad12d..00000000 --- a/src/components/images/question-mark.svg +++ /dev/null @@ -1,145 +0,0 @@ - - - -image/svg+xmlhttps://www.svgrepo.com/svg/131030/question-mark - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json index 49c8212d..94a6a92a 100644 --- a/src/components/settings-i18n.en.json +++ b/src/components/settings-i18n.en.json @@ -3,6 +3,9 @@ "title": "Settings", "appearance": { "title": "Appearance", + "documentation": { + "label": "Documentation" + }, "dark-mode": { "label": "Dark mode" } diff --git a/src/components/settings-i18n.ts b/src/components/settings-i18n.ts index 06464b31..0e7062bb 100644 --- a/src/components/settings-i18n.ts +++ b/src/components/settings-i18n.ts @@ -6,5 +6,6 @@ export enum SettingsStringId { Title = 'settings.title', AppearanceTitle = 'settings.appearance.title', + AppearanceDocumentationLabel = 'settings.appearance.documentation.label', AppearanceDarkModeLabel = 'settings.appearance.dark-mode.label', } diff --git a/src/reducers/app.ts b/src/reducers/app.ts index 2b2171ee..ca68133a 100644 --- a/src/reducers/app.ts +++ b/src/reducers/app.ts @@ -18,18 +18,8 @@ const showSettings: Reducer = (state = false, action) => { } }; -const showDocs: Reducer = (state = false, action) => { - switch (action.type) { - case AppActionType.ToggleDocs: - return !state; - default: - return state; - } -}; - export interface AppState { readonly showSettings: boolean; - readonly showDocs: boolean; } -export default combineReducers({ showSettings, showDocs }); +export default combineReducers({ showSettings }); diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts index a4c6e6e6..f5365542 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -7,6 +7,7 @@ import { SettingsActionType } from '../actions/settings'; export interface SettingsState { readonly darkMode: boolean; + readonly showDocs: boolean; } const darkMode: Reducer = (state = false, action) => { @@ -18,4 +19,13 @@ const darkMode: Reducer = (state = false, action) => { } }; -export default combineReducers({ darkMode }); +const showDocs: Reducer = (state = false, action) => { + switch (action.type) { + case SettingsActionType.ToggleDocs: + return !state; + default: + return state; + } +}; + +export default combineReducers({ darkMode, showDocs }); 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/index.ts b/src/sagas/index.ts index 2c5f370a..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'; @@ -18,7 +17,6 @@ import terminal from './terminal'; /* istanbul ignore next */ export default function* (): Generator { yield all([ - app(), bleUart(), lwp3BootloaderBle(), lwp3BootloaderProtocol(), diff --git a/src/sagas/app.test.ts b/src/sagas/settings.test.ts similarity index 55% rename from src/sagas/app.test.ts rename to src/sagas/settings.test.ts index 77ef1431..63204417 100644 --- a/src/sagas/app.test.ts +++ b/src/sagas/settings.test.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2021 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'; +import { startup } from '../actions/app'; +import { SettingsActionType, toggleDarkMode, toggleDocs } from '../actions/settings'; +import settings from './settings'; afterAll(() => { jest.restoreAllMocks(); @@ -13,7 +14,7 @@ afterAll(() => { describe('startup', () => { test('with large screen', async () => { - const saga = new AsyncSaga(app); + const saga = new AsyncSaga(settings); jest.spyOn( Object.getPrototypeOf(window.localStorage), @@ -25,13 +26,13 @@ describe('startup', () => { // toggles documentation to be visible const toggleDocsAction = await saga.take(); - expect(toggleDocsAction.type).toBe(AppActionType.ToggleDocs); + expect(toggleDocsAction.type).toBe(SettingsActionType.ToggleDocs); await saga.end(); }); test('with small screen', async () => { - const saga = new AsyncSaga(app); + const saga = new AsyncSaga(settings); jest.spyOn( Object.getPrototypeOf(window.localStorage), @@ -47,30 +48,30 @@ describe('startup', () => { }); test('with stored value "true"', async () => { - const saga = new AsyncSaga(app); + const saga = new AsyncSaga(settings); jest.spyOn( Object.getPrototypeOf(window.localStorage), 'getItem', - ).mockReturnValue('true'); + ).mockReturnValue('{"showDocs":true}'); innerWidth = 800; saga.put(startup()); // toggles documentation to be visible const toggleDocsAction = await saga.take(); - expect(toggleDocsAction.type).toBe(AppActionType.ToggleDocs); + expect(toggleDocsAction.type).toBe(SettingsActionType.ToggleDocs); await saga.end(); }); test('with stored value "false"', async () => { - const saga = new AsyncSaga(app); + const saga = new AsyncSaga(settings); jest.spyOn( Object.getPrototypeOf(window.localStorage), 'getItem', - ).mockReturnValue('false'); + ).mockReturnValue('{"showDocs":false}'); innerWidth = 1024; saga.put(startup()); @@ -81,28 +82,36 @@ describe('startup', () => { }); }); -describe('storeDocsState', () => { - test('showing', async () => { - const saga = new AsyncSaga(app); +describe('store settings to local storage', () => { + test('showDocs', async () => { + const saga = new AsyncSaga(settings); + // NOTE: we aren't testing reducers here, so value doesn't change + // even though we call the toggle function const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') - .mockImplementation((_key, value) => expect(value).toBe('true')); - saga.setState({ app: { showSettings: false, showDocs: true } }); + .mockImplementation((_key, value) => + expect(value).toBe('{"darkMode":false,"showDocs":true}'), + ); + saga.setState({ settings: { darkMode: false, showDocs: true } }); saga.put(toggleDocs()); expect(mockSetItem).toHaveBeenCalled(); await saga.end(); }); - test('hidden', async () => { - const saga = new AsyncSaga(app); + test('darkMode', async () => { + const saga = new AsyncSaga(settings); + // NOTE: we aren't testing reducers here, so value doesn't change + // even though we call the toggle function const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') - .mockImplementation((_key, value) => expect(value).toBe('false')); - saga.setState({ app: { showSettings: false, showDocs: false } }); - saga.put(toggleDocs()); + .mockImplementation((_key, value) => + expect(value).toBe('{"darkMode":true,"showDocs":false}'), + ); + saga.setState({ settings: { darkMode: true, showDocs: false } }); + saga.put(toggleDarkMode()); expect(mockSetItem).toHaveBeenCalled(); await saga.end(); diff --git a/src/sagas/settings.ts b/src/sagas/settings.ts index 1d888233..933d1f94 100644 --- a/src/sagas/settings.ts +++ b/src/sagas/settings.ts @@ -1,17 +1,23 @@ import { put, select, takeEvery } from 'redux-saga/effects'; import { AppActionType } from '../actions/app'; -import { SettingsActionType, toggleDarkMode } from '../actions/settings'; +import { SettingsActionType, toggleDarkMode, toggleDocs } from '../actions/settings'; import { RootState } from '../reducers'; import { SettingsState } from '../reducers/settings'; function* loadSettings(): Generator { - const settingsString = localStorage.getItem('settings'); - if (!settingsString) { - return; - } + const settingsString = localStorage.getItem('settings') || '{}'; const settings = JSON.parse(settingsString) as SettingsState; // TODO: there has to be a better way to initialize app state from settings + + if ( + settings.showDocs === undefined + ? window.innerWidth >= 1024 + : settings.showDocs === true + ) { + yield put(toggleDocs()); + } + if (settings.darkMode) { yield put(toggleDarkMode()); } @@ -22,6 +28,7 @@ function* saveSettings(): Generator { localStorage.setItem('settings', JSON.stringify(settings)); } +// TODO: this should really be part of component, not saga function* updateDarkModeClass(): Generator { const darkMode = (yield select((s: RootState) => s.settings.darkMode)) as boolean; if (darkMode) { From a48f3f381ea53c3ce02f383cd1eb988127c45351 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 15:09:41 -0600 Subject: [PATCH 07/13] convert settings to do/did action pattern --- src/actions/settings.ts | 59 ++++-- src/components/SettingsDrawer.tsx | 25 ++- src/index.tsx | 16 +- src/reducers/settings.ts | 25 ++- src/sagas/settings.test.ts | 337 ++++++++++++++++++++++++------ src/sagas/settings.ts | 121 ++++++++--- src/settings/index.ts | 20 ++ 7 files changed, 482 insertions(+), 121 deletions(-) create mode 100644 src/settings/index.ts diff --git a/src/actions/settings.ts b/src/actions/settings.ts index d3731c6e..cfa4ba1a 100644 --- a/src/actions/settings.ts +++ b/src/actions/settings.ts @@ -2,28 +2,59 @@ // Copyright (c) 2021 The Pybricks Authors import { Action } from 'redux'; +import { SettingId } from '../settings'; /** Actions related to settings. */ export enum SettingsActionType { - ToggleDocs = 'settings.action.toggleDocs', - ToggleDarkMode = 'settings.action.toggleDarkMode', + SetBoolean = 'settings.action.setBoolean', + DidFailToSetBoolean = 'settings.action.didFailToSetBoolean', + DidBooleanChange = 'settings.action.didBooleanChange', } -/** Action to toggle show docs setting. */ -export type SettingsToggleDocsAction = Action; +type SettingInfo = { + /** The ID of the setting. */ + id: SettingId; + /** The new state for the setting. */ + newState: T; +}; -/** Toggles show docs setting on or off. */ -export function toggleDocs(): SettingsToggleDocsAction { - return { type: SettingsActionType.ToggleDocs }; +/** 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 to toggle dark mode setting. */ -export type SettingsToggleDarkModeAction = Action; +/** Action that indicates setting/storing a setting failed. */ +export type SettingsDidFailToSetBooleanAction = Action & { + id: SettingId; + err: Error; +}; -/** Toggles dark mode setting on or off. */ -export function toggleDarkMode(): SettingsToggleDarkModeAction { - return { type: SettingsActionType.ToggleDarkMode }; +/** Creates an action indicating that setting/storing a setting failed. */ +export function didFailToSetBoolean( + id: SettingId, + err: Error, +): SettingsDidFailToSetBooleanAction { + return { type: SettingsActionType.DidFailToSetBoolean, id, err }; } -/** common type for all settings actions. */ -export type SettingsAction = SettingsToggleDocsAction | SettingsToggleDarkModeAction; +/** 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/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index a66cf5b8..d974d443 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -7,8 +7,9 @@ import React from 'react'; import { connect } from 'react-redux'; import { Action, Dispatch } from '../actions'; import { closeSettings } from '../actions/app'; -import { toggleDarkMode, toggleDocs } from '../actions/settings'; +import { setBoolean } from '../actions/settings'; import { RootState } from '../reducers'; +import { SettingId } from '../settings'; import { SettingsStringId } from './settings-i18n'; import en from './settings-i18n.en.json'; @@ -22,8 +23,8 @@ type StateProps = { type DispatchProps = { onClose: () => void; - onShowDocsChanged: () => void; - onDarkModeChanged: () => void; + onShowDocsChanged: (checked: boolean) => void; + onDarkModeChanged: (checked: boolean) => void; }; type SettingsProps = StateProps & DispatchProps & WithI18nProps; @@ -55,7 +56,11 @@ class SettingsDrawer extends React.PureComponent { )} large={true} checked={showDocs} - onChange={() => onShowDocsChanged()} + onChange={(e) => + onShowDocsChanged( + (e.target as HTMLInputElement).checked, + ) + } /> { )} large={true} checked={darkMode} - onChange={() => onDarkModeChanged()} + onChange={(e) => + onDarkModeChanged( + (e.target as HTMLInputElement).checked, + ) + } />
@@ -80,8 +89,10 @@ const mapStateToProps = (state: RootState): StateProps => ({ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ onClose: (): Action => dispatch(closeSettings()), - onShowDocsChanged: (): Action => dispatch(toggleDocs()), - onDarkModeChanged: (): Action => dispatch(toggleDarkMode()), + onShowDocsChanged: (checked): Action => + dispatch(setBoolean(SettingId.ShowDocs, checked)), + onDarkModeChanged: (checked): Action => + dispatch(setBoolean(SettingId.DarkMode, checked)), }); export default connect( 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/settings.ts b/src/reducers/settings.ts index f5365542..884814ed 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -4,25 +4,38 @@ 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; } -const darkMode: Reducer = (state = false, action) => { +const darkMode: Reducer = ( + state = getDefaultBooleanValue(SettingId.DarkMode), + action, +) => { switch (action.type) { - case SettingsActionType.ToggleDarkMode: - return !state; + case SettingsActionType.DidBooleanChange: + if (action.id === SettingId.DarkMode) { + return action.newState; + } + return state; default: return state; } }; -const showDocs: Reducer = (state = false, action) => { +const showDocs: Reducer = ( + state = getDefaultBooleanValue(SettingId.ShowDocs), + action, +) => { switch (action.type) { - case SettingsActionType.ToggleDocs: - return !state; + case SettingsActionType.DidBooleanChange: + if (action.id === SettingId.ShowDocs) { + return action.newState; + } + return state; default: return state; } diff --git a/src/sagas/settings.test.ts b/src/sagas/settings.test.ts index 63204417..99f9aeb9 100644 --- a/src/sagas/settings.test.ts +++ b/src/sagas/settings.test.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 The Pybricks Authors -// File: sagas/app.test.ts -// Tests for app sagas. +// File: sagas/settings.test.ts +// Tests for settings sagas. import { AsyncSaga } from '../../test'; import { startup } from '../actions/app'; -import { SettingsActionType, toggleDarkMode, toggleDocs } from '../actions/settings'; +import { didBooleanChange, didFailToSetBoolean, setBoolean } from '../actions/settings'; +import { SettingsState } from '../reducers/settings'; +import { SettingId } from '../settings'; import settings from './settings'; afterAll(() => { @@ -13,107 +15,316 @@ afterAll(() => { }); describe('startup', () => { - test('with large screen', async () => { - const saga = new AsyncSaga(settings); + 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; + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockReturnValue(null); + innerWidth = 1024; - saga.put(startup()); + saga.put(startup()); - // toggles documentation to be visible - const toggleDocsAction = await saga.take(); - expect(toggleDocsAction.type).toBe(SettingsActionType.ToggleDocs); + // does nothing - await saga.end(); + 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(); + }); }); - test('with small screen', async () => { - const saga = new AsyncSaga(settings); + describe('darkMode', () => { + test('with no value set', async () => { + const saga = new AsyncSaga(settings); - jest.spyOn( - Object.getPrototypeOf(window.localStorage), - 'getItem', - ).mockReturnValue(null); - innerWidth = 800; + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockReturnValue(null); - saga.put(startup()); + saga.put(startup()); - // does nothing + // does nothing - await saga.end(); - }); + await saga.end(); + }); - test('with stored value "true"', async () => { - const saga = new AsyncSaga(settings); + test('with value set to true', async () => { + const saga = new AsyncSaga(settings); - jest.spyOn( - Object.getPrototypeOf(window.localStorage), - 'getItem', - ).mockReturnValue('{"showDocs":true}'); - innerWidth = 800; + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockImplementation((key) => { + switch (key) { + case 'setting.darkMode': + return 'true'; + default: + return null; + } + }); - saga.put(startup()); + saga.put(startup()); - // toggles documentation to be visible - const toggleDocsAction = await saga.take(); - expect(toggleDocsAction.type).toBe(SettingsActionType.ToggleDocs); + // requests to enable dark mode + const action = await saga.take(); + expect(action).toEqual(didBooleanChange(SettingId.DarkMode, true)); - await saga.end(); - }); + await saga.end(); + }); - test('with stored value "false"', async () => { - const saga = new AsyncSaga(settings); + test('with value set to false', async () => { + const saga = new AsyncSaga(settings); - jest.spyOn( - Object.getPrototypeOf(window.localStorage), - 'getItem', - ).mockReturnValue('{"showDocs":false}'); - innerWidth = 1024; + jest.spyOn( + Object.getPrototypeOf(window.localStorage), + 'getItem', + ).mockImplementation((key) => { + switch (key) { + case 'setting.darkMode': + return 'false'; + default: + return null; + } + }); - saga.put(startup()); + saga.put(startup()); - // does nothing + // does nothing - await saga.end(); + 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); - // NOTE: we aren't testing reducers here, so value doesn't change - // even though we call the toggle function const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') - .mockImplementation((_key, value) => - expect(value).toBe('{"darkMode":false,"showDocs":true}'), - ); - saga.setState({ settings: { darkMode: false, showDocs: true } }); - saga.put(toggleDocs()); + .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); - // NOTE: we aren't testing reducers here, so value doesn't change - // even though we call the toggle function const mockSetItem = jest .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') - .mockImplementation((_key, value) => - expect(value).toBe('{"darkMode":true,"showDocs":false}'), - ); - saga.setState({ settings: { darkMode: true, showDocs: false } }); - saga.put(toggleDarkMode()); + .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(); + }); +}); + +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 index 933d1f94..1cbf66d5 100644 --- a/src/sagas/settings.ts +++ b/src/sagas/settings.ts @@ -1,45 +1,106 @@ -import { put, select, takeEvery } from 'redux-saga/effects'; +// 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, toggleDarkMode, toggleDocs } from '../actions/settings'; +import { + SettingsActionType, + SettingsSetBooleanAction, + didBooleanChange, + didFailToSetBoolean, +} from '../actions/settings'; import { RootState } from '../reducers'; -import { SettingsState } from '../reducers/settings'; +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 { - const settingsString = localStorage.getItem('settings') || '{}'; - const settings = JSON.parse(settingsString) as SettingsState; + for (const id of Object.values(SettingId)) { + const storageValue = localStorage.getItem(`setting.${id}`); + const defaultValue = getDefaultBooleanValue(id); + const value = + storageValue === null ? defaultValue : stringToBoolean(storageValue); - // TODO: there has to be a better way to initialize app state from settings - - if ( - settings.showDocs === undefined - ? window.innerWidth >= 1024 - : settings.showDocs === true - ) { - yield put(toggleDocs()); - } - - if (settings.darkMode) { - yield put(toggleDarkMode()); + if (value !== defaultValue) { + yield put(didBooleanChange(id, value)); + } } } -function* saveSettings(): Generator { - const settings = (yield select((s: RootState) => s.settings)) as SettingsState; - localStorage.setItem('settings', JSON.stringify(settings)); -} +function* storeSetting(action: SettingsSetBooleanAction): Generator { + const key = `setting.${action.id}`; + const newValue = String(action.newState); -// TODO: this should really be part of component, not saga -function* updateDarkModeClass(): Generator { - const darkMode = (yield select((s: RootState) => s.settings.darkMode)) as boolean; - if (darkMode) { - document.body.classList.add('bp3-dark'); - } else { - document.body.classList.remove('bp3-dark'); + 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(Object.values(SettingsActionType), saveSettings); - yield takeEvery(SettingsActionType.ToggleDarkMode, updateDarkModeClass); + yield takeEvery(SettingsActionType.SetBoolean, storeSetting); } diff --git a/src/settings/index.ts b/src/settings/index.ts new file mode 100644 index 00000000..09be058e --- /dev/null +++ b/src/settings/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +// Definitions for user settings. + +export enum SettingId { + ShowDocs = 'showDocs', + DarkMode = 'darkMode', +} + +export function getDefaultBooleanValue(id: SettingId): boolean { + switch (id) { + case SettingId.ShowDocs: + return window.innerWidth >= 1024; + case SettingId.DarkMode: + return false; + default: + throw Error(`Bad setting id: ${id}`); + } +} From 25c0d104a93743bfe407e4ed6a239fb5b9a36149 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 15:25:57 -0600 Subject: [PATCH 08/13] convert macOS check to utility function --- src/components/Editor.tsx | 5 +++-- src/components/Terminal.tsx | 5 +++-- src/utils/os.test.ts | 15 +++++++++++++++ src/utils/os.ts | 8 ++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/utils/os.test.ts create mode 100644 src/utils/os.ts diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 25038573..80fde2cd 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -17,6 +17,7 @@ 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'; @@ -142,7 +143,7 @@ class Editor extends React.Component { }} 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'} /> { }} 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'} /> { + 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); +} From 2f57ae2f573a4986a801d160dc8e5c1bfa17a5df Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 16:35:23 -0600 Subject: [PATCH 09/13] split scss into separate files --- src/components/App.tsx | 1 + src/components/Editor.tsx | 1 + src/components/StatusBar.tsx | 2 + src/components/Toolbar.tsx | 2 + src/components/app.scss | 24 ++++++++ src/components/editor.scss | 28 +++++++++ src/{ => components/images}/beta.svg | 0 src/components/settings.scss | 2 + src/components/status-bar.scss | 26 ++++++++ src/components/toolbar.scss | 9 +++ src/index.scss | 89 +--------------------------- src/variables.scss | 18 ++++++ 12 files changed, 115 insertions(+), 87 deletions(-) create mode 100644 src/components/app.scss create mode 100644 src/components/editor.scss rename src/{ => components/images}/beta.svg (100%) create mode 100644 src/components/status-bar.scss create mode 100644 src/components/toolbar.scss create mode 100644 src/variables.scss diff --git a/src/components/App.tsx b/src/components/App.tsx index 61d7417d..04a15162 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -12,6 +12,7 @@ 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.settings.showDocs); diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 80fde2cd..75449edf 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -29,6 +29,7 @@ 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; 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/Toolbar.tsx b/src/components/Toolbar.tsx index b8ed8c90..5107bd8d 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -12,6 +12,8 @@ import SaveAsButton from './SaveAsButton'; import SettingsButton from './SettingsButton'; import StopButton from './StopButton'; +import './toolbar.scss'; + class Toolbar extends React.Component { render(): JSX.Element { return ( 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/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/settings.scss b/src/components/settings.scss index 480c0325..db0e0752 100644 --- a/src/components/settings.scss +++ b/src/components/settings.scss @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors +// Custom styling for the Settings* controls. + .pb-settings { margin: 25px; } 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 c7a9e7e4..b1bab40c 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,26 +1,10 @@ // 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; - -$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; - +@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,72 +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; -} - -.bp3-dark .ace_gutter { - // make ace editor match app backgound color - background-color: $pt-dark-app-background-color !important; -} - -.ace_gutter { - // make ace editor match app backgound color - background-color: $pt-app-background-color !important; -} - -.bp3-dark .splitter-layout > .layout-splitter { - // make layout splitter match app color scheme - background-color: $pt-dark-app-background-color !important; -} - -.layout-splitter { - // make layout splitter match app color scheme - background-color: $pt-app-background-color !important; -} - -.bp3-dark .terminal-padding { - background-color: black; -} - -.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/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; From eba2239610e9b9bb27282302dc647577201a3327 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 16:45:08 -0600 Subject: [PATCH 10/13] make settings group label bigger and bolder --- src/components/settings.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/settings.scss b/src/components/settings.scss index db0e0752..6a4d3bf0 100644 --- a/src/components/settings.scss +++ b/src/components/settings.scss @@ -6,3 +6,8 @@ .pb-settings { margin: 25px; } + +.pb-settings .bp3-label { + font-size: larger; + font-weight: bolder; +} From 9ee24d0b0caf1d2eb18d1467eee1461cc09da7bc Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 17:14:19 -0600 Subject: [PATCH 11/13] add help with zoom --- src/components/SettingsDrawer.tsx | 12 +++++++++++- src/components/settings-i18n.en.json | 3 +++ src/components/settings-i18n.ts | 1 + src/components/settings.scss | 6 +++++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index d974d443..611461be 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -10,6 +10,7 @@ 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'; @@ -49,7 +50,16 @@ class SettingsDrawer extends React.PureComponent { onClose={() => onClose()} >
- + {isMacOS() ? 'Cmd' : 'Ctrl'}-+, + out: {isMacOS() ? 'Cmd' : 'Ctrl'}--, + }, + )} + > Date: Thu, 14 Jan 2021 18:03:04 -0600 Subject: [PATCH 12/13] add setting to flash current user program --- src/components/SettingsDrawer.tsx | 21 ++++++++++++++++ src/components/settings-i18n.en.json | 6 +++++ src/components/settings-i18n.ts | 2 ++ src/components/settings.scss | 2 +- src/reducers/settings.ts | 18 +++++++++++++- src/sagas/flash-firmware.ts | 37 +++++++++++++++++++++++++--- src/sagas/settings.test.ts | 20 +++++++++++++++ src/settings/index.ts | 3 +++ 8 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 611461be..271312ff 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -20,12 +20,14 @@ 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; @@ -40,6 +42,8 @@ class SettingsDrawer extends React.PureComponent { onShowDocsChanged, darkMode, onDarkModeChanged, + flashCurrentProgram, + onFlashCurrentProgramChanged, } = this.props; return ( { } /> + + + onFlashCurrentProgramChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> +
); @@ -95,6 +113,7 @@ 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 => ({ @@ -103,6 +122,8 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ dispatch(setBoolean(SettingId.ShowDocs, checked)), onDarkModeChanged: (checked): Action => dispatch(setBoolean(SettingId.DarkMode, checked)), + onFlashCurrentProgramChanged: (checked): Action => + dispatch(setBoolean(SettingId.FlashCurrentProgram, checked)), }); export default connect( diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json index 4cdbfaed..7ac9a327 100644 --- a/src/components/settings-i18n.en.json +++ b/src/components/settings-i18n.en.json @@ -12,6 +12,12 @@ "zoom": { "help": "Use {in} and {out} to zoom." } + }, + "firmware": { + "title": "Firmware", + "flash-current-program": { + "label": "Flash my program" + } } } } diff --git a/src/components/settings-i18n.ts b/src/components/settings-i18n.ts index c56cda37..baea36ce 100644 --- a/src/components/settings-i18n.ts +++ b/src/components/settings-i18n.ts @@ -9,4 +9,6 @@ export enum SettingsStringId { AppearanceDocumentationLabel = 'settings.appearance.documentation.label', AppearanceDarkModeLabel = 'settings.appearance.dark-mode.label', AppearanceZoomHelp = 'settings.appearance.zoom.help', + FirmwareTitle = 'settings.firmware.title', + FirmwareCurrentProgramLabel = 'settings.firmware.flash-current-program.label', } diff --git a/src/components/settings.scss b/src/components/settings.scss index e79c6349..a1c23ce4 100644 --- a/src/components/settings.scss +++ b/src/components/settings.scss @@ -3,7 +3,7 @@ // Custom styling for the Settings* controls. -.pb-settings { +.pb-settings .bp3-form-group { margin: 25px; } diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts index 884814ed..51d4294e 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -9,6 +9,7 @@ import { SettingId, getDefaultBooleanValue } from '../settings'; export interface SettingsState { readonly darkMode: boolean; readonly showDocs: boolean; + readonly flashCurrentProgram: boolean; } const darkMode: Reducer = ( @@ -41,4 +42,19 @@ const showDocs: Reducer = ( } }; -export default combineReducers({ darkMode, showDocs }); +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/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/settings.test.ts b/src/sagas/settings.test.ts index 99f9aeb9..ad4c762a 100644 --- a/src/sagas/settings.test.ts +++ b/src/sagas/settings.test.ts @@ -275,6 +275,26 @@ describe('store settings to local storage', () => { 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', () => { diff --git a/src/settings/index.ts b/src/settings/index.ts index 09be058e..0c9a2406 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -6,6 +6,7 @@ export enum SettingId { ShowDocs = 'showDocs', DarkMode = 'darkMode', + FlashCurrentProgram = 'flashCurrentProgram', } export function getDefaultBooleanValue(id: SettingId): boolean { @@ -13,7 +14,9 @@ export function getDefaultBooleanValue(id: SettingId): boolean { 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}`); } From 51fc1ddce0819e5e91f3d2982b1e1a8036ae35e8 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 14 Jan 2021 18:22:29 -0600 Subject: [PATCH 13/13] add tooltips to settings --- src/components/SettingsDrawer.tsx | 97 ++++++++++++++++++---------- src/components/settings-i18n.en.json | 9 ++- src/components/settings-i18n.ts | 3 + 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 271312ff..c7d3d68e 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { Drawer, FormGroup, Switch } from '@blueprintjs/core'; +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'; @@ -16,6 +16,8 @@ import en from './settings-i18n.en.json'; import './settings.scss'; +const tooltipDelay = 1000; + type StateProps = { open: boolean; showDocs: boolean; @@ -64,44 +66,71 @@ class SettingsDrawer extends React.PureComponent { }, )} > - - onShowDocsChanged( - (e.target as HTMLInputElement).checked, - ) - } - /> - + + onShowDocsChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> + + - onDarkModeChanged( - (e.target as HTMLInputElement).checked, - ) - } - /> + position={Position.LEFT} + targetTagName="div" + hoverOpenDelay={tooltipDelay} + > + + onDarkModeChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> + - - onFlashCurrentProgramChanged( - (e.target as HTMLInputElement).checked, - ) - } - /> + position={Position.LEFT} + targetTagName="div" + hoverOpenDelay={tooltipDelay} + > + + onFlashCurrentProgramChanged( + (e.target as HTMLInputElement).checked, + ) + } + /> +
diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json index 7ac9a327..121d1298 100644 --- a/src/components/settings-i18n.en.json +++ b/src/components/settings-i18n.en.json @@ -4,10 +4,12 @@ "appearance": { "title": "Appearance", "documentation": { - "label": "Documentation" + "label": "Documentation", + "tooltip": "Hides/shows the documentation pane in the app." }, "dark-mode": { - "label": "Dark mode" + "label": "Dark mode", + "tooltip": "Disables/enables dark mode." }, "zoom": { "help": "Use {in} and {out} to zoom." @@ -16,7 +18,8 @@ "firmware": { "title": "Firmware", "flash-current-program": { - "label": "Flash my 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.ts b/src/components/settings-i18n.ts index baea36ce..6dc5c3fd 100644 --- a/src/components/settings-i18n.ts +++ b/src/components/settings-i18n.ts @@ -7,8 +7,11 @@ 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', }