diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 50dddbe0..c813a38e 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -6,5 +6,6 @@ import { testRender } from '../../test'; import App from './App'; it.each([false, true])('should render', (darkMode) => { - testRender(, { settings: { darkMode } }); + localStorage.setItem('usehooks-ts-dark-mode', String(darkMode)); + testRender(); }); diff --git a/src/app/App.tsx b/src/app/App.tsx index afc110c3..49c1041d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -5,7 +5,7 @@ import { Classes } from '@blueprintjs/core'; import React, { useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import SplitterLayout from 'react-splitter-layout'; -import { useLocalStorage } from 'usehooks-ts'; +import { useDarkMode, useLocalStorage } from 'usehooks-ts'; import Editor, { EditorType } from '../editor/Editor'; import Explorer from '../explorer/Explorer'; import { useSelector } from '../reducers'; @@ -130,7 +130,7 @@ type AppProps = { }; const App: React.VoidFunctionComponent = ({ onEditorChanged }) => { - const darkMode = useSelector((s): boolean => s.settings.darkMode); + const { isDarkMode } = useDarkMode(); const showDocs = useSelector((s): boolean => s.settings.showDocs); const [isDragging, setIsDragging] = useState(false); const dispatch = useDispatch(); @@ -138,17 +138,17 @@ const App: React.VoidFunctionComponent = ({ onEditorChanged }) => { const [docsSplit, setDocsSplit] = useLocalStorage('app-docs-split', 30); const [terminalSplit, setTerminalSplit] = useLocalStorage('app-terminal-split', 30); - // darkMode class has to be applied to body element, otherwise it won't + // Classes.DARK has to be applied to body element, otherwise it won't // affect portals useEffect(() => { - if (!darkMode) { + if (!isDarkMode) { // no class for light mode, so nothing to do return; } document.body.classList.add(Classes.DARK); return () => document.body.classList.remove(Classes.DARK); - }, [darkMode]); + }, [isDarkMode]); return (
diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index bb277ad5..4cf7c006 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -9,10 +9,10 @@ import xcodeTheme from 'monaco-themes/themes/Xcode_default.json'; import React, { useState } from 'react'; import MonacoEditor, { monaco } from 'react-monaco-editor'; import { useDispatch } from 'react-redux'; +import { useDarkMode } from 'usehooks-ts'; import { IDisposable } from 'xterm'; import { fileStorageWriteFile } from '../fileStorage/actions'; import { compile } from '../mpy/actions'; -import { useSelector } from '../reducers'; import { toggleBoolean } from '../settings/actions'; import { BooleanSettingId } from '../settings/defaults'; import { isMacOS } from '../utils/os'; @@ -136,13 +136,13 @@ const EditorContextMenu: React.VoidFunctionComponent = ( ); }; -type EditorProps = { onEditorChanged: (editor: EditorType) => void }; +type EditorProps = { onEditorChanged?: (editor: EditorType) => void }; const Editor: React.VoidFunctionComponent = ({ onEditorChanged }) => { const dispatch = useDispatch(); const [editor, setEditor] = useState(null); - const darkMode = useSelector((s) => s.settings.darkMode); + const { isDarkMode } = useDarkMode(); const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en }); @@ -158,7 +158,7 @@ const Editor: React.VoidFunctionComponent = ({ onEditorChanged }) = > = ({ onClose, }) => { const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false); + const { isDarkMode, toggle: toggleDarkMode } = useDarkMode(); const showDocs = useSelector((s) => s.settings.showDocs); - const darkMode = useSelector((s) => s.settings.darkMode); const flashCurrentProgram = useSelector((s) => s.settings.flashCurrentProgram); const isServiceWorkerRegistered = useSelector( (s) => s.app.isServiceWorkerRegistered, @@ -147,15 +148,8 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ label={i18n.translate( SettingsStringId.AppearanceDarkModeLabel, )} - checked={darkMode} - onChange={(e) => - dispatch( - setBoolean( - BooleanSettingId.DarkMode, - (e.target as HTMLInputElement).checked, - ), - ) - } + checked={isDarkMode} + onChange={toggleDarkMode} /> diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 5b2a6fb3..a7f7b99a 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -1,13 +1,10 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // Definitions for user selectable settings. -import { prefersDarkMode } from '../utils/os'; - export enum BooleanSettingId { ShowDocs = 'showDocs', - DarkMode = 'darkMode', FlashCurrentProgram = 'flashCurrentProgram', } @@ -15,8 +12,6 @@ export function getDefaultBooleanValue(id: BooleanSettingId): boolean { switch (id) { case BooleanSettingId.ShowDocs: return window.innerWidth >= 1024; - case BooleanSettingId.DarkMode: - return prefersDarkMode(); case BooleanSettingId.FlashCurrentProgram: return false; // istanbul ignore next: it is a programmer error if we hit this diff --git a/src/settings/reducers.test.ts b/src/settings/reducers.test.ts index 3b760c71..1c2aaa82 100644 --- a/src/settings/reducers.test.ts +++ b/src/settings/reducers.test.ts @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + import { AnyAction } from 'redux'; import { didBooleanChange, didStringChange } from './actions'; import { BooleanSettingId, StringSettingId } from './defaults'; @@ -8,7 +11,6 @@ type State = ReturnType; test('initial state', () => { expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(` Object { - "darkMode": false, "flashCurrentProgram": false, "hubName": "", "isHubNameValid": true, @@ -17,17 +19,6 @@ test('initial state', () => { `); }); -describe('darkMode', () => { - test('setting changed', () => { - expect( - reducers( - { darkMode: false } as State, - didBooleanChange(BooleanSettingId.DarkMode, true), - ).darkMode, - ).toBe(true); - }); -}); - describe('showDocs', () => { test('setting changed', () => { expect( diff --git a/src/settings/reducers.ts b/src/settings/reducers.ts index f53f4376..9db878a4 100644 --- a/src/settings/reducers.ts +++ b/src/settings/reducers.ts @@ -12,20 +12,6 @@ import { const encoder = new TextEncoder(); -const darkMode: Reducer = ( - state = getDefaultBooleanValue(BooleanSettingId.DarkMode), - action, -) => { - if (didBooleanChange.matches(action)) { - if (action.id === BooleanSettingId.DarkMode) { - return action.newState; - } - return state; - } - - return state; -}; - const showDocs: Reducer = ( state = getDefaultBooleanValue(BooleanSettingId.ShowDocs), action, @@ -87,7 +73,6 @@ const isHubNameValid: Reducer = (state = true, action) => { }; export default combineReducers({ - darkMode, showDocs, flashCurrentProgram, hubName, diff --git a/src/settings/sagas.test.ts b/src/settings/sagas.test.ts index cee297f2..111f7b95 100644 --- a/src/settings/sagas.test.ts +++ b/src/settings/sagas.test.ts @@ -151,69 +151,6 @@ describe('startup', () => { await saga.end(); }); }); - - describe('darkMode', () => { - test('with no value set', async () => { - const saga = new AsyncSaga(settings); - - jest.spyOn( - Object.getPrototypeOf(window.localStorage), - 'getItem', - ).mockReturnValue(null); - - saga.put(didStart()); - - // does nothing - - await saga.end(); - }); - - test('with value set to true', async () => { - const saga = new AsyncSaga(settings); - - jest.spyOn( - Object.getPrototypeOf(window.localStorage), - 'getItem', - ).mockImplementation((key) => { - switch (key) { - case 'setting.darkMode': - return 'true'; - default: - return null; - } - }); - - saga.put(didStart()); - - // requests to enable dark mode - const action = await saga.take(); - expect(action).toEqual(didBooleanChange(BooleanSettingId.DarkMode, true)); - - await saga.end(); - }); - - test('with value set to false', async () => { - const saga = new AsyncSaga(settings); - - jest.spyOn( - Object.getPrototypeOf(window.localStorage), - 'getItem', - ).mockImplementation((key) => { - switch (key) { - case 'setting.darkMode': - return 'false'; - default: - return null; - } - }); - - saga.put(didStart()); - - // does nothing - - await saga.end(); - }); - }); }); describe('store settings to local storage', () => { @@ -276,27 +213,6 @@ describe('store settings to local storage', () => { await saga.end(); }); - test('darkMode', async () => { - const saga = new AsyncSaga(settings); - - saga.updateState({ settings: { darkMode: true } }); - - const mockSetItem = jest - .spyOn(Object.getPrototypeOf(window.localStorage), 'setItem') - .mockImplementation((key, value) => { - expect(key).toBe('setting.darkMode'); - expect(value).toBe('false'); - }); - - saga.put(setBoolean(BooleanSettingId.DarkMode, false)); - expect(mockSetItem).toHaveBeenCalled(); - - const action = await saga.take(); - expect(action).toEqual(didBooleanChange(BooleanSettingId.DarkMode, false)); - - await saga.end(); - }); - test('flashCurrentProgram', async () => { const saga = new AsyncSaga(settings); diff --git a/src/terminal/Terminal.tsx b/src/terminal/Terminal.tsx index e7dfe032..fd7db5b1 100644 --- a/src/terminal/Terminal.tsx +++ b/src/terminal/Terminal.tsx @@ -1,14 +1,14 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { Menu, MenuDivider, MenuItem, ResizeSensor } from '@blueprintjs/core'; import { ContextMenu2, ContextMenu2ContentProps } from '@blueprintjs/popover2'; import { useI18n } from '@shopify/react-i18n'; import React, { useContext, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; +import { useDarkMode } from 'usehooks-ts'; import { Terminal as XTerm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; -import { useSelector } from '../reducers'; import { isMacOS } from '../utils/os'; import { TerminalContext } from './TerminalContext'; import { receiveData } from './actions'; @@ -100,7 +100,7 @@ function createContextMenu( const Terminal: React.FC = (_props) => { const { xterm, fitAddon } = useMemo(createXTerm, [createXTerm]); const terminalRef = useRef(null); - const darkMode = useSelector((s) => s.settings.darkMode); + const { isDarkMode } = useDarkMode(); const dispatch = useDispatch(); const terminalStream = useContext(TerminalContext); @@ -118,16 +118,16 @@ const Terminal: React.FC = (_props) => { return () => xterm.dispose(); }, [xterm]); - // wire up darkMode to terminal + // wire up isDarkMode to terminal useEffect(() => { xterm.options.theme = { - background: darkMode ? 'black' : 'white', - foreground: darkMode ? 'white' : 'black', - cursor: darkMode ? 'white' : 'black', + background: isDarkMode ? 'black' : 'white', + foreground: isDarkMode ? 'white' : 'black', + cursor: isDarkMode ? 'white' : 'black', // transparency is needed to work around https://github.com/xtermjs/xterm.js/issues/2808 - selection: darkMode ? 'rgb(81,81,81,0.5)' : 'rgba(181,213,255,0.5)', // this should match AceEditor theme + selection: isDarkMode ? 'rgb(81,81,81,0.5)' : 'rgba(181,213,255,0.5)', // this should match AceEditor theme }; - }, [darkMode]); + }, [isDarkMode]); const handleKeyDownEvent = (e: KeyboardEvent): void => { // implement CTRL+SHIFT+C keyboard shortcut for copying text from terminal diff --git a/src/utils/os.test.ts b/src/utils/os.test.ts index c21b28da..51346573 100644 --- a/src/utils/os.test.ts +++ b/src/utils/os.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { isAndroid, isMacOS, isWindows, prefersDarkMode } from './os'; +import { isAndroid, isMacOS, isWindows } from './os'; afterEach(() => { jest.resetAllMocks(); @@ -39,18 +39,3 @@ describe('isWindows', () => { expect(isWindows()).toBeFalsy(); }); }); - -describe('prefersDarkMode', () => { - test('is true', () => { - jest.spyOn(window, 'matchMedia').mockReturnValue({ - matches: true, - } as MediaQueryList); - expect(prefersDarkMode()).toBeTruthy(); - }); - test('is false', () => { - jest.spyOn(window, 'matchMedia').mockReturnValue({ - matches: false, - } as MediaQueryList); - expect(prefersDarkMode()).toBeFalsy(); - }); -}); diff --git a/src/utils/os.ts b/src/utils/os.ts index 5fe83dfd..00913d16 100644 --- a/src/utils/os.ts +++ b/src/utils/os.ts @@ -29,11 +29,3 @@ export function isMacOS(): boolean { export function isWindows(): boolean { return /win/i.test(navigator.platform); } - -/** - * Tests if the OS is set to dark mode. - * @returns: `true` if dark mode should be preferred, otherwise `false`. - */ -export function prefersDarkMode(): boolean { - return window.matchMedia('(prefers-color-scheme: dark)').matches; -}