From d7dbaaa12dc57995768ccb9ea41298afba15d676 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 11 Jan 2021 22:56:12 -0600 Subject: [PATCH] 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); +}